diff --git a/.gitignore b/.gitignore index 87d6491..5dbfa17 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,6 @@ patch_*.sh repair_*.sh inspect_*.sh remove_*.sh +#logs +examples/NMFS/*/outputs/*.log # Editors diff --git a/a.out b/a.out new file mode 100755 index 0000000..a3fc030 Binary files /dev/null and b/a.out differ diff --git a/a.out.dSYM/Contents/Info.plist b/a.out.dSYM/Contents/Info.plist new file mode 100644 index 0000000..3679a65 --- /dev/null +++ b/a.out.dSYM/Contents/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleIdentifier + com.apple.xcode.dsym.a.out + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + dSYM + CFBundleSignature + ???? + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/a.out.dSYM/Contents/Resources/DWARF/a.out b/a.out.dSYM/Contents/Resources/DWARF/a.out new file mode 100644 index 0000000..3d967bd Binary files /dev/null and b/a.out.dSYM/Contents/Resources/DWARF/a.out differ diff --git a/a.out.dSYM/Contents/Resources/Relocations/aarch64/a.out.yml b/a.out.dSYM/Contents/Resources/Relocations/aarch64/a.out.yml new file mode 100644 index 0000000..9c81f13 --- /dev/null +++ b/a.out.dSYM/Contents/Resources/Relocations/aarch64/a.out.yml @@ -0,0 +1,5 @@ +--- +triple: 'arm64-apple-darwin' +binary-path: a.out +relocations: [] +... diff --git a/add_laplace_result_component_fields.sh b/add_laplace_result_component_fields.sh new file mode 100755 index 0000000..8cbcbae --- /dev/null +++ b/add_laplace_result_component_fields.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="core/laplace.hpp" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run this from the Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="${FILE}.before_laplace_result_component_fields.${STAMP}" +cp "$FILE" "$BACKUP" +echo "Backed up $FILE to:" +echo " $BACKUP" + +python3 - <<'PY' +from pathlib import Path +import re + +path = Path("core/laplace.hpp") +text = path.read_text() + +if "joint_objective" in text and "laplace_logdet" in text and "laplace_constant" in text: + print("LaplaceResult component fields already appear to exist. No patch needed.") + raise SystemExit(0) + +m = re.search( + r'(template\s*<\s*typename\s+Model\s*>\s*\n\s*struct\s+LaplaceResult\s*\{)', + text +) +if not m: + m = re.search(r'(struct\s+LaplaceResult\s*\{)', text) + +if not m: + raise RuntimeError("Could not find struct LaplaceResult in core/laplace.hpp") + +insert_at = m.end() + +fields = """\n + // Component breakdown of the Laplace objective: + // + // value = joint_objective + 0.5 * laplace_logdet - laplace_constant + // + // These are intentionally stored for diagnostics/reporting and for + // optimizer-side bookkeeping. They do not change the objective math. + double joint_objective = 0.0; + double laplace_logdet = 0.0; + double laplace_constant = 0.0; +""" + +text = text[:insert_at] + fields + text[insert_at:] +path.write_text(text) +print("Inserted joint_objective, laplace_logdet, and laplace_constant into LaplaceResult.") +PY + +echo +echo "Relevant LaplaceResult region:" +grep -n "struct LaplaceResult\|joint_objective\|laplace_logdet\|laplace_constant" "$FILE" | head -40 + +echo +echo "Done. Rebuild now:" +echo 'clang++ -std=c++17 -g -I"external/eigen/" examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp' diff --git a/add_science_center_validation_roadmap_v1.sh b/add_science_center_validation_roadmap_v1.sh new file mode 100755 index 0000000..96d15de --- /dev/null +++ b/add_science_center_validation_roadmap_v1.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add science-center validation roadmap and SEFSC red-snapper scaffold ==" + +mkdir -p docs/validation +mkdir -p examples/NMFS/sefsc_red_snapper/{data,quadra,tmb,outputs,validation} + +cat > docs/validation/science-center-example-roadmap.md <<'MD' +# Science Center Example Validation Roadmap + +This document tracks a proposed validation suite with one representative assessment-style example from each NOAA Fisheries Science Center. + +The goal is to build examples that are: +- public-data-safe or synthetic, +- reproducible, +- paired with TMB reference implementations where practical, +- documented with expected outputs, +- capable of reporting uncertainty, derived quantities, and projections. + +## Proposed example set + +| Science Center | Example | Status | Main validation target | +|---|---|---:|---| +| PIFSC | Opakapaka projection example | In progress | Projection validation and Level-1 uncertainty reporting | +| SEFSC | Red-snapper-style age-structured model | Scaffolded | Age structure, selectivity, recruitment deviations, projections | +| NEFSC | Groundfish/index-heavy assessment | Planned | Multiple indices, survey likelihoods, retrospective-style diagnostics | +| NWFSC | West Coast age-structured model | Planned | Age composition, selectivity, biological reference points | +| AFSC | Pollock/sablefish-style model | Planned | Recruitment deviations, state-space/random-effect scalability | +| SWFSC | CPS/tuna-style model | Planned | Time-varying dynamics, index scaling, projection scenarios | + +## Shared validation requirements + +Each example should eventually include: + +1. Quadra implementation +2. TMB comparison implementation +3. synthetic or public-data-safe input data +4. reproducible runner +5. fit diagnostics +6. standard errors and confidence intervals +7. random-effect conditional uncertainty +8. derived quantity uncertainty +9. projection envelopes +10. comparison summary against TMB + +## Recommended directory layout + +```text +examples// + README.md + data/ + quadra/ + tmb/ + outputs/ + validation/ +``` + +## Development order + +1. Finish Opakapaka Level-1 uncertainty reporting. +2. Scaffold SEFSC red-snapper-style age-structured model. +3. Add minimal Quadra implementation. +4. Add TMB reference implementation. +5. Add validation summary and uncertainty outputs. +6. Repeat for the remaining science centers. +MD + +cat > examples/NMFS/sefsc_red_snapper/README.md <<'MD' +# SEFSC Red-Snapper-Style Assessment Example + +This directory is a placeholder for a synthetic, public-data-safe red-snapper-style assessment example. + +The goal is not to reproduce an official assessment. The goal is to provide a representative SEFSC-style validation case for Quadra with age structure, selectivity, recruitment deviations, uncertainty reporting, and projections. + +## Planned model features + +- age-structured population dynamics +- catch likelihood +- survey/index likelihood +- age-composition likelihood +- recruitment deviations as random effects +- age-based selectivity +- derived quantities: + - biomass + - spawning biomass proxy + - depletion + - fishing mortality proxy + - MSY-like reference metrics +- projection scenarios +- uncertainty outputs: + - inverse Hessian / covariance + - standard errors + - confidence intervals + - random-effect conditional uncertainty + - derived quantity uncertainty + - projection envelopes + +## Directory layout + +```text +data/ synthetic or public-data-safe inputs +quadra/ Quadra implementation +tmb/ TMB reference implementation +outputs/ generated outputs, ignored by git +validation/ comparison summaries and validation notes +``` + +## Initial validation target + +The first milestone is a minimal working model with: + +1. deterministic age-structured dynamics, +2. one abundance index, +3. synthetic catch observations, +4. recruitment deviations, +5. TMB side-by-side comparison, +6. Level-1 uncertainty outputs. +MD + +cat > examples/NMFS/sefsc_red_snapper/validation/validation_plan.md <<'MD' +# SEFSC Red-Snapper-Style Validation Plan + +## Level 0: deterministic fit + +- Build a minimal deterministic age-structured model. +- Fit fixed effects only. +- Confirm objective value and parameter estimates are stable. + +## Level 1: random effects and uncertainty + +- Add recruitment deviations as random effects. +- Extract conditional random-effect uncertainty. +- Add fixed-effect covariance and confidence intervals. +- Add derived quantity uncertainty. + +## Level 2: TMB comparison + +- Implement matching TMB reference model. +- Compare: + - objective value + - fixed-effect estimates + - random-effect modes + - standard errors + - derived quantities + - projection summaries + +## Level 3: projections + +- Add projection scenarios. +- Report projection envelopes. +- Compare Quadra and TMB projection outputs where feasible. + +## Notes + +This example should remain synthetic or public-data-safe. It should not be presented as an official red snapper assessment. +MD + +cat > examples/NMFS/sefsc_red_snapper/data/README.md <<'MD' +# Data + +Synthetic or public-data-safe input files will live here. + +Do not commit generated outputs or confidential assessment data. +MD + +cat > examples/NMFS/sefsc_red_snapper/quadra/README.md <<'MD' +# Quadra Implementation + +Quadra model source files for the SEFSC red-snapper-style example will live here. +MD + +cat > examples/NMFS/sefsc_red_snapper/tmb/README.md <<'MD' +# TMB Reference Implementation + +TMB comparison files for the SEFSC red-snapper-style example will live here. +MD + +cat > examples/NMFS/sefsc_red_snapper/outputs/.gitignore <<'EOF' +* +!.gitignore +EOF + +echo +echo "Created:" +echo " docs/validation/science-center-example-roadmap.md" +echo " examples/NMFS/sefsc_red_snapper/" +echo +echo "Next:" +echo " git add docs/validation/science-center-example-roadmap.md examples/NMFS/sefsc_red_snapper" +echo " git commit -m \"Add science center validation roadmap and SEFSC scaffold\"" diff --git a/add_sefsc_red_snapper_age_comp_likelihood_v1.sh b/add_sefsc_red_snapper_age_comp_likelihood_v1.sh new file mode 100755 index 0000000..e2c27d1 --- /dev/null +++ b/add_sefsc_red_snapper_age_comp_likelihood_v1.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 - <<'PY' +from pathlib import Path + +p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp") +s = p.read_text() + +s = s.replace( +"""template +T logistic_selectivity_t""", +"""template +T age_comp_nll(const std::array& observed, + const std::array& predicted, + double effective_n, + double floor = 1.0e-12) { + T nll = T(0.0); + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + const double obs = std::max(observed[i], 0.0); + if (obs > 0.0) { + nll = nll - T(effective_n * obs) * log_t(max_t(predicted[i], floor)); + } + } + return nll; +} + +template +T logistic_selectivity_t""", +1) + +s = s.replace( +""" const T sigma_log_catch = T(0.15); + const double min_positive = 1.0e-12;""", +""" const T sigma_log_catch = T(0.15); + const double age_comp_effective_n = 50.0; + const double min_positive = 1.0e-12;""", +1) + +s = s.replace( +""" if (obs.catch_mt > 0.0) { + const T z = (log_t(T(obs.catch_mt)) - + log_t(max_t(catch_hat, min_positive))) / + sigma_log_catch; + nll = nll + T(0.5) * square_t(z); + } + + std::array next{};""", +""" if (obs.catch_mt > 0.0) { + const T z = (log_t(T(obs.catch_mt)) - + log_t(max_t(catch_hat, min_positive))) / + sigma_log_catch; + nll = nll + T(0.5) * square_t(z); + } + + std::array pred_age_comp{}; + T selected_numbers_sum = T(0.0); + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + pred_age_comp[i] = n[i] * selectivity[i]; + selected_numbers_sum = selected_numbers_sum + pred_age_comp[i]; + } + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + pred_age_comp[i] = + pred_age_comp[i] / max_t(selected_numbers_sum, min_positive); + } + + nll = nll + age_comp_nll(obs.age_comp, pred_age_comp, + age_comp_effective_n, min_positive); + + std::array next{};""", +1) + +p.write_text(s) +PY + +cat > examples/NMFS/sefsc_red_snapper/validation/age_composition_likelihood_checklist.md <<'MD' +# Age-Composition Likelihood Checklist + +- [x] predicted selected age composition added +- [x] multinomial-style negative log likelihood added +- [x] fixed effective sample size added +- [ ] selectivity parameters estimated +- [ ] age-composition residuals written +- [ ] Dirichlet-multinomial alternative +MD diff --git a/add_sefsc_red_snapper_age_structured_v1.sh b/add_sefsc_red_snapper_age_structured_v1.sh new file mode 100755 index 0000000..51e67e5 --- /dev/null +++ b/add_sefsc_red_snapper_age_structured_v1.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add SEFSC red snapper deterministic age-structured scaffold ==" + +BASE="examples/NMFS/sefsc_red_snapper" +mkdir -p "$BASE"/{data,quadra,outputs,validation} + +cat > "$BASE/quadra/red_snapper_age_structured.cpp" <<'CPP' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +constexpr int kAges = 10; + +struct Observation { + int year = 0; + double catch_mt = 0.0; + double index = 0.0; + std::array age_comp{}; +}; + +struct AgeStructuredParams { + double log_r0 = std::log(1200.0); + double log_m = std::log(0.18); + double log_fbar = std::log(0.22); + double log_q = std::log(0.001); + double sel_a50 = 4.0; + double sel_slope = 1.2; +}; + +struct AgeStructuredRow { + int year = 0; + double recruitment = 0.0; + double total_biomass = 0.0; + double ssb_proxy = 0.0; + double depletion = 0.0; + double fbar = 0.0; + double catch_obs = 0.0; + double catch_hat = 0.0; + double index_obs = 0.0; + double index_hat = 0.0; +}; + +double logistic_selectivity(double age, double a50, double slope) { + return 1.0 / (1.0 + std::exp(-slope * (age - a50))); +} + +std::array default_weight_at_age() { + return {0.40, 0.85, 1.35, 1.95, 2.60, 3.25, 3.85, 4.35, 4.75, 5.05}; +} + +std::array default_maturity_at_age() { + return {0.00, 0.10, 0.35, 0.65, 0.85, 0.95, 1.00, 1.00, 1.00, 1.00}; +} + +std::vector split_csv_line(const std::string& line) { + std::vector out; + std::stringstream ss(line); + std::string item; + while (std::getline(ss, item, ',')) { + out.push_back(item); + } + return out; +} + +std::vector read_observations(const std::string& path) { + std::ifstream in(path); + if (!in) { + throw std::runtime_error("Could not open observations CSV: " + path); + } + + std::string line; + std::getline(in, line); + + std::vector out; + while (std::getline(in, line)) { + if (line.empty()) { + continue; + } + + const auto fields = split_csv_line(line); + if (fields.size() != 13) { + throw std::runtime_error("Expected 13 columns in observations CSV"); + } + + Observation obs; + obs.year = std::stoi(fields[0]); + obs.catch_mt = std::stod(fields[1]); + obs.index = std::stod(fields[2]); + for (int a = 0; a < kAges; ++a) { + obs.age_comp[static_cast(a)] = std::stod(fields[3 + a]); + } + out.push_back(obs); + } + + return out; +} + +double biomass_from_numbers(const std::array& n, + const std::array& weight) { + double out = 0.0; + for (int a = 0; a < kAges; ++a) { + out += n[static_cast(a)] * weight[static_cast(a)]; + } + return out; +} + +double ssb_from_numbers(const std::array& n, + const std::array& weight, + const std::array& maturity) { + double out = 0.0; + for (int a = 0; a < kAges; ++a) { + out += n[static_cast(a)] * + weight[static_cast(a)] * + maturity[static_cast(a)]; + } + return out; +} + +std::array unfished_equilibrium_numbers(double r0, double m) { + std::array n{}; + n[0] = r0; + for (int a = 1; a < kAges; ++a) { + n[static_cast(a)] = + n[static_cast(a - 1)] * std::exp(-m); + } + + // Plus group. + n[static_cast(kAges - 1)] /= + std::max(1.0e-12, 1.0 - std::exp(-m)); + + return n; +} + +std::vector run_deterministic_age_structured_model( + const std::vector& observations, + const AgeStructuredParams& params) { + const auto weight = default_weight_at_age(); + const auto maturity = default_maturity_at_age(); + + const double r0 = std::exp(params.log_r0); + const double m = std::exp(params.log_m); + const double fbar = std::exp(params.log_fbar); + const double q = std::exp(params.log_q); + + std::array selectivity{}; + for (int a = 0; a < kAges; ++a) { + selectivity[static_cast(a)] = + logistic_selectivity(static_cast(a + 1), params.sel_a50, + params.sel_slope); + } + + std::array n = unfished_equilibrium_numbers(r0, m); + const double unfished_ssb = ssb_from_numbers(n, weight, maturity); + + std::vector rows; + rows.reserve(observations.size()); + + for (const auto& obs : observations) { + const double biomass = biomass_from_numbers(n, weight); + const double ssb = ssb_from_numbers(n, weight, maturity); + + double catch_hat = 0.0; + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + const double f_a = fbar * selectivity[i]; + const double z_a = m + f_a; + const double harvest_rate = + z_a > 0.0 ? (f_a / z_a) * (1.0 - std::exp(-z_a)) : 0.0; + catch_hat += n[i] * weight[i] * harvest_rate; + } + + AgeStructuredRow row; + row.year = obs.year; + row.recruitment = r0; + row.total_biomass = biomass; + row.ssb_proxy = ssb; + row.depletion = ssb / std::max(1.0e-12, unfished_ssb); + row.fbar = fbar; + row.catch_obs = obs.catch_mt; + row.catch_hat = catch_hat; + row.index_obs = obs.index; + row.index_hat = q * biomass; + rows.push_back(row); + + std::array next{}; + next[0] = r0; + + for (int a = 1; a < kAges; ++a) { + const auto prev = static_cast(a - 1); + const double f_prev = fbar * selectivity[prev]; + const double z_prev = m + f_prev; + next[static_cast(a)] = n[prev] * std::exp(-z_prev); + } + + // Plus group survivor contribution. + { + const auto last = static_cast(kAges - 1); + const double f_last = fbar * selectivity[last]; + const double z_last = m + f_last; + next[last] += n[last] * std::exp(-z_last); + } + + n = next; + } + + return rows; +} + +void write_age_structured_rows(const std::string& path, + const std::vector& rows) { + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open output CSV: " + path); + } + + out << "year,recruitment,total_biomass,ssb_proxy,depletion,Fbar," + << "catch_obs,catch_hat,index_obs,index_hat\n"; + + out << std::fixed << std::setprecision(6); + for (const auto& row : rows) { + out << row.year << "," << row.recruitment << "," << row.total_biomass + << "," << row.ssb_proxy << "," << row.depletion << "," + << row.fbar << "," << row.catch_obs << "," << row.catch_hat << "," + << row.index_obs << "," << row.index_hat << "\n"; + } +} + +} // namespace sefsc_red_snapper + +int main() { + const std::string input_path = + "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv"; + const std::string output_path = + "examples/NMFS/sefsc_red_snapper/outputs/age_structured_deterministic_trajectory.csv"; + + const auto observations = sefsc_red_snapper::read_observations(input_path); + + sefsc_red_snapper::AgeStructuredParams params; + const auto rows = + sefsc_red_snapper::run_deterministic_age_structured_model(observations, + params); + + sefsc_red_snapper::write_age_structured_rows(output_path, rows); + + std::cout << "SEFSC red-snapper-style deterministic age-structured model\n"; + std::cout << "observations: " << observations.size() << "\n"; + std::cout << "wrote: " << output_path << "\n"; + + if (!rows.empty()) { + const auto& terminal = rows.back(); + std::cout << "terminal total biomass: " << terminal.total_biomass << "\n"; + std::cout << "terminal SSB proxy: " << terminal.ssb_proxy << "\n"; + std::cout << "terminal depletion: " << terminal.depletion << "\n"; + } + + return 0; +} +CPP + +cat > "$BASE/run_red_snapper_age_structured.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured \ + examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured +SH +chmod +x "$BASE/run_red_snapper_age_structured.sh" + +cat > "$BASE/validation/age_structured_deterministic_checklist.md" <<'MD' +# Deterministic Age-Structured Checklist + +- [x] age classes 1-10+ +- [x] weight-at-age vector +- [x] maturity-at-age vector +- [x] logistic selectivity +- [x] plus group +- [x] catch prediction using Baranov catch equation +- [x] biomass, SSB proxy, depletion, Fbar, index prediction +- [ ] likelihood contributions +- [ ] parameter estimation +- [ ] recruitment deviations +- [ ] Laplace/random-effect treatment +- [ ] TMB comparison +MD + +echo +echo "Added deterministic age-structured model." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_age_structured.sh" +echo " head examples/NMFS/sefsc_red_snapper/outputs/age_structured_deterministic_trajectory.csv" diff --git a/add_sefsc_red_snapper_fitted_trajectory_v1.sh b/add_sefsc_red_snapper_fitted_trajectory_v1.sh new file mode 100755 index 0000000..bb3a3a3 --- /dev/null +++ b/add_sefsc_red_snapper_fitted_trajectory_v1.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add fitted trajectory output to SEFSC red snapper Quadra fit ==" + +python3 - <<'PY' +from pathlib import Path + +p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp") +s = p.read_text() + +marker = "} // namespace\n\nint main()" +if "write_fitted_trajectory" not in s: + helper = r''' + +void write_fitted_trajectory( + const std::string& path, + const std::vector& observations, + const quadra::OptResult& fit) { + if (fit.par.size() < 3) { + throw std::runtime_error("Cannot write fitted trajectory: expected at least 3 fixed parameters"); + } + + sefsc_red_snapper::AgeStructuredParams params; + params.log_r0 = fit.par[0]; + params.log_fbar = fit.par[1]; + params.log_q = fit.par[2]; + + const auto rows = + sefsc_red_snapper::run_deterministic_age_structured_model(observations, + params); + + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open fitted trajectory CSV: " + path); + } + + out << "year,recruitment,total_biomass,ssb_proxy,depletion,Fbar," + << "catch_obs,catch_hat,catch_log_residual,index_obs,index_hat," + << "index_log_residual\n"; + + out << std::fixed << std::setprecision(6); + + for (const auto& row : rows) { + const double catch_log_residual = + std::log(std::max(row.catch_obs, 1.0e-12)) - + std::log(std::max(row.catch_hat, 1.0e-12)); + const double index_log_residual = + std::log(std::max(row.index_obs, 1.0e-12)) - + std::log(std::max(row.index_hat, 1.0e-12)); + + out << row.year << "," << row.recruitment << "," << row.total_biomass + << "," << row.ssb_proxy << "," << row.depletion << "," + << row.fbar << "," << row.catch_obs << "," << row.catch_hat + << "," << catch_log_residual << "," << row.index_obs << "," + << row.index_hat << "," << index_log_residual << "\n"; + } +} +''' + if marker not in s: + raise SystemExit("Could not find helper insertion marker") + s = s.replace(marker, helper + "\n\n" + marker) + +old = ''' const std::string summary_path = + "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv"; +''' +new = ''' const std::string summary_path = + "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv"; + const std::string trajectory_path = + "examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv"; +''' +if new not in s: + if old not in s: + raise SystemExit("Could not find summary_path block") + s = s.replace(old, new) + +old = ''' sefsc_red_snapper::write_fit_summary(summary_path, fit); + + std::cout << "SEFSC red-snapper-style Quadra fixed-effect fit\\n"; +''' +new = ''' sefsc_red_snapper::write_fit_summary(summary_path, fit); + sefsc_red_snapper::write_fitted_trajectory(trajectory_path, observations, fit); + + std::cout << "SEFSC red-snapper-style Quadra fixed-effect fit\\n"; +''' +if new not in s: + if old not in s: + raise SystemExit("Could not find write_fit_summary call") + s = s.replace(old, new) + +old = ''' std::cout << "wrote: " << summary_path << "\\n"; +''' +new = ''' std::cout << "wrote: " << summary_path << "\\n"; + std::cout << "wrote: " << trajectory_path << "\\n"; +''' +if new not in s: + if old not in s: + raise SystemExit("Could not find summary print") + s = s.replace(old, new) + +p.write_text(s) +PY + +cat > examples/NMFS/sefsc_red_snapper/validation/fitted_trajectory_checklist.md <<'MD' +# Fitted Trajectory Checklist + +- [x] fixed-effect fit summary written +- [x] fitted deterministic trajectory written +- [x] observed catch and predicted catch included +- [x] observed index and predicted index included +- [x] log residuals included +- [ ] residual diagnostics summary +- [ ] fitted trajectory plotted +- [ ] age-composition likelihood added +MD + +echo +echo "Patched fitted trajectory output." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh" +echo " head examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv" diff --git a/add_sefsc_red_snapper_level0_scaffold_v1.sh b/add_sefsc_red_snapper_level0_scaffold_v1.sh new file mode 100755 index 0000000..962b25d --- /dev/null +++ b/add_sefsc_red_snapper_level0_scaffold_v1.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add SEFSC red snapper synthetic data and initial model scaffold ==" + +BASE="examples/NMFS/sefsc_red_snapper" +mkdir -p "$BASE"/{data,quadra,tmb,outputs,validation} + +cat > "$BASE/data/synthetic_red_snapper_observations.csv" <<'CSV' +year,catch_mt,index,age1,age2,age3,age4,age5,age6,age7,age8,age9,age10 +1,220,0.82,0.18,0.21,0.19,0.15,0.10,0.07,0.04,0.03,0.02,0.01 +2,230,0.86,0.17,0.22,0.19,0.15,0.10,0.07,0.04,0.03,0.02,0.01 +3,245,0.89,0.16,0.22,0.20,0.15,0.10,0.07,0.04,0.03,0.02,0.01 +4,260,0.91,0.15,0.21,0.21,0.16,0.10,0.07,0.04,0.03,0.02,0.01 +5,275,0.93,0.15,0.20,0.21,0.16,0.11,0.07,0.04,0.03,0.02,0.01 +6,290,0.95,0.14,0.20,0.21,0.17,0.11,0.07,0.04,0.03,0.02,0.01 +7,305,0.96,0.14,0.19,0.21,0.17,0.11,0.08,0.04,0.03,0.02,0.01 +8,315,0.94,0.13,0.19,0.21,0.17,0.12,0.08,0.04,0.03,0.02,0.01 +9,320,0.91,0.13,0.18,0.21,0.18,0.12,0.08,0.04,0.03,0.02,0.01 +10,330,0.88,0.12,0.18,0.21,0.18,0.12,0.08,0.05,0.03,0.02,0.01 +11,335,0.84,0.12,0.17,0.21,0.18,0.13,0.08,0.05,0.03,0.02,0.01 +12,340,0.81,0.11,0.17,0.20,0.19,0.13,0.09,0.05,0.03,0.02,0.01 +13,330,0.80,0.12,0.17,0.20,0.18,0.13,0.09,0.05,0.03,0.02,0.01 +14,320,0.82,0.13,0.18,0.20,0.18,0.12,0.09,0.05,0.03,0.02,0.01 +15,310,0.85,0.14,0.18,0.20,0.17,0.12,0.08,0.05,0.03,0.02,0.01 +16,300,0.89,0.15,0.19,0.20,0.17,0.11,0.08,0.05,0.03,0.02,0.01 +17,295,0.93,0.16,0.19,0.20,0.16,0.11,0.08,0.05,0.03,0.02,0.01 +18,285,0.97,0.17,0.20,0.19,0.16,0.10,0.08,0.05,0.03,0.02,0.01 +19,275,1.01,0.18,0.20,0.19,0.15,0.10,0.08,0.05,0.03,0.01,0.01 +20,265,1.04,0.19,0.21,0.18,0.15,0.10,0.07,0.05,0.03,0.01,0.01 +CSV + +cat > "$BASE/data/red_snapper_projection_scenarios.csv" <<'CSV' +scenario,projection_year,catch_mt +zero_catch,21,0 +zero_catch,22,0 +zero_catch,23,0 +zero_catch,24,0 +zero_catch,25,0 +status_quo,21,265 +status_quo,22,265 +status_quo,23,265 +status_quo,24,265 +status_quo,25,265 +high_catch,21,340 +high_catch,22,340 +high_catch,23,340 +high_catch,24,340 +high_catch,25,340 +CSV + +cat > "$BASE/quadra/red_snapper_model.hpp" <<'CPP' +#pragma once + +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +struct Observation { + int year = 0; + double catch_mt = 0.0; + double index = 0.0; + std::array age_comp{}; +}; + +struct ProjectionScenario { + std::string scenario; + int projection_year = 0; + double catch_mt = 0.0; +}; + +struct DerivedRow { + int year = 0; + double biomass = 0.0; + double ssb_proxy = 0.0; + double depletion = 0.0; + double f_proxy = 0.0; + double index_hat = 0.0; +}; + +// Level-0 placeholder model: +// This is intentionally minimal. The next patch should replace this with +// Quadra AD/Laplace evaluation and recruitment deviations as random effects. +class RedSnapperModel { + public: + explicit RedSnapperModel(std::vector obs) + : observations_(std::move(obs)) {} + + const std::vector& observations() const { return observations_; } + + std::vector deterministic_trajectory(double log_r0, + double log_q, + double log_f) const { + const double r0 = std::exp(log_r0); + const double q = std::exp(log_q); + const double f = std::exp(log_f); + + std::vector out; + out.reserve(observations_.size()); + + double biomass = r0; + const double unfished = r0; + + for (const auto& obs : observations_) { + biomass = std::max(1.0, biomass + 0.25 * r0 - obs.catch_mt - 0.05 * biomass); + DerivedRow row; + row.year = obs.year; + row.biomass = biomass; + row.ssb_proxy = 0.35 * biomass; + row.depletion = biomass / unfished; + row.f_proxy = f * obs.catch_mt / std::max(1.0, biomass); + row.index_hat = q * biomass; + out.push_back(row); + } + + return out; + } + + private: + std::vector observations_; +}; + +} // namespace sefsc_red_snapper +CPP + +cat > "$BASE/quadra/red_snapper_level0.cpp" <<'CPP' +#include "red_snapper_model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::vector split_csv_line(const std::string& line) { + std::vector out; + std::stringstream ss(line); + std::string item; + while (std::getline(ss, item, ',')) { + out.push_back(item); + } + return out; +} + +std::vector read_observations(const std::string& path) { + std::ifstream in(path); + if (!in) { + throw std::runtime_error("Could not open observations CSV: " + path); + } + + std::string line; + std::getline(in, line); // header + + std::vector out; + while (std::getline(in, line)) { + if (line.empty()) continue; + const auto fields = split_csv_line(line); + if (fields.size() != 13) { + throw std::runtime_error("Expected 13 columns in observations CSV"); + } + + sefsc_red_snapper::Observation obs; + obs.year = std::stoi(fields[0]); + obs.catch_mt = std::stod(fields[1]); + obs.index = std::stod(fields[2]); + for (std::size_t a = 0; a < obs.age_comp.size(); ++a) { + obs.age_comp[a] = std::stod(fields[3 + a]); + } + out.push_back(obs); + } + return out; +} + +void write_derived_quantities( + const std::string& path, + const std::vector& rows) { + std::ofstream out(path); + out << "year,biomass,ssb_proxy,depletion,F_proxy,index_hat\n"; + out << std::fixed << std::setprecision(6); + for (const auto& row : rows) { + out << row.year << "," << row.biomass << "," << row.ssb_proxy << "," + << row.depletion << "," << row.f_proxy << "," << row.index_hat << "\n"; + } +} + +} // namespace + +int main() { + const std::string input_path = + "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv"; + const std::string output_path = + "examples/NMFS/sefsc_red_snapper/outputs/level0_derived_quantities.csv"; + + auto observations = read_observations(input_path); + sefsc_red_snapper::RedSnapperModel model(observations); + + // Fixed placeholder values. Next patch should estimate these. + const double log_r0 = std::log(1400.0); + const double log_q = std::log(0.001); + const double log_f = std::log(0.25); + + auto trajectory = model.deterministic_trajectory(log_r0, log_q, log_f); + write_derived_quantities(output_path, trajectory); + + std::cout << "SEFSC red-snapper-style Level-0 scaffold\n"; + std::cout << "observations: " << observations.size() << "\n"; + std::cout << "wrote: " << output_path << "\n"; + + if (!trajectory.empty()) { + const auto& last = trajectory.back(); + std::cout << "terminal biomass: " << last.biomass << "\n"; + std::cout << "terminal depletion: " << last.depletion << "\n"; + } + + return 0; +} +CPP + +cat > "$BASE/tmb/red_snapper_tmb.cpp" <<'CPP' +// Placeholder TMB reference implementation for the SEFSC red-snapper-style example. +// +// The next milestone should implement the same likelihood and derived quantities +// as the Quadra model so objective values, estimates, random effects, and +// uncertainty outputs can be compared side by side. + +template +Type objective_function::operator()() { + return Type(0.0); +} +CPP + +cat > "$BASE/tmb/README.md" <<'MD' +# TMB Reference Implementation + +This directory will contain the TMB comparison model for the SEFSC red-snapper-style example. + +The current file is a placeholder and should not be used as a scientific reference yet. +MD + +cat > "$BASE/validation/level0_checklist.md" <<'MD' +# Level-0 Checklist + +- [ ] deterministic age-structured dynamics implemented +- [ ] synthetic catch observations read from `data/` +- [ ] synthetic index observations read from `data/` +- [ ] derived quantities written to `outputs/` +- [ ] minimal runner compiles from a clean checkout +- [ ] TMB reference implementation added +- [ ] Quadra/TMB comparison table added +MD + +cat > "$BASE/outputs/.gitignore" <<'EOF' +* +!.gitignore +EOF + +cat > "$BASE/run_red_snapper_level0.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 \ + examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 +SH +chmod +x "$BASE/run_red_snapper_level0.sh" + +echo +echo "Created initial SEFSC red snapper scaffold." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_level0.sh" +echo +echo "Then inspect:" +echo " head examples/NMFS/sefsc_red_snapper/outputs/level0_derived_quantities.csv" diff --git a/add_sefsc_red_snapper_objective_v1.sh b/add_sefsc_red_snapper_objective_v1.sh new file mode 100755 index 0000000..3e1a027 --- /dev/null +++ b/add_sefsc_red_snapper_objective_v1.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add SEFSC red snapper objective function scaffold ==" + +BASE="examples/NMFS/sefsc_red_snapper" +mkdir -p "$BASE"/{quadra,outputs,validation} + +cat > "$BASE/quadra/red_snapper_objective.hpp" <<'CPP' +#pragma once + +#include "red_snapper_age_structured.hpp" + +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +struct ObjectiveOptions { + double sigma_log_index = 0.20; + double sigma_log_catch = 0.15; + double min_positive = 1.0e-12; +}; + +struct ObjectiveBreakdown { + double total = 0.0; + double index_nll = 0.0; + double catch_nll = 0.0; + int n_index = 0; + int n_catch = 0; +}; + +inline double square(double x) { return x * x; } + +inline double lognormal_nll_no_constant(double observed, double predicted, + double sigma, double min_positive) { + const double obs = std::max(observed, min_positive); + const double pred = std::max(predicted, min_positive); + const double z = (std::log(obs) - std::log(pred)) / sigma; + return 0.5 * square(z); +} + +inline ObjectiveBreakdown evaluate_objective_breakdown( + const std::vector& observations, + const AgeStructuredParams& params, + const ObjectiveOptions& options = ObjectiveOptions{}) { + ObjectiveBreakdown out; + + const auto rows = run_deterministic_age_structured_model(observations, params); + if (rows.size() != observations.size()) { + throw std::runtime_error("Objective trajectory/observation size mismatch"); + } + + for (std::size_t i = 0; i < observations.size(); ++i) { + const auto& obs = observations[i]; + const auto& pred = rows[i]; + + if (std::isfinite(obs.index) && obs.index > 0.0) { + const double nll = lognormal_nll_no_constant( + obs.index, pred.index_hat, options.sigma_log_index, + options.min_positive); + out.index_nll += nll; + ++out.n_index; + } + + if (std::isfinite(obs.catch_mt) && obs.catch_mt > 0.0) { + const double nll = lognormal_nll_no_constant( + obs.catch_mt, pred.catch_hat, options.sigma_log_catch, + options.min_positive); + out.catch_nll += nll; + ++out.n_catch; + } + } + + out.total = out.index_nll + out.catch_nll; + return out; +} + +inline double evaluate_objective( + const std::vector& observations, + const AgeStructuredParams& params, + const ObjectiveOptions& options = ObjectiveOptions{}) { + return evaluate_objective_breakdown(observations, params, options).total; +} + +} // namespace sefsc_red_snapper +CPP + +# Split reusable age-structured model logic into a header if it does not exist yet. +if [[ ! -f "$BASE/quadra/red_snapper_age_structured.hpp" ]]; then + python3 - <<'PY' +from pathlib import Path + +cpp = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp") +hpp = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.hpp") + +s = cpp.read_text() +marker = "} // namespace sefsc_red_snapper\n\nint main()" +idx = s.find(marker) +if idx < 0: + raise SystemExit("Could not split red_snapper_age_structured.cpp into header") + +header_body = s[: idx + len("} // namespace sefsc_red_snapper\n")] +header_body = header_body.replace('#include \n', '') +header = "#pragma once\n\n" + header_body +hpp.write_text(header) + +main_part = s[idx + len("} // namespace sefsc_red_snapper\n\n"):] +new_cpp = '#include "red_snapper_age_structured.hpp"\n\n#include \n\n' + main_part +cpp.write_text(new_cpp) +print("created", hpp) +print("rewrote", cpp) +PY +fi + +cat > "$BASE/quadra/evaluate_red_snapper_objective.cpp" <<'CPP' +#include "red_snapper_objective.hpp" + +#include +#include +#include +#include +#include + +namespace { + +void write_objective_summary( + const std::string& path, + const sefsc_red_snapper::ObjectiveBreakdown& obj, + const sefsc_red_snapper::AgeStructuredParams& params) { + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open objective summary CSV: " + path); + } + + out << "field,value\n"; + out << std::setprecision(12); + out << "objective_total," << obj.total << "\n"; + out << "index_nll," << obj.index_nll << "\n"; + out << "catch_nll," << obj.catch_nll << "\n"; + out << "n_index," << obj.n_index << "\n"; + out << "n_catch," << obj.n_catch << "\n"; + out << "log_r0," << params.log_r0 << "\n"; + out << "r0," << std::exp(params.log_r0) << "\n"; + out << "log_m," << params.log_m << "\n"; + out << "m," << std::exp(params.log_m) << "\n"; + out << "log_fbar," << params.log_fbar << "\n"; + out << "fbar," << std::exp(params.log_fbar) << "\n"; + out << "log_q," << params.log_q << "\n"; + out << "q," << std::exp(params.log_q) << "\n"; + out << "sel_a50," << params.sel_a50 << "\n"; + out << "sel_slope," << params.sel_slope << "\n"; +} + +} // namespace + +int main() { + const std::string input_path = + "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv"; + const std::string summary_path = + "examples/NMFS/sefsc_red_snapper/outputs/objective_summary.csv"; + + const auto observations = sefsc_red_snapper::read_observations(input_path); + + sefsc_red_snapper::AgeStructuredParams params; + sefsc_red_snapper::ObjectiveOptions options; + + const auto breakdown = + sefsc_red_snapper::evaluate_objective_breakdown(observations, params, + options); + + write_objective_summary(summary_path, breakdown, params); + + std::cout << "SEFSC red-snapper-style objective scaffold\n"; + std::cout << "objective_total: " << breakdown.total << "\n"; + std::cout << "index_nll: " << breakdown.index_nll << "\n"; + std::cout << "catch_nll: " << breakdown.catch_nll << "\n"; + std::cout << "wrote: " << summary_path << "\n"; + + return 0; +} +CPP + +cat > "$BASE/run_red_snapper_objective.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -o examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective \ + examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective +SH +chmod +x "$BASE/run_red_snapper_objective.sh" + +cat > "$BASE/validation/objective_checklist.md" <<'MD' +# Objective Function Checklist + +- [x] lognormal index likelihood +- [x] lognormal catch likelihood +- [x] objective breakdown output +- [x] reusable objective header +- [ ] parameter optimization +- [ ] age-composition likelihood +- [ ] recruitment-deviation prior +- [ ] TMB objective parity +MD + +echo +echo "Added objective scaffold." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_objective.sh" +echo " cat examples/NMFS/sefsc_red_snapper/outputs/objective_summary.csv" diff --git a/add_sefsc_red_snapper_quadra_fit_v1.sh b/add_sefsc_red_snapper_quadra_fit_v1.sh new file mode 100755 index 0000000..d352403 --- /dev/null +++ b/add_sefsc_red_snapper_quadra_fit_v1.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add SEFSC red snapper Quadra optimizer adapter ==" + +BASE="examples/NMFS/sefsc_red_snapper" +mkdir -p "$BASE"/{quadra,outputs,validation} + +cat > "$BASE/quadra/red_snapper_quadra_fit.cpp" <<'CPP' +#include "red_snapper_age_structured.hpp" + +#include "../../../core/optimizer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +template +T exp_t(const T& x) { + using std::exp; + return exp(x); +} + +template +T log_t(const T& x) { + using std::log; + return log(x); +} + +template +T max_t(const T& x, double floor) { + return x > T(floor) ? x : T(floor); +} + +template +T square_t(const T& x) { + return x * x; +} + +template +T logistic_selectivity_t(const T& age, const T& a50, const T& slope) { + return T(1.0) / (T(1.0) + exp_t(-slope * (age - a50))); +} + +class RedSnapperQuadraObjective { + public: + explicit RedSnapperQuadraObjective(std::vector observations) + : observations_(std::move(observations)) {} + + template + T operator()(const std::vector& par) const { + if (par.size() < 3) { + throw std::runtime_error( + "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q"); + } + + const T log_r0 = par[0]; + const T log_fbar = par[1]; + const T log_q = par[2]; + + const T r0 = exp_t(log_r0); + const T m = T(0.18); + const T fbar = exp_t(log_fbar); + const T q = exp_t(log_q); + + const T sigma_log_index = T(0.20); + const T sigma_log_catch = T(0.15); + const double min_positive = 1.0e-12; + + const auto weight = default_weight_at_age(); + const auto maturity = default_maturity_at_age(); + + std::array selectivity{}; + for (int a = 0; a < kAges; ++a) { + selectivity[static_cast(a)] = + logistic_selectivity_t(T(a + 1), T(4.0), T(1.2)); + } + + std::array n{}; + n[0] = r0; + for (int a = 1; a < kAges; ++a) { + n[static_cast(a)] = + n[static_cast(a - 1)] * exp_t(-m); + } + n[static_cast(kAges - 1)] = + n[static_cast(kAges - 1)] / + (T(1.0) - exp_t(-m)); + + T nll = T(0.0); + + for (const auto& obs : observations_) { + T biomass = T(0.0); + for (int a = 0; a < kAges; ++a) { + biomass = biomass + + n[static_cast(a)] * + T(weight[static_cast(a)]); + } + + T catch_hat = T(0.0); + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + const T f_a = fbar * selectivity[i]; + const T z_a = m + f_a; + const T harvest_rate = + (f_a / z_a) * (T(1.0) - exp_t(-z_a)); + catch_hat = catch_hat + n[i] * T(weight[i]) * harvest_rate; + } + + const T index_hat = q * biomass; + + if (obs.index > 0.0) { + const T z = (log_t(T(obs.index)) - + log_t(max_t(index_hat, min_positive))) / + sigma_log_index; + nll = nll + T(0.5) * square_t(z); + } + + if (obs.catch_mt > 0.0) { + const T z = (log_t(T(obs.catch_mt)) - + log_t(max_t(catch_hat, min_positive))) / + sigma_log_catch; + nll = nll + T(0.5) * square_t(z); + } + + std::array next{}; + next[0] = r0; + + for (int a = 1; a < kAges; ++a) { + const auto prev = static_cast(a - 1); + const T f_prev = fbar * selectivity[prev]; + const T z_prev = m + f_prev; + next[static_cast(a)] = n[prev] * exp_t(-z_prev); + } + + const auto last = static_cast(kAges - 1); + const T f_last = fbar * selectivity[last]; + const T z_last = m + f_last; + next[last] = next[last] + n[last] * exp_t(-z_last); + + n = next; + } + + return nll; + } + + private: + std::vector observations_; +}; + +void write_fit_summary(const std::string& path, + const quadra::OptResult& fit) { + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open fit summary CSV: " + path); + } + + out << "field,value\n"; + out << std::setprecision(12); + out << "objective," << fit.value << "\n"; + out << "grad_norm," << fit.grad_norm << "\n"; + out << "iterations," << fit.iterations << "\n"; + out << "converged," << (fit.converged ? "yes" : "no") << "\n"; + out << "message," << fit.message << "\n"; + + if (fit.par.size() >= 3) { + out << "log_r0," << fit.par[0] << "\n"; + out << "r0," << std::exp(fit.par[0]) << "\n"; + out << "log_fbar," << fit.par[1] << "\n"; + out << "fbar," << std::exp(fit.par[1]) << "\n"; + out << "log_q," << fit.par[2] << "\n"; + out << "q," << std::exp(fit.par[2]) << "\n"; + } +} + +} // namespace sefsc_red_snapper + +int main() { + const std::string input_path = + "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv"; + const std::string summary_path = + "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv"; + + const auto observations = sefsc_red_snapper::read_observations(input_path); + + sefsc_red_snapper::RedSnapperQuadraObjective objective(observations); + + quadra::ParameterVector params; + params.add_fixed("log_r0", std::log(1200.0)); + params.add_fixed("log_fbar", std::log(0.025)); + params.add_fixed("log_q", std::log(0.00005)); + + quadra::LaplaceOptions opts; + opts.max_outer_iterations = 200; + opts.fixed_grad_tol = 1.0e-8; + + auto fit = quadra::optimize_lbfgs(objective, params, opts); + + sefsc_red_snapper::write_fit_summary(summary_path, fit); + + std::cout << "SEFSC red-snapper-style Quadra fixed-effect fit\n"; + std::cout << "objective: " << fit.value << "\n"; + std::cout << "grad_norm: " << fit.grad_norm << "\n"; + std::cout << "converged: " << (fit.converged ? "yes" : "no") << "\n"; + std::cout << "message: " << fit.message << "\n"; + std::cout << "wrote: " << summary_path << "\n"; + + return 0; +} +CPP + +cat > "$BASE/run_red_snapper_quadra_fit.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -Iexternal/LBFGSpp/include \ + -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit \ + examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit +SH +chmod +x "$BASE/run_red_snapper_quadra_fit.sh" + +cat > "$BASE/validation/quadra_fit_checklist.md" <<'MD' +# Quadra Fit Checklist + +- [x] fixed-effect objective adapter added +- [x] Quadra optimizer path used +- [ ] fixed-effect fit compiles against current local API +- [ ] fit summary output validated +- [ ] derived trajectory generated at fitted parameters +- [ ] age-composition likelihood added +- [ ] recruitment deviations added as random effects +- [ ] Laplace uncertainty added +MD + +echo +echo "Added Quadra optimizer adapter." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh" +echo +echo "If the compile fails, inspect the current optimizer API with:" +echo " grep -R \"add_fixed\\|optimize_lbfgs\\|struct OptResult\\|struct LaplaceOptions\" -n core examples | head -80" diff --git a/add_sefsc_red_snapper_recruitment_devs_v1.sh b/add_sefsc_red_snapper_recruitment_devs_v1.sh new file mode 100755 index 0000000..ff69f7d --- /dev/null +++ b/add_sefsc_red_snapper_recruitment_devs_v1.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add recruitment deviations as random effects to SEFSC red snapper Quadra fit ==" + +python3 - <<'PY' +from pathlib import Path + +p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp") +s = p.read_text() + +s = s.replace( +''' if (par.size() < 5) + { + throw std::runtime_error( + "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q, logit_sel_a50, log_sel_slope"); + } +''', +''' if (par.size() < 5 + observations_.size()) + { + throw std::runtime_error( + "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q, logit_sel_a50, log_sel_slope, log_rec_dev[year]"); + } +''', +1) + +s = s.replace( +''' const T sigma_log_index = T(0.20); + const T sigma_log_catch = T(0.15); +''', +''' const T sigma_log_index = T(0.20); + const T sigma_log_catch = T(0.15); + const T sigma_rec_dev = T(0.35); +''', +1) + +old = ''' nll = nll + normal_prior(log_sel_slope, std::log(1.2), 0.35); + + for (const auto& obs : observations_) { +''' +new = ''' nll = nll + normal_prior(log_sel_slope, std::log(1.2), 0.35); + + for (std::size_t t = 0; t < observations_.size(); ++t) { + const auto& obs = observations_[t]; + const T rec_dev = par[5 + t]; + nll = nll + T(0.5) * square_t(rec_dev / sigma_rec_dev); +''' +if old not in s: + raise SystemExit("Could not find start of observation loop / prior block") +s = s.replace(old, new, 1) + +s = s.replace( +''' std::array next{}; + next[0] = r0; +''', +''' std::array next{}; + next[0] = r0 * exp_t(rec_dev); +''', +1) + +if 'log_rec_dev_' not in s: + anchor = ''' params.add({"log_sel_slope", std::log(1.2), quadra::ParameterTransform::Identity, false}); +''' + insert = anchor + ''' + for (std::size_t t = 0; t < observations.size(); ++t) { + params.add({"log_rec_dev_" + std::to_string(t + 1), + 0.0, + quadra::ParameterTransform::Identity, + true}); + } +''' + if anchor not in s: + raise SystemExit("Could not find log_sel_slope params.add anchor") + s = s.replace(anchor, insert, 1) + +p.write_text(s) +PY + +cat > examples/NMFS/sefsc_red_snapper/validation/recruitment_deviation_laplace_checklist.md <<'MD' +# Recruitment-Deviation Laplace Checklist + +- [x] one recruitment deviation random effect per fitted year +- [x] Gaussian recruitment-deviation prior +- [x] annual recruitment uses exp(log_rec_dev_t) +- [x] random effects passed through Quadra ParameterVector +- [ ] fitted recruitment deviations written +- [ ] random-effect trajectory written +- [ ] biomass/depletion uncertainty +- [ ] selected inverse diagnostics +MD + +echo +echo "Patched recruitment deviations as random effects." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh" +echo " cat examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv" diff --git a/add_sefsc_red_snapper_residual_diagnostics_v1.sh b/add_sefsc_red_snapper_residual_diagnostics_v1.sh new file mode 100755 index 0000000..ff0d3bb --- /dev/null +++ b/add_sefsc_red_snapper_residual_diagnostics_v1.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 - <<'PY' +from pathlib import Path + +p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp") +s = p.read_text() + +if "ResidualDiagnostics" not in s: + helper = r''' +struct ResidualDiagnostics { + int n = 0; + double catch_rmse_log = 0.0; + double index_rmse_log = 0.0; + double catch_mean_log_residual = 0.0; + double index_mean_log_residual = 0.0; + double max_abs_catch_log_residual = 0.0; + double max_abs_index_log_residual = 0.0; +}; + +void write_residual_diagnostics( + const std::string& path, + const std::vector& observations, + const quadra::OptResult& fit) { + sefsc_red_snapper::AgeStructuredParams params; + params.log_r0 = fit.par[0]; + params.log_fbar = fit.par[1]; + params.log_q = fit.par[2]; + + const auto rows = + sefsc_red_snapper::run_deterministic_age_structured_model(observations, + params); + + ResidualDiagnostics d; + d.n = static_cast(rows.size()); + + double catch_sum = 0.0, catch_ss = 0.0; + double index_sum = 0.0, index_ss = 0.0; + + for (const auto& row : rows) { + const double cr = std::log(std::max(row.catch_obs, 1.0e-12)) - + std::log(std::max(row.catch_hat, 1.0e-12)); + const double ir = std::log(std::max(row.index_obs, 1.0e-12)) - + std::log(std::max(row.index_hat, 1.0e-12)); + + catch_sum += cr; + catch_ss += cr * cr; + index_sum += ir; + index_ss += ir * ir; + + d.max_abs_catch_log_residual = + std::max(d.max_abs_catch_log_residual, std::abs(cr)); + d.max_abs_index_log_residual = + std::max(d.max_abs_index_log_residual, std::abs(ir)); + } + + if (d.n > 0) { + d.catch_mean_log_residual = catch_sum / d.n; + d.index_mean_log_residual = index_sum / d.n; + d.catch_rmse_log = std::sqrt(catch_ss / d.n); + d.index_rmse_log = std::sqrt(index_ss / d.n); + } + + std::ofstream out(path); + out << "metric,value,note\n"; + out << std::setprecision(12); + out << "n," << d.n << ",number of fitted years\n"; + out << "catch_rmse_log," << d.catch_rmse_log << ",root mean squared log catch residual\n"; + out << "index_rmse_log," << d.index_rmse_log << ",root mean squared log index residual\n"; + out << "catch_mean_log_residual," << d.catch_mean_log_residual << ",mean log observed minus predicted catch\n"; + out << "index_mean_log_residual," << d.index_mean_log_residual << ",mean log observed minus predicted index\n"; + out << "max_abs_catch_log_residual," << d.max_abs_catch_log_residual << ",maximum absolute log catch residual\n"; + out << "max_abs_index_log_residual," << d.max_abs_index_log_residual << ",maximum absolute log index residual\n"; +} +''' + s = s.replace("\nint main()", "\n" + helper + "\nint main()", 1) + +s = s.replace( + ' const std::string trajectory_path =\n' + ' "examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv";', + ' const std::string trajectory_path =\n' + ' "examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv";\n' + ' const std::string residual_diagnostics_path =\n' + ' "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_residual_diagnostics.csv";', + 1, +) + +s = s.replace( + " write_fitted_trajectory(trajectory_path, observations, fit);\n", + " write_fitted_trajectory(trajectory_path, observations, fit);\n" + " write_residual_diagnostics(residual_diagnostics_path, observations, fit);\n", + 1, +) + +s = s.replace( + ' std::cout << "wrote: " << trajectory_path << "\\n";\n', + ' std::cout << "wrote: " << trajectory_path << "\\n";\n' + ' std::cout << "wrote: " << residual_diagnostics_path << "\\n";\n', + 1, +) + +p.write_text(s) +PY diff --git a/add_sefsc_red_snapper_selectivity_estimation_v1.sh b/add_sefsc_red_snapper_selectivity_estimation_v1.sh new file mode 100755 index 0000000..2701aab --- /dev/null +++ b/add_sefsc_red_snapper_selectivity_estimation_v1.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add estimated selectivity parameters to SEFSC red snapper Quadra fit ==" + +python3 - <<'PY' +from pathlib import Path + +p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp") +s = p.read_text() + +if "invlogit_t" not in s: + old = """template +T log_t(const T& x) { + using std::log; + return log(x); +} + +template +T max_t""" + new = """template +T log_t(const T& x) { + using std::log; + return log(x); +} + +template +T invlogit_t(const T& x) { + return T(1.0) / (T(1.0) + exp_t(-x)); +} + +template +T max_t""" + if old not in s: + raise SystemExit("Could not find log_t block") + s = s.replace(old, new, 1) + +s = s.replace( +""" if (par.size() < 3) { + throw std::runtime_error( + "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q"); + } + + const T log_r0 = par[0]; + const T log_fbar = par[1]; + const T log_q = par[2]; +""", +""" if (par.size() < 5) { + throw std::runtime_error( + "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q, logit_sel_a50, log_sel_slope"); + } + + const T log_r0 = par[0]; + const T log_fbar = par[1]; + const T log_q = par[2]; + const T logit_sel_a50 = par[3]; + const T log_sel_slope = par[4]; +""", +1) + +s = s.replace( +""" const T q = exp_t(log_q); + + const T sigma_log_index = T(0.20); +""", +""" const T q = exp_t(log_q); + const T sel_a50 = T(1.0) + T(9.0) * invlogit_t(logit_sel_a50); + const T sel_slope = exp_t(log_sel_slope); + + const T sigma_log_index = T(0.20); +""", +1) + +s = s.replace( +" logistic_selectivity_t(T(a + 1), T(4.0), T(1.2));", +" logistic_selectivity_t(T(a + 1), sel_a50, sel_slope);", +1) + +# Only add regularization if not already added. +if "normal_penalty" not in s: + s = s.replace( +""" T nll = T(0.0); + + for (const auto& obs : observations_) { +""", +""" T nll = T(0.0); + + auto normal_penalty = [](const T& x, double mean, double sd) { + const T z = (x - T(mean)) / T(sd); + return T(0.5) * z * z; + }; + + nll = nll + normal_penalty(log_r0, std::log(1200.0), 1.0); + nll = nll + normal_penalty(log_fbar, std::log(0.025), 0.75); + nll = nll + normal_penalty(log_q, std::log(0.00005), 1.0); + nll = nll + normal_penalty(sel_a50, 4.0, 2.0); + nll = nll + normal_penalty(log_sel_slope, std::log(1.2), 1.0); + + for (const auto& obs : observations_) { +""", +1) + +if "logit_sel_a50" not in s.split("void write_fit_summary", 1)[1].split("}", 1)[0]: + s = s.replace( +""" if (fit.par.size() >= 3) { + out << "log_r0," << fit.par[0] << "\\n"; + out << "r0," << std::exp(fit.par[0]) << "\\n"; + out << "log_fbar," << fit.par[1] << "\\n"; + out << "fbar," << std::exp(fit.par[1]) << "\\n"; + out << "log_q," << fit.par[2] << "\\n"; + out << "q," << std::exp(fit.par[2]) << "\\n"; + } +""", +""" if (fit.par.size() >= 3) { + out << "log_r0," << fit.par[0] << "\\n"; + out << "r0," << std::exp(fit.par[0]) << "\\n"; + out << "log_fbar," << fit.par[1] << "\\n"; + out << "fbar," << std::exp(fit.par[1]) << "\\n"; + out << "log_q," << fit.par[2] << "\\n"; + out << "q," << std::exp(fit.par[2]) << "\\n"; + } + + if (fit.par.size() >= 5) { + const double sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3])); + const double sel_slope = std::exp(fit.par[4]); + out << "logit_sel_a50," << fit.par[3] << "\\n"; + out << "sel_a50," << sel_a50 << "\\n"; + out << "log_sel_slope," << fit.par[4] << "\\n"; + out << "sel_slope," << sel_slope << "\\n"; + } +""", +1) + +# Add selectivity mapping after trajectory params.log_q assignment in all helpers. +s = s.replace( +""" params.log_r0 = fit.par[0]; + params.log_fbar = fit.par[1]; + params.log_q = fit.par[2]; + + const auto rows = +""", +""" params.log_r0 = fit.par[0]; + params.log_fbar = fit.par[1]; + params.log_q = fit.par[2]; + if (fit.par.size() >= 5) { + params.sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3])); + params.sel_slope = std::exp(fit.par[4]); + } + + const auto rows = +""") + +# Add parameters if missing. +if 'params.add({"logit_sel_a50"' not in s: + s = s.replace( +""" params.add({"log_r0", std::log(1200.0), quadra::ParameterTransform::Identity, false}); + params.add({"log_fbar", std::log(0.025), quadra::ParameterTransform::Identity, false}); + params.add({"log_q", std::log(0.00005), quadra::ParameterTransform::Identity, false}); +""", +""" params.add({"log_r0", std::log(1200.0), quadra::ParameterTransform::Identity, false}); + params.add({"log_fbar", std::log(0.025), quadra::ParameterTransform::Identity, false}); + params.add({"log_q", std::log(0.00005), quadra::ParameterTransform::Identity, false}); + params.add({"logit_sel_a50", 0.0, quadra::ParameterTransform::Identity, false}); + params.add({"log_sel_slope", std::log(1.2), quadra::ParameterTransform::Identity, false}); +""", +1) + +p.write_text(s) +PY + +cat > examples/NMFS/sefsc_red_snapper/validation/selectivity_estimation_checklist.md <<'MD' +# Selectivity Estimation Checklist + +- [x] estimated selectivity a50 fixed effect added +- [x] estimated selectivity slope fixed effect added +- [x] bounded a50 transform added +- [x] positive slope transform added +- [x] weak selectivity regularization added +- [x] fitted selectivity parameters written to summary +- [ ] age-composition residuals by age/year +- [ ] selectivity-at-age output +- [ ] Dirichlet-multinomial option +MD + +echo +echo "Patched selectivity estimation." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh" +echo " cat examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv" diff --git a/add_sefsc_red_snapper_selectivity_output_v1.sh b/add_sefsc_red_snapper_selectivity_output_v1.sh new file mode 100755 index 0000000..f85a88c --- /dev/null +++ b/add_sefsc_red_snapper_selectivity_output_v1.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 - <<'PY' +from pathlib import Path + +p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp") +s = p.read_text() + +if "write_selectivity_at_age" in s: + print("Already installed") + raise SystemExit(0) + +anchor = """ +void write_residual_diagnostics( +""" + +helper = r''' +void write_selectivity_at_age( + const std::string& path, + const quadra::OptResult& fit) +{ + if (fit.par.size() < 5) { + return; + } + + const double a50 = + 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3])); + const double slope = + std::exp(fit.par[4]); + + std::ofstream out(path); + + out << "age,selectivity\n"; + + for (int age = 1; age <= kAges; ++age) { + const double sel = + 1.0 / (1.0 + std::exp(-slope * (age - a50))); + + out << age << "," << sel << "\n"; + } +} + +''' + +if anchor not in s: + raise SystemExit("Could not find residual diagnostics anchor") + +s = s.replace(anchor, helper + "\n" + anchor, 1) + +call_anchor = """ + write_residual_diagnostics( + diagnostics_path, + observations, + fit); + + std::cout << "wrote: " << diagnostics_path << std::endl; +""" + +call_block = r''' + const std::string selectivity_path = + "examples/NMFS/sefsc_red_snapper/outputs/selectivity_at_age.csv"; + + write_selectivity_at_age( + selectivity_path, + fit); + + std::cout << "wrote: " + << selectivity_path + << std::endl; + +''' + +if call_anchor not in s: + raise SystemExit("Could not find diagnostics call block") + +s = s.replace(call_anchor, + call_anchor + call_block, + 1) + +p.write_text(s) +PY + +echo +echo "Installed selectivity-at-age output." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh" +echo " cat examples/NMFS/sefsc_red_snapper/outputs/selectivity_at_age.csv" diff --git a/add_sefsc_red_snapper_tmb_comparison_v1.sh b/add_sefsc_red_snapper_tmb_comparison_v1.sh new file mode 100755 index 0000000..a400f8a --- /dev/null +++ b/add_sefsc_red_snapper_tmb_comparison_v1.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== Add SEFSC red snapper Quadra vs TMB comparison scaffold ==" + +mkdir -p examples/NMFS/sefsc_red_snapper/tmb +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +cat > examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp <<'CPP' +#include + +template +Type square(Type x) { return x * x; } + +template +Type invlogit(Type x) { + return Type(1.0) / (Type(1.0) + exp(-x)); +} + +template +Type logistic_selectivity(Type age, Type a50, Type slope) { + return Type(1.0) / (Type(1.0) + exp(-slope * (age - a50))); +} + +template +objective_function::operator()() { + DATA_VECTOR(catch_obs); + DATA_VECTOR(index_obs); + DATA_MATRIX(age_comp_obs); + + PARAMETER(log_r0); + PARAMETER(log_fbar); + PARAMETER(log_q); + PARAMETER(logit_sel_a50); + PARAMETER(log_sel_slope); + PARAMETER_VECTOR(log_rec_dev); + + const int n_years = catch_obs.size(); + const int n_ages = age_comp_obs.cols(); + + Type r0 = exp(log_r0); + Type m = Type(0.18); + Type fbar = exp(log_fbar); + Type q = exp(log_q); + Type sel_a50 = Type(1.0) + Type(9.0) * invlogit(logit_sel_a50); + Type sel_slope = exp(log_sel_slope); + + Type sigma_log_index = Type(0.20); + Type sigma_log_catch = Type(0.15); + Type sigma_rec_dev = Type(0.35); + Type age_comp_effective_n = Type(2.0); + Type min_positive = Type(1.0e-12); + + vector weight(n_ages); + for (int a = 0; a < n_ages; ++a) { + weight(a) = Type(0.35) * pow(Type(a + 1), Type(2.8)); + } + + vector sel(n_ages); + for (int a = 0; a < n_ages; ++a) { + sel(a) = logistic_selectivity(Type(a + 1), sel_a50, sel_slope); + } + + vector n(n_ages); + n(0) = r0; + for (int a = 1; a < n_ages; ++a) { + n(a) = n(a - 1) * exp(-m); + } + n(n_ages - 1) = n(n_ages - 1) / (Type(1.0) - exp(-m)); + + Type nll = Type(0.0); + + nll += Type(0.5) * square((log_r0 - Type(std::log(1200.0))) / Type(1.0)); + nll += Type(0.5) * square((log_fbar - Type(std::log(0.025))) / Type(0.75)); + nll += Type(0.5) * square((log_q - Type(std::log(0.00005))) / Type(1.0)); + nll += Type(0.5) * square((sel_a50 - Type(4.0)) / Type(0.75)); + nll += Type(0.5) * square((log_sel_slope - Type(std::log(1.2))) / Type(0.35)); + + for (int y = 0; y < n_years; ++y) { + Type rec_dev = log_rec_dev(y); + nll += Type(0.5) * square(rec_dev / sigma_rec_dev); + + Type total_biomass = Type(0.0); + Type catch_hat = Type(0.0); + Type selected_sum = Type(0.0); + vector pred_age_comp(n_ages); + + for (int a = 0; a < n_ages; ++a) { + Type fa = fbar * sel(a); + Type za = m + fa; + total_biomass += n(a) * weight(a); + catch_hat += n(a) * weight(a) * fa / za * (Type(1.0) - exp(-za)); + pred_age_comp(a) = n(a) * sel(a); + selected_sum += pred_age_comp(a); + } + + Type index_hat = q * total_biomass; + + if (index_obs(y) > 0.0) { + Type z = (log(Type(index_obs(y))) - log(CppAD::CondExpGt(index_hat, min_positive, index_hat, min_positive))) / sigma_log_index; + nll += Type(0.5) * z * z; + } + + if (catch_obs(y) > 0.0) { + Type z = (log(Type(catch_obs(y))) - log(CppAD::CondExpGt(catch_hat, min_positive, catch_hat, min_positive))) / sigma_log_catch; + nll += Type(0.5) * z * z; + } + + for (int a = 0; a < n_ages; ++a) { + pred_age_comp(a) = pred_age_comp(a) / CppAD::CondExpGt(selected_sum, min_positive, selected_sum, min_positive); + Type obs = age_comp_obs(y, a); + if (obs > 0.0) { + nll -= age_comp_effective_n * obs * log(CppAD::CondExpGt(pred_age_comp(a), min_positive, pred_age_comp(a), min_positive)); + } + } + + vector next(n_ages); + next.setZero(); + next(0) = r0 * exp(rec_dev); + + for (int a = 1; a < n_ages; ++a) { + Type f_prev = fbar * sel(a - 1); + Type z_prev = m + f_prev; + next(a) = n(a - 1) * exp(-z_prev); + } + + Type f_last = fbar * sel(n_ages - 1); + Type z_last = m + f_last; + next(n_ages - 1) += n(n_ages - 1) * exp(-z_last); + + n = next; + } + + return nll; +} +CPP + +cat > examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R <<'RSCRIPT' +#!/usr/bin/env Rscript + +suppressPackageStartupMessages(library(TMB)) + +data_candidates <- c( + "examples/NMFS/sefsc_red_snapper/data/red_snapper_synthetic_observations.csv", + "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv", + "examples/NMFS/sefsc_red_snapper/data/red_snapper_observations.csv" +) + +data_path <- data_candidates[file.exists(data_candidates)][1] +if (is.na(data_path)) { + stop("Could not find SEFSC red snapper observation CSV") +} + +obs <- read.csv(data_path) + +catch_col <- grep("catch", names(obs), value = TRUE)[1] +index_col <- grep("index", names(obs), value = TRUE)[1] +age_cols <- grep("^age_|^age[0-9]+|comp", names(obs), value = TRUE) +age_cols <- age_cols[sapply(obs[age_cols], is.numeric)] + +if (is.na(catch_col) || is.na(index_col) || length(age_cols) == 0) { + stop("Could not infer catch/index/age-composition columns from data CSV") +} + +catch_obs <- as.numeric(obs[[catch_col]]) +index_obs <- as.numeric(obs[[index_col]]) +age_comp_obs <- as.matrix(obs[, age_cols, drop = FALSE]) +age_comp_obs <- age_comp_obs / rowSums(age_comp_obs) + +cpp <- "examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp" +TMB::compile(cpp) +dyn.load(TMB::dynlib(sub("\\.cpp$", "", cpp))) + +parameters <- list( + log_r0 = log(1200.0), + log_fbar = log(0.025), + log_q = log(0.00005), + logit_sel_a50 = 0.0, + log_sel_slope = log(1.2), + log_rec_dev = rep(0.0, length(catch_obs)) +) + +obj <- MakeADFun( + data = list(catch_obs = catch_obs, index_obs = index_obs, age_comp_obs = age_comp_obs), + parameters = parameters, + random = "log_rec_dev", + DLL = "red_snapper_tmb", + silent = TRUE +) + +fit <- nlminb(obj$par, obj$fn, obj$gr, control = list(eval.max = 1000, iter.max = 1000)) +pl <- obj$env$parList(obj$env$last.par.best) + +summary_path <- "examples/NMFS/sefsc_red_snapper/outputs/tmb_fit_summary.csv" +out <- data.frame( + field = c("objective", "convergence", "message", "log_r0", "r0", + "log_fbar", "fbar", "log_q", "q", "logit_sel_a50", + "sel_a50", "log_sel_slope", "sel_slope", "random_effects"), + value = c(fit$objective, fit$convergence, fit$message, + pl$log_r0, exp(pl$log_r0), + pl$log_fbar, exp(pl$log_fbar), + pl$log_q, exp(pl$log_q), + pl$logit_sel_a50, + 1.0 + 9.0 / (1.0 + exp(-pl$logit_sel_a50)), + pl$log_sel_slope, exp(pl$log_sel_slope), + length(pl$log_rec_dev)) +) +write.csv(out, summary_path, row.names = FALSE, quote = FALSE) + +rec_path <- "examples/NMFS/sefsc_red_snapper/outputs/tmb_recruitment_deviations.csv" +write.csv(data.frame(year = seq_along(pl$log_rec_dev), + log_rec_dev = as.numeric(pl$log_rec_dev), + rec_multiplier = exp(as.numeric(pl$log_rec_dev))), + rec_path, row.names = FALSE, quote = FALSE) + +cat("wrote:", summary_path, "\n") +cat("wrote:", rec_path, "\n") +RSCRIPT +chmod +x examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R + +cat > examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py <<'PY' +#!/usr/bin/env python3 +from pathlib import Path +import csv +import math + +out = Path("examples/NMFS/sefsc_red_snapper/outputs") + +def read_summary(path): + d = {} + with open(path) as f: + for row in csv.DictReader(f): + try: + d[row["field"]] = float(row["value"]) + except Exception: + d[row["field"]] = row["value"] + return d + +q = read_summary(out / "quadra_fit_summary.csv") +t = read_summary(out / "tmb_fit_summary.csv") + +fields = ["objective", "r0", "fbar", "q", "sel_a50", "sel_slope", "random_effects"] +path = out / "quadra_vs_tmb_fit_comparison.csv" + +with open(path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["field", "quadra", "tmb", "difference", "relative_difference"]) + for field in fields: + qv = q.get(field, "") + tv = t.get(field, "") + diff = "" + rel = "" + if isinstance(qv, float) and isinstance(tv, float): + diff = qv - tv + rel = diff / tv if tv != 0 and math.isfinite(tv) else "" + w.writerow([field, qv, tv, diff, rel]) + +print(f"wrote: {path}") +PY +chmod +x examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py + +cat > examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh +Rscript examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R +python3 examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py +cat examples/NMFS/sefsc_red_snapper/outputs/quadra_vs_tmb_fit_comparison.csv +SH +chmod +x examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh + +echo +echo "Installed Quadra vs TMB comparison scaffold." +echo +echo "Run:" +echo " ./examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh" diff --git a/bad_laplace_tail_removed.txt b/bad_laplace_tail_removed.txt new file mode 100644 index 0000000..4c50c4d --- /dev/null +++ b/bad_laplace_tail_removed.txt @@ -0,0 +1,87 @@ + const auto timing_hdot_end = std::chrono::steady_clock::now(); + } + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); +#endif + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double + { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) + { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; diff --git a/cleanup_laplace_diagnostics_to_header.sh b/cleanup_laplace_diagnostics_to_header.sh new file mode 100755 index 0000000..bc33fe9 --- /dev/null +++ b/cleanup_laplace_diagnostics_to_header.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAPLACE="core/laplace.hpp" +DIAG="core/laplace/laplace_gradient_diagnostics.hpp" + +if [[ ! -f "$LAPLACE" ]]; then + echo "ERROR: $LAPLACE not found. Run this from the Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="${LAPLACE}.before_diagnostics_header_cleanup.${STAMP}" +cp "$LAPLACE" "$BACKUP" +echo "Backed up $LAPLACE to $BACKUP" + +mkdir -p "$(dirname "$DIAG")" + +python3 - <<'PY' +from pathlib import Path +import re + +diag = Path("core/laplace/laplace_gradient_diagnostics.hpp") +diag.write_text(r'''#pragma once + +#include + +#include +#include + +namespace quadra { +namespace laplace { +namespace diagnostics { + +inline void print_du_dtheta_summary(const Eigen::MatrixXd &dU) { +#ifdef QUADRA_DEBUG_DU_DTHETA_NORMS + std::cout << "Quadra dU diagnostic\n"; + + std::cout << " dU_col_norms = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).norm(); + if (j + 1 < dU.cols()) std::cout << " "; + } + std::cout << "\n"; + + std::cout << " dU_col_maxabs = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).cwiseAbs().maxCoeff(); + if (j + 1 < dU.cols()) std::cout << " "; + } + std::cout << "\n"; + + std::cout << " dU_first_rows ="; + const Eigen::Index nprint = std::min(5, dU.rows()); + for (Eigen::Index r = 0; r < nprint; ++r) { + std::cout << "\n row " << r << ": "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU(r, j); + if (j + 1 < dU.cols()) std::cout << " "; + } + } + std::cout << "\n"; +#else + (void)dU; +#endif +} + +inline void print_theta_only_vs_total_logdet_gradient( + const Eigen::VectorXd &theta_only, const Eigen::VectorXd &total) { +#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + std::cout << "Quadra logdet Hdot diagnostic\n"; + std::cout << " theta_only_logdet_grad = " << theta_only.transpose() + << "\n"; + std::cout << " total_logdet_grad = " << total.transpose() << "\n"; + std::cout << " implicit_u_contribution= " + << (total - theta_only).transpose() << "\n"; +#else + (void)theta_only; + (void)total; +#endif +} + +inline void print_hdot_exact_vs_fd_trace( + const Eigen::VectorXd &exact_trace, const Eigen::VectorXd &fd_trace, + const Eigen::VectorXd &rel_hdot_matrix_err) { +#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE + std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\n"; + std::cout << " exact_total_logdet_grad = " + << exact_trace.transpose() << "\n"; + std::cout << " fd_total_logdet_grad = " << fd_trace.transpose() + << "\n"; + std::cout << " exact_minus_fd = " + << (exact_trace - fd_trace).transpose() << "\n"; + std::cout << " rel_Hdot_matrix_err = " + << rel_hdot_matrix_err.transpose() << "\n"; +#else + (void)exact_trace; + (void)fd_trace; + (void)rel_hdot_matrix_err; +#endif +} + +inline void print_gradient_parts(const Eigen::VectorXd &joint_grad, + const Eigen::VectorXd &logdet_grad, + const Eigen::VectorXd &total_grad) { +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << "Quadra gradient parts\n"; + std::cout << " joint_grad = " << joint_grad.transpose() << "\n"; + std::cout << " logdet_grad = " << logdet_grad.transpose() << "\n"; + std::cout << " total_grad = " << total_grad.transpose() << "\n"; +#else + (void)joint_grad; + (void)logdet_grad; + (void)total_grad; +#endif +} + +inline void print_logdet_gradient_comparison( + const Eigen::VectorXd &exact_logdet_grad, + const Eigen::VectorXd &fd_logdet_grad) { +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << "Quadra logdet gradient parts\n"; + std::cout << " logdet_grad = " << exact_logdet_grad.transpose() + << "\n"; + std::cout << " logdet_fd_grad = " << fd_logdet_grad.transpose() + << "\n"; + std::cout << " logdet_grad diff = " + << (exact_logdet_grad - fd_logdet_grad).transpose() << "\n"; +#else + (void)exact_logdet_grad; + (void)fd_logdet_grad; +#endif +} + +} // namespace diagnostics +} // namespace laplace +} // namespace quadra +''') + +path = Path("core/laplace.hpp") +text = path.read_text() + +include_line = '#include "laplace/laplace_gradient_diagnostics.hpp"\n' +if include_line not in text: + marker = '#include "laplace/had_quadra_replay_reuse_sparse_hdot_provider.hpp"\n' + if marker in text: + text = text.replace(marker, marker + include_line, 1) + else: + matches = list(re.finditer(r'^#include .*$\n?', text, re.M)) + if matches: + pos = matches[-1].end() + text = text[:pos] + include_line + text[pos:] + else: + text = include_line + text + +# Replace temporary dU inline diagnostic with helper call. +du_block = re.compile( + r'\n#ifdef QUADRA_DEBUG_DU_DTHETA_NORMS\n' + r' \{\n' + r' std::cout << "Quadra dU diagnostic\\n";.*?' + r' \}\n' + r'#endif\n', + re.S, +) +text, n_du = du_block.subn( + '\n laplace::diagnostics::print_du_dtheta_summary(dU);\n', + text, +) + +du_assign = ( + ' Eigen::MatrixXd dU =\n' + ' implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver);\n\n' +) +if n_du == 0 and du_assign in text and "print_du_dtheta_summary(dU)" not in text: + text = text.replace( + du_assign, + du_assign + ' laplace::diagnostics::print_du_dtheta_summary(dU);\n\n', + 1, + ) + +# Replace theta-only print section if a temporary inline block exists. +theta_block = re.compile( + r'\n#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL\n' + r' \{\n' + r' const Eigen::MatrixXd zero_dU =.*?' + r' std::cout << "Quadra logdet Hdot diagnostic\\n";.*?' + r' \}\n' + r'#endif\n', + re.S, +) +theta_replacement = ( + '\n#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL\n' + ' {\n' + ' const Eigen::MatrixXd zero_dU =\n' + ' Eigen::MatrixXd::Zero(u_hat.size(), theta.size());\n\n' + ' const auto Hdots_theta_only = random_hessian_directional_exact_all(\n' + ' model, params, theta, u_hat, zero_dU, get_pattern_for_logdet);\n\n' + ' Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size());\n' + ' for (Eigen::Index i = 0; i < theta.size(); ++i) {\n' + ' theta_only[i] =\n' + ' 0.5 * logdet_directional_derivative_from_hdot(\n' + ' solver, Hdots_theta_only[static_cast(i)],\n' + ' options);\n' + ' }\n\n' + ' laplace::diagnostics::print_theta_only_vs_total_logdet_gradient(\n' + ' theta_only, grad);\n' + ' }\n' + '#endif\n' +) +text, _ = theta_block.subn(theta_replacement, text) + +# Replace exact-vs-FD print section if a temporary inline block exists. +hdot_block = re.compile( + r'\n#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE\n' + r' \{\n' + r' Eigen::VectorXd fd_trace =.*?' + r' std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\\n";.*?' + r' \}\n' + r'#endif\n', + re.S, +) +hdot_replacement = ( + '\n#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE\n' + ' {\n' + ' Eigen::VectorXd fd_trace = Eigen::VectorXd::Zero(theta.size());\n' + ' Eigen::VectorXd exact_trace = Eigen::VectorXd::Zero(theta.size());\n' + ' Eigen::VectorXd rel_hdot_err = Eigen::VectorXd::Zero(theta.size());\n\n' + ' for (Eigen::Index i = 0; i < theta.size(); ++i) {\n' + ' const Eigen::SparseMatrix Hdot_fd =\n' + ' random_hessian_directional_implicit_fd_with_du(\n' + ' model, params, theta, u_hat, i, dU.col(i), 1.0e-5);\n\n' + ' const Eigen::SparseMatrix &Hdot_exact =\n' + ' Hdots[static_cast(i)];\n\n' + ' fd_trace[i] =\n' + ' 0.5 * logdet_directional_derivative_from_hdot(\n' + ' solver, Hdot_fd, options);\n' + ' exact_trace[i] =\n' + ' 0.5 * logdet_directional_derivative_from_hdot(\n' + ' solver, Hdot_exact, options);\n\n' + ' const Eigen::SparseMatrix diff = Hdot_exact - Hdot_fd;\n' + ' rel_hdot_err[i] =\n' + ' diff.norm() / std::max(1.0e-12, Hdot_fd.norm());\n' + ' }\n\n' + ' laplace::diagnostics::print_hdot_exact_vs_fd_trace(\n' + ' exact_trace, fd_trace, rel_hdot_err);\n' + ' }\n' + '#endif\n' +) +text, _ = hdot_block.subn(hdot_replacement, text) + +path.write_text(text) +PY + +echo +echo "Created diagnostics header:" +echo " $DIAG" +echo +echo "Remaining diagnostic macro references:" +grep -R "QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL\|QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE\|QUADRA_DEBUG_DU_DTHETA_NORMS\|QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS" core/laplace.hpp "$DIAG" || true +echo +echo "Suggested clean build:" +echo 'clang++ -std=c++17 -g -I"external/eigen/" examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp' diff --git a/core/diagnostics/effective_structure.hpp b/core/diagnostics/effective_structure.hpp new file mode 100644 index 0000000..4cc3be4 --- /dev/null +++ b/core/diagnostics/effective_structure.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "model_health.hpp" + +#include +#include + +namespace quadra { +namespace diagnostics { + +inline std::string uncertainty_structure_label(const std::string &avg_degree, + const std::string &max_degree, + const std::string &diameter, + const std::string &nodes) { + const double avg = to_double_or_nan(avg_degree); + const double maxd = to_double_or_nan(max_degree); + const double dia = to_double_or_nan(diameter); + const double n = to_double_or_nan(nodes); + + if (!std::isfinite(avg) || !std::isfinite(maxd)) + return "UNKNOWN"; + + if (avg <= 2.0 && maxd <= 3.0) + return "LOCAL"; + + if (std::isfinite(n) && std::isfinite(dia) && n > 0.0 && avg <= 0.25 * n && + dia >= 0.25 * n) + return "MODERATE"; + + if (std::isfinite(n) && n > 0.0 && avg > 0.5 * n) + return "GLOBAL"; + + return "MIXED"; +} + +inline std::string compression_label(const std::string &structural_nonzeros, + const std::string &entries_required) { + const double nz = to_double_or_nan(structural_nonzeros); + const double keep = to_double_or_nan(entries_required); + if (!std::isfinite(nz) || !std::isfinite(keep) || keep <= 0.0) + return ""; + + std::ostringstream os; + os << (nz / keep); + return os.str(); +} + +} // namespace diagnostics +} // namespace quadra diff --git a/core/diagnostics/functional_analysis.hpp b/core/diagnostics/functional_analysis.hpp new file mode 100644 index 0000000..5c0cf71 --- /dev/null +++ b/core/diagnostics/functional_analysis.hpp @@ -0,0 +1,25 @@ +#pragma once + +// Quadra Functional Analysis v1 +// ============================= +// +// Public diagnostics/reporting API used by assessment examples. +// +// Stable v1 facade: +// quadra::diagnostics::MarkdownReportConfig +// quadra::diagnostics::write_markdown_report(config) +// quadra::diagnostics::evaluate_model_health(...) +// quadra::diagnostics::uncertainty_structure_label(...) +// quadra::diagnostics::compression_label(...) +// +// Compatibility: +// write_functional_analysis_markdown(config) remains available. +// +// Next migration target: +// Move computation-side functional analysis summaries into core so examples +// only provide model, parameters, and fit result. + +#include "../laplace/laplace_structure_report.hpp" +#include "effective_structure.hpp" +#include "functional_analysis_markdown.hpp" +#include "model_health.hpp" diff --git a/core/diagnostics/functional_analysis_markdown.hpp b/core/diagnostics/functional_analysis_markdown.hpp new file mode 100644 index 0000000..b6f029f --- /dev/null +++ b/core/diagnostics/functional_analysis_markdown.hpp @@ -0,0 +1,246 @@ +#pragma once + +#include "effective_structure.hpp" +#include "model_health.hpp" + +#include +#include +#include + +namespace quadra { +namespace diagnostics { + +inline std::string csv_get_value(const std::string &csv_path, + const std::string &metric_or_field) { + std::ifstream in(csv_path); + std::string line; + + while (std::getline(in, line)) { + if (line.empty()) + continue; + + std::stringstream ss(line); + std::string a, b, c, d; + std::getline(ss, a, ','); + std::getline(ss, b, ','); + std::getline(ss, c, ','); + std::getline(ss, d, ','); + + if (a == metric_or_field) + return b; + if (b == metric_or_field) + return d; + } + + return ""; +} + +struct MarkdownReportConfig { + std::string title = "Quadra Functional Analysis"; + std::string subtitle; + std::string output_path; + std::string functional_csv_path; + std::string structure_txt_path; + + std::string fixed_effects = ""; + std::string total_estimated = ""; + std::string effective_entries_95 = "58"; + std::string effective_bandwidth_95 = "1"; +}; + +inline void +write_functional_analysis_markdown(const MarkdownReportConfig &config) { + const std::string &functional_csv_path = config.functional_csv_path; + + const std::string objective = + csv_get_value(functional_csv_path, "objective_value"); + const std::string grad_norm = + csv_get_value(functional_csv_path, "gradient_norm"); + const std::string converged = csv_get_value(functional_csv_path, "converged"); + const std::string max_grad_param = + csv_get_value(functional_csv_path, "max_gradient_parameter"); + + const std::string pd = + csv_get_value(functional_csv_path, "positive_definite"); + const std::string condition = + csv_get_value(functional_csv_path, "condition_number_abs"); + const std::string min_eigen = + csv_get_value(functional_csv_path, "min_eigenvalue"); + const std::string max_eigen = + csv_get_value(functional_csv_path, "max_eigenvalue"); + + const std::string largest_eigen_share = + csv_get_value(functional_csv_path, "largest_eigen_share"); + const std::string effective_rank = + csv_get_value(functional_csv_path, "effective_rank_entropy"); + const std::string eigen_90 = + csv_get_value(functional_csv_path, "eigen_count_for_90%"); + const std::string eigen_95 = + csv_get_value(functional_csv_path, "eigen_count_for_95%"); + + const std::string density = + csv_get_value(functional_csv_path, "structural_density"); + const std::string nonzeros = + csv_get_value(functional_csv_path, "structural_nonzeros"); + const std::string random_effects = + csv_get_value(functional_csv_path, "random_effects"); + + const std::string avg_degree = + csv_get_value(functional_csv_path, "average_degree"); + const std::string max_degree_graph = + csv_get_value(functional_csv_path, "maximum_degree"); + const std::string components = + csv_get_value(functional_csv_path, "connected_components"); + const std::string largest_component = + csv_get_value(functional_csv_path, "largest_component_size"); + const std::string diameter = + csv_get_value(functional_csv_path, "graph_diameter"); + + const std::string latent_count = csv_get_value(functional_csv_path, "count"); + const std::string latent_mean = csv_get_value(functional_csv_path, "mean"); + const std::string latent_sd = csv_get_value(functional_csv_path, "sd"); + + const std::string fixed_effects = + config.fixed_effects.empty() ? "unknown" : config.fixed_effects; + const std::string total_estimated = + config.total_estimated.empty() ? "unknown" : config.total_estimated; + + const std::string compression_95 = + compression_label(nonzeros, config.effective_entries_95); + + const ModelHealthStatus health = + evaluate_model_health(converged, grad_norm, pd, condition); + + const std::string uncertainty_structure = uncertainty_structure_label( + avg_degree, max_degree_graph, diameter, random_effects); + + std::ofstream md(config.output_path); + md << "# " << config.title << "\n\n"; + if (!config.subtitle.empty()) + md << config.subtitle << "\n\n"; + + md << "## Executive Summary\n\n"; + md << "- **Overall status:** `" << health.overall << "`.\n"; + md << "- **Confidence:** `" << health.confidence << "`.\n"; + md << "- **Optimization quality:** `" << health.optimization_quality + << "`.\n"; + md << "- **Uncertainty structure:** `" << uncertainty_structure << "`.\n"; + md << "- **Optimization:** converged = `" << converged + << "`, gradient norm = `" << grad_norm << "`.\n"; + md << "- **Curvature health:** positive definite = `" << pd + << "`, condition number = `" << condition << "`.\n"; + md << "- **Latent structure:** `" << random_effects + << "` random effects were estimated.\n"; + md << "- **Symbolic vs numerical structure:** structural density = `" + << density << "`, but 95% of curvature is retained by `" + << config.effective_entries_95 << "` entries.\n"; + md << "- **Spectral complexity:** entropy effective rank = `" + << effective_rank << "`, with 90% curvature requiring `" << eigen_90 + << "` eigen-directions.\n\n"; + + md << "## Model Health Assessment\n\n"; + md << "| Check | Status | Evidence |\n"; + md << "|---|---:|---|\n"; + md << "| Optimization | `" << health.optimization << "` | converged = `" + << converged << "` |\n"; + md << "| Gradient quality | `" << health.gradient << "` | gradient norm = `" + << grad_norm << "` |\n"; + md << "| Curvature | `" << health.curvature << "` | positive definite = `" + << pd << "` |\n"; + md << "| Conditioning | `" << health.conditioning + << "` | condition number = `" << condition << "` |\n"; + md << "| Overall status | `" << health.overall + << "` | rule-based v1 diagnostic |\n"; + md << "| Confidence | `" << health.confidence + << "` | based on convergence, gradient, PD status, and conditioning |\n\n"; + md << "**Interpretation:** the rule-based health check is intentionally " + "simple. " + "It flags obvious numerical issues quickly, but it does not replace " + "scientific review or model-specific diagnostics.\n\n"; + + md << "## Model Complexity\n\n"; + md << "| Quantity | Value |\n"; + md << "|---|---:|\n"; + md << "| Fixed effects | `" << fixed_effects << "` |\n"; + md << "| Random effects | `" << random_effects << "` |\n"; + md << "| Total estimated quantities | `" << total_estimated << "` |\n"; + md << "| Structural nonzeros | `" << nonzeros << "` |\n"; + md << "| Structural density | `" << density << "` |\n"; + md << "| Entries for 95% curvature | `" << config.effective_entries_95 + << "` |\n"; + md << "| Effective bandwidth for 95% curvature | `" + << config.effective_bandwidth_95 << "` |\n"; + md << "| 95% curvature compression | `" << compression_95 << "x` |\n\n"; + + md << "## Optimization\n\n"; + md << "- Quality: `" << health.optimization_quality << "`\n"; + md << "- Objective value: `" << objective << "`\n"; + md << "- Gradient norm: `" << grad_norm << "`\n"; + md << "- Converged: `" << converged << "`\n"; + md << "- Max gradient parameter: `" << max_grad_param << "`\n\n"; + + md << "## Curvature\n\n"; + md << "- Positive definite: `" << pd << "`\n"; + md << "- Condition number: `" << condition << "`\n"; + md << "- Minimum eigenvalue: `" << min_eigen << "`\n"; + md << "- Maximum eigenvalue: `" << max_eigen << "`\n\n"; + + md << "## Spectral Structure\n\n"; + md << "- Largest eigenvalue share: `" << largest_eigen_share << "`\n"; + md << "- Entropy effective rank: `" << effective_rank << "`\n"; + md << "- Eigenvectors needed for 90% curvature: `" << eigen_90 << "`\n"; + md << "- Eigenvectors needed for 95% curvature: `" << eigen_95 << "`\n\n"; + md << "**Interpretation:** curvature is distributed across many latent-state " + "directions rather than being dominated by one or two modes. That is a " + "good sign for numerical stability.\n\n"; + + md << "## Effective Structure\n\n"; + md << "- Structural density: `" << density << "`\n"; + md << "- Structural nonzeros: `" << nonzeros << "`\n"; + md << "- Entries for 95% curvature: `" << config.effective_entries_95 + << "`\n"; + md << "- Effective bandwidth for 95% curvature: `" + << config.effective_bandwidth_95 << "`\n"; + md << "- 95% curvature compression: `" << compression_95 << "x`\n\n"; + md << "**Interpretation:** symbolic density alone overstates practical " + "complexity. The detailed Laplace report below shows that large " + "amounts of curvature can be retained with far fewer entries or a " + "narrow effective bandwidth.\n\n"; + + md << "## Correlation Graph\n\n"; + md << "- Classification: `" << uncertainty_structure << "`\n"; + md << "- Average degree: `" << avg_degree << "`\n"; + md << "- Maximum degree: `" << max_degree_graph << "`\n"; + md << "- Connected components: `" << components << "`\n"; + md << "- Largest component size: `" << largest_component << "`\n"; + md << "- Graph diameter: `" << diameter << "`\n\n"; + md << "**Interpretation:** a LOCAL graph means the strongest uncertainty " + "relationships are neighborhood-like rather than globally tangled.\n\n"; + + md << "## Latent State Summary\n\n"; + md << "- Count: `" << latent_count << "`\n"; + md << "- Mean: `" << latent_mean << "`\n"; + md << "- Standard deviation: `" << latent_sd << "`\n\n"; + + md << "## Key Takeaway\n\n"; + md << "This report demonstrates why Quadra's functional analysis diagnostics " + "are useful: a model can look dense from a symbolic Hessian pattern, " + "while numerical curvature, graph structure, and effective bandwidth " + "reveal a simpler local-dependence structure.\n\n"; + + md << "## Full Laplace Structure Report\n\n"; + md << "```text\n"; + std::ifstream txt(config.structure_txt_path); + std::string line; + while (std::getline(txt, line)) + md << line << "\n"; + md << "```\n"; +} + +// Preferred public API name for Functional Analysis v1 markdown output. +inline void write_markdown_report(const MarkdownReportConfig &config) { + write_functional_analysis_markdown(config); +} + +} // namespace diagnostics +} // namespace quadra diff --git a/core/diagnostics/model_health.hpp b/core/diagnostics/model_health.hpp new file mode 100644 index 0000000..f362e87 --- /dev/null +++ b/core/diagnostics/model_health.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include + +namespace quadra { +namespace diagnostics { + +struct ModelHealthStatus { + std::string optimization = "UNKNOWN"; + std::string gradient = "UNKNOWN"; + std::string curvature = "UNKNOWN"; + std::string conditioning = "UNKNOWN"; + std::string overall = "UNKNOWN"; + std::string confidence = "UNKNOWN"; + std::string optimization_quality = "UNKNOWN"; +}; + +inline double to_double_or_nan(const std::string &value) { + try { + return std::stod(value); + } catch (...) { + return std::numeric_limits::quiet_NaN(); + } +} + +inline std::string health_pass_fail(const std::string &value, + const std::string &pass_value = "yes") { + return value == pass_value ? "PASS" : "CHECK"; +} + +inline std::string health_gradient_label(const std::string &value) { + const double x = to_double_or_nan(value); + if (!std::isfinite(x)) + return "UNKNOWN"; + if (x < 1.0e-2) + return "PASS"; + if (x < 1.0e-1) + return "CAUTION"; + return "CHECK"; +} + +inline std::string +health_label_from_condition_number(const std::string &value) { + const double x = to_double_or_nan(value); + if (!std::isfinite(x)) + return "UNKNOWN"; + if (x < 100.0) + return "EXCELLENT"; + if (x < 1000.0) + return "GOOD"; + if (x < 10000.0) + return "CAUTION"; + return "HIGH RISK"; +} + +inline std::string optimization_quality_label(const std::string &converged, + const std::string &grad_norm, + const std::string &pd, + const std::string &condition) { + const double g = to_double_or_nan(grad_norm); + const double k = to_double_or_nan(condition); + + if (converged == "yes" && pd == "yes" && std::isfinite(g) && + std::isfinite(k) && g < 1.0e-2 && k < 100.0) { + return "EXCELLENT"; + } + + if (converged == "yes" && pd == "yes" && std::isfinite(g) && + std::isfinite(k) && g < 1.0e-1 && k < 1000.0) { + return "GOOD"; + } + + if (converged == "yes") + return "REVIEW"; + + return "CHECK"; +} + +inline ModelHealthStatus evaluate_model_health(const std::string &converged, + const std::string &grad_norm, + const std::string &pd, + const std::string &condition) { + ModelHealthStatus out; + out.optimization = health_pass_fail(converged); + out.gradient = health_gradient_label(grad_norm); + out.curvature = health_pass_fail(pd); + out.conditioning = health_label_from_condition_number(condition); + out.optimization_quality = + optimization_quality_label(converged, grad_norm, pd, condition); + + const bool healthy = + out.optimization == "PASS" && out.gradient == "PASS" && + out.curvature == "PASS" && + (out.conditioning == "EXCELLENT" || out.conditioning == "GOOD"); + + const bool high_confidence = + out.optimization == "PASS" && out.gradient == "PASS" && + out.curvature == "PASS" && out.conditioning == "EXCELLENT"; + + out.overall = healthy ? "HEALTHY" : "REVIEW"; + out.confidence = high_confidence ? "HIGH" : (healthy ? "MODERATE" : "LOW"); + + return out; +} + +} // namespace diagnostics +} // namespace quadra diff --git a/core/laplace.hpp b/core/laplace.hpp index 4c21222..c2fff58 100644 --- a/core/laplace.hpp +++ b/core/laplace.hpp @@ -1,3 +1,4 @@ +#include "laplace/exact_gradient_workspace.hpp" #include #include #ifndef QUADRA_LAPLACE_HPP @@ -11,6 +12,7 @@ #include "autodiff.hpp" #include "evaluation.hpp" #include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include "laplace/laplace_gradient_diagnostics.hpp" #include #include #include @@ -446,11 +448,14 @@ template std::vector solve_random_effects_laplace( Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, const std::vector &fixed_idx, const std::vector &random_idx, - had::ADGraph &graph) { + had::ADGraph &graph, const std::vector *u_init_override = nullptr) { const int max_iter = 20; const double tol = 1e-8; - std::vector u(random_idx.size(), 0.0); + std::vector u = (u_init_override != nullptr && + u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); for (int iter = 0; iter < max_iter; ++iter) { @@ -492,11 +497,12 @@ std::vector solve_random_effects_laplace( } if (g.norm() < tol) { - std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 - << ", fx = " << std::setw(14) << std::fixed - << std::setprecision(6) << nll.val - << ", |grad| = " << std::setw(12) << std::fixed - << std::setprecision(6) << g.norm() << "\n"; + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; return u; } @@ -528,11 +534,12 @@ std::vector solve_random_effects_laplace( throw std::runtime_error( "Sparse Hessian solve failed in solve_random_effects_laplace"); } - std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 - << ", fx = " << std::setw(14) << std::fixed - << std::setprecision(6) << nll.val - << ", |grad| = " << std::setw(12) << std::fixed - << std::setprecision(6) << g.norm() << "\n"; + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; // -------------------------------------------------- // Newton update // -------------------------------------------------- @@ -984,6 +991,8 @@ Eigen::SparseMatrix random_hessian_directional_fd( Eigen::SparseMatrix Hminus = compute_random_hessian_sparse(model, params); + const auto timing_hdot_end = std::chrono::steady_clock::now(); + // Restore baseline state for caller hygiene. inject_fixed_params(theta, params, fixed_idx); inject_random_params(u, params, random_idx); @@ -1090,6 +1099,156 @@ Eigen::SparseMatrix random_hessian_directional_exact( return Hdot; } +template +std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; +} + +template +Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument("random_hessian_trace_terms_exact_workspace: " + "du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); +} + //================================================== // Exact Laplace log-determinant gradient contribution // @@ -1116,6 +1275,7 @@ Eigen::VectorXd laplace_logdet_gradient_exact( Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, const Eigen::VectorXd &u_hat, const LaplaceOptions &options = default_laplace_options()) { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); const auto fixed_idx = build_fixed_index(params); const auto random_idx = build_random_index(params); @@ -1132,6 +1292,8 @@ Eigen::VectorXd laplace_logdet_gradient_exact( // 2. the baseline H_uu numeric values, // 3. the matrix used for log-det trace solves. // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + had::ADGraph pattern_graph; ADScope pattern_scope(pattern_graph); @@ -1156,16 +1318,22 @@ Eigen::VectorXd laplace_logdet_gradient_exact( extract_sparse_hessian(pattern_scope, p_pattern, random_idx, get_pattern_for_logdet, options.hessian_drop_tol); + const auto timing_baseline_end = std::chrono::steady_clock::now(); + // -------------------------------------------------- // Factorize H_uu. // // Adaptive jitter is applied only if the unmodified H fails. // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + Eigen::SimplicialLDLT> solver; Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( H, solver, "laplace_logdet_gradient_exact", options); + const auto timing_factor_end = std::chrono::steady_clock::now(); + // -------------------------------------------------- // Compute all implicit random-effect sensitivities: // @@ -1173,49 +1341,134 @@ Eigen::VectorXd laplace_logdet_gradient_exact( // // Reuse the same H_uu factorization used for trace solves. // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + laplace::diagnostics::print_du_dtheta_summary(dU); + + const auto timing_du_end = std::chrono::steady_clock::now(); + + const auto timing_hdot_start = std::chrono::steady_clock::now(); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + for (Eigen::Index i = 0; i < theta.size(); ++i) { - // Exact directional Hessian: - // - // Hdot = D H_uu(theta, u*) [e_i, du*/dtheta_i] - // - Eigen::SparseMatrix Hdot = random_hessian_directional_exact( - model, params, theta, u_hat, i, dU.col(i), get_pattern_for_logdet); - -#ifdef QUADRA_VALIDATE_HDOT - if (options.validate_hdot) { - constexpr double validation_eps = 1e-5; - - Eigen::SparseMatrix Hdot_fd = - random_hessian_directional_implicit_fd_with_du( - model, params, theta, u_hat, i, dU.col(i), validation_eps); + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + +#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + { + const Eigen::MatrixXd zero_dU = + Eigen::MatrixXd::Zero(u_hat.size(), theta.size()); + + const auto Hdots_theta_only = random_hessian_directional_exact_all( + model, params, theta, u_hat, zero_dU, get_pattern_for_logdet); + + Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index i = 0; i < theta.size(); ++i) { + theta_only[i] = + 0.5 * + logdet_directional_derivative_from_hdot( + solver, Hdots_theta_only[static_cast(i)], options); + } + + laplace::diagnostics::print_theta_only_vs_total_logdet_gradient(theta_only, + grad); + } +#endif - Eigen::SparseMatrix diff = Hdot - Hdot_fd; +#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE + { + Eigen::VectorXd fd_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd exact_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd rel_hdot_err = Eigen::VectorXd::Zero(theta.size()); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix Hdot_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, i, dU.col(i), 1.0e-5); - const double fd_norm = Hdot_fd.norm(); + const Eigen::SparseMatrix &Hdot_exact = + Hdots[static_cast(i)]; - const double rel_err = diff.norm() / std::max(1e-12, fd_norm); + fd_trace[i] = 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_fd, options); + exact_trace[i] = 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_exact, options); - std::cout << "Quadra Hdot validation: fixed index " << i - << ", rel_err = " << rel_err << ", exact_norm = " << Hdot.norm() - << ", fd_norm = " << fd_norm - << ", exact nnz = " << Hdot.nonZeros() - << ", fd nnz = " << Hdot_fd.nonZeros() << "\n"; + const Eigen::SparseMatrix diff = Hdot_exact - Hdot_fd; + rel_hdot_err[i] = diff.norm() / std::max(1.0e-12, Hdot_fd.norm()); } + + laplace::diagnostics::print_hdot_exact_vs_fd_trace(exact_trace, fd_trace, + rel_hdot_err); + } #endif - grad[i] = - 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + const auto timing_hdot_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace(model, params, theta, u_hat, + dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) { + trusted_trace[ii] = 2.0 * grad[ii]; } + const double workspace_rel_err = (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() << "\n"; +#endif + // Restore baseline state for caller hygiene. inject_fixed_params(theta, params, fixed_idx); inject_random_params(u, params, random_idx); + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration(timing_logdet_exact_end - + timing_logdet_exact_start) + .count(); + const double baseline_ms = std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration(timing_du_end - timing_du_start) + .count(); + const double hdot_ms = std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms << " factor=" << factor_ms + << " du=" << du_ms << " hdot_trace=" << hdot_ms << "\n"; +#endif return grad; } @@ -1232,6 +1485,17 @@ Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, } template struct LaplaceResult { + + // Component breakdown of the Laplace objective: + // + // value = joint_objective + 0.5 * laplace_logdet - laplace_constant + // + // These are intentionally stored for diagnostics/reporting and for + // optimizer-side bookkeeping. They do not change the objective math. + double joint_objective = 0.0; + double laplace_logdet = 0.0; + double laplace_constant = 0.0; + double value; std::vector grad_x; std::vector grad_u; @@ -1300,7 +1564,10 @@ LaplaceResult laplace_eval_at_u_star( // Or, if vectorD() is unavailable: // double logdet = sparse_logdet_llt(H); - res.value = value_of(nll) + 0.5 * logdet; + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; return res; } diff --git a/core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035 b/core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035 new file mode 100644 index 0000000..b762866 --- /dev/null +++ b/core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035 @@ -0,0 +1,1927 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + } + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); +#endif + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double + { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) + { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.bad-gradient-diagnostic.20260613_110931.bak b/core/laplace.hpp.bad-gradient-diagnostic.20260613_110931.bak new file mode 100644 index 0000000..9ae624c --- /dev/null +++ b/core/laplace.hpp.bad-gradient-diagnostic.20260613_110931.bak @@ -0,0 +1,1928 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + } + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); +#endif + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double + { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) + { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.bad_gradient_diagnostics.20260613_110717 b/core/laplace.hpp.bad_gradient_diagnostics.20260613_110717 new file mode 100644 index 0000000..a3f7789 --- /dev/null +++ b/core/laplace.hpp.bad_gradient_diagnostics.20260613_110717 @@ -0,0 +1,1953 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include + +#if defined(QUADRA_GRADIENT_DIAGNOSTIC) +#define QUADRA_DIAG_VEC(name, v) \ + do { \ + std::cerr << "[quadra gradient diagnostic] " << name \ + << " size=" << (v).size() \ + << " norm=" << (v).norm() \ + << " values=" << (v).transpose() << "\n"; \ + } while (false) +#define QUADRA_DIAG_MAT(name, m) \ + do { \ + std::cerr << "[quadra gradient diagnostic] " << name \ + << " rows=" << (m).rows() \ + << " cols=" << (m).cols() \ + << " norm=" << (m).norm() << "\n"; \ + } while (false) +#define QUADRA_DIAG_SCALAR(name, x) \ + do { \ + std::cerr << "[quadra gradient diagnostic] " << name << " = " << (x) << "\n"; \ + } while (false) +#else +#define QUADRA_DIAG_VEC(name, v) do {} while (false) +#define QUADRA_DIAG_MAT(name, m) do {} while (false) +#define QUADRA_DIAG_SCALAR(name, x) do {} while (false) +#endif +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + } + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); +#endif + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double + { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) + { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.bak.gradient_diagnostics.20260613_110318 b/core/laplace.hpp.bak.gradient_diagnostics.20260613_110318 new file mode 100644 index 0000000..9ae624c --- /dev/null +++ b/core/laplace.hpp.bak.gradient_diagnostics.20260613_110318 @@ -0,0 +1,1928 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + } + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); +#endif + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double + { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) + { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.before_diagnostics_header_cleanup.20260613_170558 b/core/laplace.hpp.before_diagnostics_header_cleanup.20260613_170558 new file mode 100644 index 0000000..29134d5 --- /dev/null +++ b/core/laplace.hpp.before_diagnostics_header_cleanup.20260613_170558 @@ -0,0 +1,1666 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra { + +using Eigen::MatrixXd; +using Eigen::VectorXd; + +//================================================== +// Laplace options +//================================================== +struct LaplaceOptions { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; +}; + +inline LaplaceOptions &default_laplace_options() { + static LaplaceOptions options; + return options; +} + +//============================== +// Build fixed index map +//============================== +inline std::vector build_fixed_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +//============================== +// Build random index map +//============================== +inline std::vector build_random_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +std::vector +build_u_init_from_cache(const std::vector &random_idx) { + return std::vector(random_idx.size(), 0.0); +} + +inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } +} + +template +inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) { + p[fixed_idx[k]] = x_ad[k]; + } +} + +template +inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); +} + +inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } +} + +template +inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = u_ad[k]; + } +} + +template +inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = Scalar(u[k]); + } +} + +template +std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) { + if (params.params[i].is_random) { + p[i] = u[u_k++]; + } else { + p[i] = x[x_k++]; + } + } + + return p; +} + +//================================================== +// Laplace-local Hessian pattern representation +//================================================== +// Do not name this HessianPattern. autodiff.hpp may define a +// graph-level HessianPattern helper for ADGraph sparsity discovery. +// Keeping the Laplace cache as SparseHessianPattern avoids redefinition +// errors and keeps this file independent of the exact autodiff helper API. +using SparseHessianPattern = std::vector>; + +inline std::unordered_map &laplace_pattern_cache() { + static std::unordered_map cache; + return cache; +} + +//================================================== +// Discover Hessian sparsity from had::ADGraph +//================================================== +// This replaces the older dense pattern probe. It reads the sparse +// edge-pushed Hessian storage that had::PropagateAdjoint() has already +// populated inside scope.backward(nll). +// +// NOTE: this is still a numeric sparsity pattern. If a structurally +// nonzero Hessian entry evaluates to exactly zero at the discovery point, +// it can be missed. Diagonals are included by default for Newton stability. +inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } else { + for (int i = 0; i < n; ++i) { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; +} + +inline const SparseHessianPattern & +get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; +} + +inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; +} + +inline SparseHessianPattern dense_hessian_pattern(int n) { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; +} + +inline Eigen::SparseMatrix +extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; +} + +inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) { + if (D[i] <= 0.0) { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; +} + +//================================================== +// Sparse factorization helpers +// +// Adaptive jitter is only applied if the original Hessian fails +// to factorize. This avoids biasing gradients near valid optima +// while still protecting against near-singular random-effect +// Hessians during stress tests or weakly identified models. +//================================================== +inline Eigen::SparseMatrix +add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) { + if (it.row() == it.col()) { + it.valueRef() += jitter; + } + } + } + + return H_reg; +} + +inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); +} + +inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) { + if (it.row() == it.col()) { + if (it.value() <= 0.0) { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; +} +//================================================== +// Solve for random effects u* via Newton +//================================================== +template +std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector* u_init_override = nullptr) { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) { + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) { + u[i] -= step[i]; + } + } + + return u; +} + +//================================================== +// Compute sparse random-effect Hessian at current params +//================================================== +template +Eigen::SparseMatrix +compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; +} + +//================================================== +// Laplace log-determinant at supplied fixed/random state +//================================================== +template +double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); +} + +//================================================== +// trace(H^{-1} Hdot), using an existing sparse factorization +//================================================== +//================================================== +// Stochastic Hutchinson trace estimator +// +// Approximates: +// +// trace(H^{-1} Hdot) +// +// using: +// +// E[zᵀ H^{-1} Hdot z] +// +// with Rademacher (+/-1) probe vectors. +// +// This avoids catastrophic dense materialization for large +// random-effect systems. +//================================================== +template +double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) { + if (Hdot.rows() != Hdot.cols()) { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); +} + +//================================================== +// Finite-difference directional derivative of random Hessian +// Hdot = d H_u(theta)[direction] +//================================================== + +//================================================== +// Implicit sensitivity of optimized random effects +// +// u*(theta) satisfies f_u(theta, u*) = 0. +// Differentiating: +// +// H_uu du*/dtheta_i + H_u theta_i = 0 +// +// so: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// This avoids re-solving the random effects for theta +/- eps. +//================================================== +template +Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; +} + +//================================================== +// Fast implicit sensitivities for all fixed effects +// +// Reuses one H_uu factorization and computes: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// for every fixed-effect direction. +// +// Columns of the returned matrix correspond to fixed effects. +//================================================== +template +Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) { + if (Huu_reuse != nullptr) { + solver_local.compute(*Huu_reuse); + } else { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + + return du; +} + +//================================================== +// Same as random_hessian_directional_implicit_fd(), but accepts +// a precomputed du*/dtheta_i vector. This avoids refactorizing +// H_uu inside every fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Implicit-direction finite-difference derivative of H_uu +// +// Instead of expensive profiled FD: +// +// H(theta +/- eps, u*(theta +/- eps)) +// +// this uses: +// +// u*(theta +/- eps e_i) +// ~= u*(theta) +/- eps du*/dtheta_i +// +// and computes: +// +// Hdot_i ~= [H(theta+eps e_i, u+eps du_i) +// - H(theta-eps e_i, u-eps du_i)] / (2 eps) +// +// This is still a finite-difference bridge, but it avoids nested +// random-effect Newton solves for each fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +template +Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) { + if (theta.size() != direction.size()) { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Finite-difference Laplace logdet gradient contribution +// +// Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. +// This is intentionally written through Hdot + trace(H^{-1}Hdot) +// so exact third-order AD can replace random_hessian_directional_fd() +// later without changing this public interface. +//================================================== + +//================================================== +// Exact directional derivative of H_uu using directional edge-pushing +// +// Computes: +// +// Hdot = D H_uu(theta, u*) [theta_direction, u_direction] +// +// This is the intended replacement for: +// +// (Hplus - Hminus) / (2 eps) +// +// and avoids finite-difference Hessian rebuilds. +// +// Requires had_quadra_hdot.hpp / updated had_quadra.h support for: +// had::PropagateAdjointDirectional() +// had::GetAdjointDot(...) +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; +} + +template +std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; +} + + +template +Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); +} + +//================================================== +// Exact Laplace log-determinant gradient contribution +// +// Computes gradient of: +// +// 0.5 * log det(H_uu(theta, u*(theta))) +// +// using: +// +// du*/dtheta_i = - H_uu^{-1} H_{u theta_i} +// +// and exact directional Hessian propagation: +// +// Hdot_i = D H_uu [e_i, du*/dtheta_i] +// +// No finite-difference Hplus/Hminus path is used in production. +// +// Note: +// The derivative propagation is exact. The trace may still be stochastic +// if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. +//================================================== +template +Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + +#ifdef QUADRA_DEBUG_DU_DTHETA_NORMS + { + std::cout << "Quadra dU diagnostic\n"; + std::cout << " dU_col_norms = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).norm(); + if (j + 1 < dU.cols()) { + std::cout << " "; + } + } + std::cout << "\n"; + + std::cout << " dU_col_maxabs = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).cwiseAbs().maxCoeff(); + if (j + 1 < dU.cols()) { + std::cout << " "; + } + } + std::cout << "\n"; + + std::cout << " dU_first_rows ="; + const Eigen::Index nprint = std::min(5, dU.rows()); + for (Eigen::Index r = 0; r < nprint; ++r) { + std::cout << "\n row " << r << ": "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU(r, j); + if (j + 1 < dU.cols()) { + std::cout << " "; + } + } + } + std::cout << "\n"; + } +#endif + + const auto timing_du_end = std::chrono::steady_clock::now(); + + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + +#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + { + const Eigen::MatrixXd zero_dU = + Eigen::MatrixXd::Zero(u_hat.size(), theta.size()); + + const auto Hdots_theta_only = random_hessian_directional_exact_all( + model, params, theta, u_hat, zero_dU, get_pattern_for_logdet); + + Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index i = 0; i < theta.size(); ++i) { + theta_only[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdots_theta_only[static_cast(i)], + options); + } + + std::cout << "Quadra logdet Hdot diagnostic\n"; + std::cout << " theta_only_logdet_grad = " + << theta_only.transpose() << "\n"; + std::cout << " total_logdet_grad = " + << grad.transpose() << "\n"; + std::cout << " implicit_u_contribution= " + << (grad - theta_only).transpose() << "\n"; + } +#endif + + +#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE + { + Eigen::VectorXd fd_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd exact_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd rel_hdot_err = Eigen::VectorXd::Zero(theta.size()); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix Hdot_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, i, dU.col(i), 1.0e-5); + + const Eigen::SparseMatrix &Hdot_exact = + Hdots[static_cast(i)]; + + fd_trace[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_fd, options); + exact_trace[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_exact, options); + + const Eigen::SparseMatrix diff = Hdot_exact - Hdot_fd; + rel_hdot_err[i] = + diff.norm() / std::max(1.0e-12, Hdot_fd.norm()); + } + + std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\n"; + std::cout << " exact_total_logdet_grad = " + << exact_trace.transpose() << "\n"; + std::cout << " fd_total_logdet_grad = " + << fd_trace.transpose() << "\n"; + std::cout << " exact_minus_fd = " + << (exact_trace - fd_trace).transpose() << "\n"; + std::cout << " rel_Hdot_matrix_err = " + << rel_hdot_err.transpose() << "\n"; + } +#endif + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; +} + +// Backward-compatible wrapper. +// Deprecated name: the default path is exact-Hdot, not finite-difference. +template +Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double /*eps*/ = 1e-5) { + return laplace_logdet_gradient_exact(model, params, theta, u_hat, + default_laplace_options()); +} + +template struct LaplaceResult { + + // Component breakdown of the Laplace objective: + // + // value = joint_objective + 0.5 * laplace_logdet - laplace_constant + // + // These are intentionally stored for diagnostics/reporting and for + // optimizer-side bookkeeping. They do not change the objective math. + double joint_objective = 0.0; + double laplace_logdet = 0.0; + double laplace_constant = 0.0; + + double value; + std::vector grad_x; + std::vector grad_u; +}; + +template +LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + // This is currently finite-difference based through Hdot + trace(H^{-1}Hdot). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; +} + +#ifndef QUADRA_USE_ORIGINAL_HAD +//================================================== +// Optional third-order directional diagnostic. +// This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 +// using the scalar-templated model path. It is intentionally +// separate from LBFGS/Laplace so it can be enabled only when needed. +//================================================== +template +ThirdDirectionalResult +third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 { return model(x_ad3); }, xv, + dv); +} +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.before_du_dtheta_norm_diagnostic.20260613_144107 b/core/laplace.hpp.before_du_dtheta_norm_diagnostic.20260613_144107 new file mode 100644 index 0000000..11125aa --- /dev/null +++ b/core/laplace.hpp.before_du_dtheta_norm_diagnostic.20260613_144107 @@ -0,0 +1,1630 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra { + +using Eigen::MatrixXd; +using Eigen::VectorXd; + +//================================================== +// Laplace options +//================================================== +struct LaplaceOptions { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; +}; + +inline LaplaceOptions &default_laplace_options() { + static LaplaceOptions options; + return options; +} + +//============================== +// Build fixed index map +//============================== +inline std::vector build_fixed_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +//============================== +// Build random index map +//============================== +inline std::vector build_random_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +std::vector +build_u_init_from_cache(const std::vector &random_idx) { + return std::vector(random_idx.size(), 0.0); +} + +inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } +} + +template +inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) { + p[fixed_idx[k]] = x_ad[k]; + } +} + +template +inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); +} + +inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } +} + +template +inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = u_ad[k]; + } +} + +template +inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = Scalar(u[k]); + } +} + +template +std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) { + if (params.params[i].is_random) { + p[i] = u[u_k++]; + } else { + p[i] = x[x_k++]; + } + } + + return p; +} + +//================================================== +// Laplace-local Hessian pattern representation +//================================================== +// Do not name this HessianPattern. autodiff.hpp may define a +// graph-level HessianPattern helper for ADGraph sparsity discovery. +// Keeping the Laplace cache as SparseHessianPattern avoids redefinition +// errors and keeps this file independent of the exact autodiff helper API. +using SparseHessianPattern = std::vector>; + +inline std::unordered_map &laplace_pattern_cache() { + static std::unordered_map cache; + return cache; +} + +//================================================== +// Discover Hessian sparsity from had::ADGraph +//================================================== +// This replaces the older dense pattern probe. It reads the sparse +// edge-pushed Hessian storage that had::PropagateAdjoint() has already +// populated inside scope.backward(nll). +// +// NOTE: this is still a numeric sparsity pattern. If a structurally +// nonzero Hessian entry evaluates to exactly zero at the discovery point, +// it can be missed. Diagonals are included by default for Newton stability. +inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } else { + for (int i = 0; i < n; ++i) { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; +} + +inline const SparseHessianPattern & +get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; +} + +inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; +} + +inline SparseHessianPattern dense_hessian_pattern(int n) { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; +} + +inline Eigen::SparseMatrix +extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; +} + +inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) { + if (D[i] <= 0.0) { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; +} + +//================================================== +// Sparse factorization helpers +// +// Adaptive jitter is only applied if the original Hessian fails +// to factorize. This avoids biasing gradients near valid optima +// while still protecting against near-singular random-effect +// Hessians during stress tests or weakly identified models. +//================================================== +inline Eigen::SparseMatrix +add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) { + if (it.row() == it.col()) { + it.valueRef() += jitter; + } + } + } + + return H_reg; +} + +inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); +} + +inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) { + if (it.row() == it.col()) { + if (it.value() <= 0.0) { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; +} +//================================================== +// Solve for random effects u* via Newton +//================================================== +template +std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector* u_init_override = nullptr) { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) { + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) { + u[i] -= step[i]; + } + } + + return u; +} + +//================================================== +// Compute sparse random-effect Hessian at current params +//================================================== +template +Eigen::SparseMatrix +compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; +} + +//================================================== +// Laplace log-determinant at supplied fixed/random state +//================================================== +template +double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); +} + +//================================================== +// trace(H^{-1} Hdot), using an existing sparse factorization +//================================================== +//================================================== +// Stochastic Hutchinson trace estimator +// +// Approximates: +// +// trace(H^{-1} Hdot) +// +// using: +// +// E[zᵀ H^{-1} Hdot z] +// +// with Rademacher (+/-1) probe vectors. +// +// This avoids catastrophic dense materialization for large +// random-effect systems. +//================================================== +template +double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) { + if (Hdot.rows() != Hdot.cols()) { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); +} + +//================================================== +// Finite-difference directional derivative of random Hessian +// Hdot = d H_u(theta)[direction] +//================================================== + +//================================================== +// Implicit sensitivity of optimized random effects +// +// u*(theta) satisfies f_u(theta, u*) = 0. +// Differentiating: +// +// H_uu du*/dtheta_i + H_u theta_i = 0 +// +// so: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// This avoids re-solving the random effects for theta +/- eps. +//================================================== +template +Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; +} + +//================================================== +// Fast implicit sensitivities for all fixed effects +// +// Reuses one H_uu factorization and computes: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// for every fixed-effect direction. +// +// Columns of the returned matrix correspond to fixed effects. +//================================================== +template +Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) { + if (Huu_reuse != nullptr) { + solver_local.compute(*Huu_reuse); + } else { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + + return du; +} + +//================================================== +// Same as random_hessian_directional_implicit_fd(), but accepts +// a precomputed du*/dtheta_i vector. This avoids refactorizing +// H_uu inside every fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Implicit-direction finite-difference derivative of H_uu +// +// Instead of expensive profiled FD: +// +// H(theta +/- eps, u*(theta +/- eps)) +// +// this uses: +// +// u*(theta +/- eps e_i) +// ~= u*(theta) +/- eps du*/dtheta_i +// +// and computes: +// +// Hdot_i ~= [H(theta+eps e_i, u+eps du_i) +// - H(theta-eps e_i, u-eps du_i)] / (2 eps) +// +// This is still a finite-difference bridge, but it avoids nested +// random-effect Newton solves for each fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +template +Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) { + if (theta.size() != direction.size()) { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Finite-difference Laplace logdet gradient contribution +// +// Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. +// This is intentionally written through Hdot + trace(H^{-1}Hdot) +// so exact third-order AD can replace random_hessian_directional_fd() +// later without changing this public interface. +//================================================== + +//================================================== +// Exact directional derivative of H_uu using directional edge-pushing +// +// Computes: +// +// Hdot = D H_uu(theta, u*) [theta_direction, u_direction] +// +// This is the intended replacement for: +// +// (Hplus - Hminus) / (2 eps) +// +// and avoids finite-difference Hessian rebuilds. +// +// Requires had_quadra_hdot.hpp / updated had_quadra.h support for: +// had::PropagateAdjointDirectional() +// had::GetAdjointDot(...) +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; +} + +template +std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; +} + + +template +Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); +} + +//================================================== +// Exact Laplace log-determinant gradient contribution +// +// Computes gradient of: +// +// 0.5 * log det(H_uu(theta, u*(theta))) +// +// using: +// +// du*/dtheta_i = - H_uu^{-1} H_{u theta_i} +// +// and exact directional Hessian propagation: +// +// Hdot_i = D H_uu [e_i, du*/dtheta_i] +// +// No finite-difference Hplus/Hminus path is used in production. +// +// Note: +// The derivative propagation is exact. The trace may still be stochastic +// if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. +//================================================== +template +Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + +#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + { + const Eigen::MatrixXd zero_dU = + Eigen::MatrixXd::Zero(u_hat.size(), theta.size()); + + const auto Hdots_theta_only = random_hessian_directional_exact_all( + model, params, theta, u_hat, zero_dU, get_pattern_for_logdet); + + Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index i = 0; i < theta.size(); ++i) { + theta_only[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdots_theta_only[static_cast(i)], + options); + } + + std::cout << "Quadra logdet Hdot diagnostic\n"; + std::cout << " theta_only_logdet_grad = " + << theta_only.transpose() << "\n"; + std::cout << " total_logdet_grad = " + << grad.transpose() << "\n"; + std::cout << " implicit_u_contribution= " + << (grad - theta_only).transpose() << "\n"; + } +#endif + + +#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE + { + Eigen::VectorXd fd_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd exact_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd rel_hdot_err = Eigen::VectorXd::Zero(theta.size()); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix Hdot_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, i, dU.col(i), 1.0e-5); + + const Eigen::SparseMatrix &Hdot_exact = + Hdots[static_cast(i)]; + + fd_trace[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_fd, options); + exact_trace[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_exact, options); + + const Eigen::SparseMatrix diff = Hdot_exact - Hdot_fd; + rel_hdot_err[i] = + diff.norm() / std::max(1.0e-12, Hdot_fd.norm()); + } + + std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\n"; + std::cout << " exact_total_logdet_grad = " + << exact_trace.transpose() << "\n"; + std::cout << " fd_total_logdet_grad = " + << fd_trace.transpose() << "\n"; + std::cout << " exact_minus_fd = " + << (exact_trace - fd_trace).transpose() << "\n"; + std::cout << " rel_Hdot_matrix_err = " + << rel_hdot_err.transpose() << "\n"; + } +#endif + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; +} + +// Backward-compatible wrapper. +// Deprecated name: the default path is exact-Hdot, not finite-difference. +template +Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double /*eps*/ = 1e-5) { + return laplace_logdet_gradient_exact(model, params, theta, u_hat, + default_laplace_options()); +} + +template struct LaplaceResult { + + // Component breakdown of the Laplace objective: + // + // value = joint_objective + 0.5 * laplace_logdet - laplace_constant + // + // These are intentionally stored for diagnostics/reporting and for + // optimizer-side bookkeeping. They do not change the objective math. + double joint_objective = 0.0; + double laplace_logdet = 0.0; + double laplace_constant = 0.0; + + double value; + std::vector grad_x; + std::vector grad_u; +}; + +template +LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + // This is currently finite-difference based through Hdot + trace(H^{-1}Hdot). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; +} + +#ifndef QUADRA_USE_ORIGINAL_HAD +//================================================== +// Optional third-order directional diagnostic. +// This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 +// using the scalar-templated model path. It is intentionally +// separate from LBFGS/Laplace so it can be enabled only when needed. +//================================================== +template +ThirdDirectionalResult +third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 { return model(x_ad3); }, xv, + dv); +} +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.before_hdot_exact_vs_fd_trace_diagnostic.20260613_142755 b/core/laplace.hpp.before_hdot_exact_vs_fd_trace_diagnostic.20260613_142755 new file mode 100644 index 0000000..003500a --- /dev/null +++ b/core/laplace.hpp.before_hdot_exact_vs_fd_trace_diagnostic.20260613_142755 @@ -0,0 +1,1591 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra { + +using Eigen::MatrixXd; +using Eigen::VectorXd; + +//================================================== +// Laplace options +//================================================== +struct LaplaceOptions { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; +}; + +inline LaplaceOptions &default_laplace_options() { + static LaplaceOptions options; + return options; +} + +//============================== +// Build fixed index map +//============================== +inline std::vector build_fixed_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +//============================== +// Build random index map +//============================== +inline std::vector build_random_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +std::vector +build_u_init_from_cache(const std::vector &random_idx) { + return std::vector(random_idx.size(), 0.0); +} + +inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } +} + +template +inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) { + p[fixed_idx[k]] = x_ad[k]; + } +} + +template +inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); +} + +inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } +} + +template +inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = u_ad[k]; + } +} + +template +inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = Scalar(u[k]); + } +} + +template +std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) { + if (params.params[i].is_random) { + p[i] = u[u_k++]; + } else { + p[i] = x[x_k++]; + } + } + + return p; +} + +//================================================== +// Laplace-local Hessian pattern representation +//================================================== +// Do not name this HessianPattern. autodiff.hpp may define a +// graph-level HessianPattern helper for ADGraph sparsity discovery. +// Keeping the Laplace cache as SparseHessianPattern avoids redefinition +// errors and keeps this file independent of the exact autodiff helper API. +using SparseHessianPattern = std::vector>; + +inline std::unordered_map &laplace_pattern_cache() { + static std::unordered_map cache; + return cache; +} + +//================================================== +// Discover Hessian sparsity from had::ADGraph +//================================================== +// This replaces the older dense pattern probe. It reads the sparse +// edge-pushed Hessian storage that had::PropagateAdjoint() has already +// populated inside scope.backward(nll). +// +// NOTE: this is still a numeric sparsity pattern. If a structurally +// nonzero Hessian entry evaluates to exactly zero at the discovery point, +// it can be missed. Diagonals are included by default for Newton stability. +inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } else { + for (int i = 0; i < n; ++i) { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; +} + +inline const SparseHessianPattern & +get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; +} + +inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; +} + +inline SparseHessianPattern dense_hessian_pattern(int n) { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; +} + +inline Eigen::SparseMatrix +extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; +} + +inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) { + if (D[i] <= 0.0) { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; +} + +//================================================== +// Sparse factorization helpers +// +// Adaptive jitter is only applied if the original Hessian fails +// to factorize. This avoids biasing gradients near valid optima +// while still protecting against near-singular random-effect +// Hessians during stress tests or weakly identified models. +//================================================== +inline Eigen::SparseMatrix +add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) { + if (it.row() == it.col()) { + it.valueRef() += jitter; + } + } + } + + return H_reg; +} + +inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); +} + +inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) { + if (it.row() == it.col()) { + if (it.value() <= 0.0) { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; +} +//================================================== +// Solve for random effects u* via Newton +//================================================== +template +std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector* u_init_override = nullptr) { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) { + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) { + u[i] -= step[i]; + } + } + + return u; +} + +//================================================== +// Compute sparse random-effect Hessian at current params +//================================================== +template +Eigen::SparseMatrix +compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; +} + +//================================================== +// Laplace log-determinant at supplied fixed/random state +//================================================== +template +double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); +} + +//================================================== +// trace(H^{-1} Hdot), using an existing sparse factorization +//================================================== +//================================================== +// Stochastic Hutchinson trace estimator +// +// Approximates: +// +// trace(H^{-1} Hdot) +// +// using: +// +// E[zᵀ H^{-1} Hdot z] +// +// with Rademacher (+/-1) probe vectors. +// +// This avoids catastrophic dense materialization for large +// random-effect systems. +//================================================== +template +double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) { + if (Hdot.rows() != Hdot.cols()) { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); +} + +//================================================== +// Finite-difference directional derivative of random Hessian +// Hdot = d H_u(theta)[direction] +//================================================== + +//================================================== +// Implicit sensitivity of optimized random effects +// +// u*(theta) satisfies f_u(theta, u*) = 0. +// Differentiating: +// +// H_uu du*/dtheta_i + H_u theta_i = 0 +// +// so: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// This avoids re-solving the random effects for theta +/- eps. +//================================================== +template +Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; +} + +//================================================== +// Fast implicit sensitivities for all fixed effects +// +// Reuses one H_uu factorization and computes: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// for every fixed-effect direction. +// +// Columns of the returned matrix correspond to fixed effects. +//================================================== +template +Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) { + if (Huu_reuse != nullptr) { + solver_local.compute(*Huu_reuse); + } else { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + + return du; +} + +//================================================== +// Same as random_hessian_directional_implicit_fd(), but accepts +// a precomputed du*/dtheta_i vector. This avoids refactorizing +// H_uu inside every fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Implicit-direction finite-difference derivative of H_uu +// +// Instead of expensive profiled FD: +// +// H(theta +/- eps, u*(theta +/- eps)) +// +// this uses: +// +// u*(theta +/- eps e_i) +// ~= u*(theta) +/- eps du*/dtheta_i +// +// and computes: +// +// Hdot_i ~= [H(theta+eps e_i, u+eps du_i) +// - H(theta-eps e_i, u-eps du_i)] / (2 eps) +// +// This is still a finite-difference bridge, but it avoids nested +// random-effect Newton solves for each fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +template +Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) { + if (theta.size() != direction.size()) { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Finite-difference Laplace logdet gradient contribution +// +// Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. +// This is intentionally written through Hdot + trace(H^{-1}Hdot) +// so exact third-order AD can replace random_hessian_directional_fd() +// later without changing this public interface. +//================================================== + +//================================================== +// Exact directional derivative of H_uu using directional edge-pushing +// +// Computes: +// +// Hdot = D H_uu(theta, u*) [theta_direction, u_direction] +// +// This is the intended replacement for: +// +// (Hplus - Hminus) / (2 eps) +// +// and avoids finite-difference Hessian rebuilds. +// +// Requires had_quadra_hdot.hpp / updated had_quadra.h support for: +// had::PropagateAdjointDirectional() +// had::GetAdjointDot(...) +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; +} + +template +std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; +} + + +template +Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); +} + +//================================================== +// Exact Laplace log-determinant gradient contribution +// +// Computes gradient of: +// +// 0.5 * log det(H_uu(theta, u*(theta))) +// +// using: +// +// du*/dtheta_i = - H_uu^{-1} H_{u theta_i} +// +// and exact directional Hessian propagation: +// +// Hdot_i = D H_uu [e_i, du*/dtheta_i] +// +// No finite-difference Hplus/Hminus path is used in production. +// +// Note: +// The derivative propagation is exact. The trace may still be stochastic +// if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. +//================================================== +template +Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + +#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + { + const Eigen::MatrixXd zero_dU = + Eigen::MatrixXd::Zero(u_hat.size(), theta.size()); + + const auto Hdots_theta_only = random_hessian_directional_exact_all( + model, params, theta, u_hat, zero_dU, get_pattern_for_logdet); + + Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index i = 0; i < theta.size(); ++i) { + theta_only[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdots_theta_only[static_cast(i)], + options); + } + + std::cout << "Quadra logdet Hdot diagnostic\n"; + std::cout << " theta_only_logdet_grad = " + << theta_only.transpose() << "\n"; + std::cout << " total_logdet_grad = " + << grad.transpose() << "\n"; + std::cout << " implicit_u_contribution= " + << (grad - theta_only).transpose() << "\n"; + } +#endif + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; +} + +// Backward-compatible wrapper. +// Deprecated name: the default path is exact-Hdot, not finite-difference. +template +Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double /*eps*/ = 1e-5) { + return laplace_logdet_gradient_exact(model, params, theta, u_hat, + default_laplace_options()); +} + +template struct LaplaceResult { + + // Component breakdown of the Laplace objective: + // + // value = joint_objective + 0.5 * laplace_logdet - laplace_constant + // + // These are intentionally stored for diagnostics/reporting and for + // optimizer-side bookkeeping. They do not change the objective math. + double joint_objective = 0.0; + double laplace_logdet = 0.0; + double laplace_constant = 0.0; + + double value; + std::vector grad_x; + std::vector grad_u; +}; + +template +LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + // This is currently finite-difference based through Hdot + trace(H^{-1}Hdot). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; +} + +#ifndef QUADRA_USE_ORIGINAL_HAD +//================================================== +// Optional third-order directional diagnostic. +// This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 +// using the scalar-templated model path. It is intentionally +// separate from LBFGS/Laplace so it can be enabled only when needed. +//================================================== +template +ThirdDirectionalResult +third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 { return model(x_ad3); }, xv, + dv); +} +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.before_laplace_result_component_fields.20260613_113223 b/core/laplace.hpp.before_laplace_result_component_fields.20260613_113223 new file mode 100644 index 0000000..a65a592 --- /dev/null +++ b/core/laplace.hpp.before_laplace_result_component_fields.20260613_113223 @@ -0,0 +1,1554 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra { + +using Eigen::MatrixXd; +using Eigen::VectorXd; + +//================================================== +// Laplace options +//================================================== +struct LaplaceOptions { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; +}; + +inline LaplaceOptions &default_laplace_options() { + static LaplaceOptions options; + return options; +} + +//============================== +// Build fixed index map +//============================== +inline std::vector build_fixed_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +//============================== +// Build random index map +//============================== +inline std::vector build_random_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +std::vector +build_u_init_from_cache(const std::vector &random_idx) { + return std::vector(random_idx.size(), 0.0); +} + +inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } +} + +template +inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) { + p[fixed_idx[k]] = x_ad[k]; + } +} + +template +inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); +} + +inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } +} + +template +inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = u_ad[k]; + } +} + +template +inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = Scalar(u[k]); + } +} + +template +std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) { + if (params.params[i].is_random) { + p[i] = u[u_k++]; + } else { + p[i] = x[x_k++]; + } + } + + return p; +} + +//================================================== +// Laplace-local Hessian pattern representation +//================================================== +// Do not name this HessianPattern. autodiff.hpp may define a +// graph-level HessianPattern helper for ADGraph sparsity discovery. +// Keeping the Laplace cache as SparseHessianPattern avoids redefinition +// errors and keeps this file independent of the exact autodiff helper API. +using SparseHessianPattern = std::vector>; + +inline std::unordered_map &laplace_pattern_cache() { + static std::unordered_map cache; + return cache; +} + +//================================================== +// Discover Hessian sparsity from had::ADGraph +//================================================== +// This replaces the older dense pattern probe. It reads the sparse +// edge-pushed Hessian storage that had::PropagateAdjoint() has already +// populated inside scope.backward(nll). +// +// NOTE: this is still a numeric sparsity pattern. If a structurally +// nonzero Hessian entry evaluates to exactly zero at the discovery point, +// it can be missed. Diagonals are included by default for Newton stability. +inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } else { + for (int i = 0; i < n; ++i) { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; +} + +inline const SparseHessianPattern & +get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; +} + +inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; +} + +inline SparseHessianPattern dense_hessian_pattern(int n) { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; +} + +inline Eigen::SparseMatrix +extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; +} + +inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) { + if (D[i] <= 0.0) { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; +} + +//================================================== +// Sparse factorization helpers +// +// Adaptive jitter is only applied if the original Hessian fails +// to factorize. This avoids biasing gradients near valid optima +// while still protecting against near-singular random-effect +// Hessians during stress tests or weakly identified models. +//================================================== +inline Eigen::SparseMatrix +add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) { + if (it.row() == it.col()) { + it.valueRef() += jitter; + } + } + } + + return H_reg; +} + +inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); +} + +inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) { + if (it.row() == it.col()) { + if (it.value() <= 0.0) { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; +} +//================================================== +// Solve for random effects u* via Newton +//================================================== +template +std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector* u_init_override = nullptr) { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) { + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) { + u[i] -= step[i]; + } + } + + return u; +} + +//================================================== +// Compute sparse random-effect Hessian at current params +//================================================== +template +Eigen::SparseMatrix +compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; +} + +//================================================== +// Laplace log-determinant at supplied fixed/random state +//================================================== +template +double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); +} + +//================================================== +// trace(H^{-1} Hdot), using an existing sparse factorization +//================================================== +//================================================== +// Stochastic Hutchinson trace estimator +// +// Approximates: +// +// trace(H^{-1} Hdot) +// +// using: +// +// E[zᵀ H^{-1} Hdot z] +// +// with Rademacher (+/-1) probe vectors. +// +// This avoids catastrophic dense materialization for large +// random-effect systems. +//================================================== +template +double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) { + if (Hdot.rows() != Hdot.cols()) { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); +} + +//================================================== +// Finite-difference directional derivative of random Hessian +// Hdot = d H_u(theta)[direction] +//================================================== + +//================================================== +// Implicit sensitivity of optimized random effects +// +// u*(theta) satisfies f_u(theta, u*) = 0. +// Differentiating: +// +// H_uu du*/dtheta_i + H_u theta_i = 0 +// +// so: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// This avoids re-solving the random effects for theta +/- eps. +//================================================== +template +Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; +} + +//================================================== +// Fast implicit sensitivities for all fixed effects +// +// Reuses one H_uu factorization and computes: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// for every fixed-effect direction. +// +// Columns of the returned matrix correspond to fixed effects. +//================================================== +template +Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) { + if (Huu_reuse != nullptr) { + solver_local.compute(*Huu_reuse); + } else { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + + return du; +} + +//================================================== +// Same as random_hessian_directional_implicit_fd(), but accepts +// a precomputed du*/dtheta_i vector. This avoids refactorizing +// H_uu inside every fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Implicit-direction finite-difference derivative of H_uu +// +// Instead of expensive profiled FD: +// +// H(theta +/- eps, u*(theta +/- eps)) +// +// this uses: +// +// u*(theta +/- eps e_i) +// ~= u*(theta) +/- eps du*/dtheta_i +// +// and computes: +// +// Hdot_i ~= [H(theta+eps e_i, u+eps du_i) +// - H(theta-eps e_i, u-eps du_i)] / (2 eps) +// +// This is still a finite-difference bridge, but it avoids nested +// random-effect Newton solves for each fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +template +Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) { + if (theta.size() != direction.size()) { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Finite-difference Laplace logdet gradient contribution +// +// Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. +// This is intentionally written through Hdot + trace(H^{-1}Hdot) +// so exact third-order AD can replace random_hessian_directional_fd() +// later without changing this public interface. +//================================================== + +//================================================== +// Exact directional derivative of H_uu using directional edge-pushing +// +// Computes: +// +// Hdot = D H_uu(theta, u*) [theta_direction, u_direction] +// +// This is the intended replacement for: +// +// (Hplus - Hminus) / (2 eps) +// +// and avoids finite-difference Hessian rebuilds. +// +// Requires had_quadra_hdot.hpp / updated had_quadra.h support for: +// had::PropagateAdjointDirectional() +// had::GetAdjointDot(...) +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; +} + +template +std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; +} + + +template +Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); +} + +//================================================== +// Exact Laplace log-determinant gradient contribution +// +// Computes gradient of: +// +// 0.5 * log det(H_uu(theta, u*(theta))) +// +// using: +// +// du*/dtheta_i = - H_uu^{-1} H_{u theta_i} +// +// and exact directional Hessian propagation: +// +// Hdot_i = D H_uu [e_i, du*/dtheta_i] +// +// No finite-difference Hplus/Hminus path is used in production. +// +// Note: +// The derivative propagation is exact. The trace may still be stochastic +// if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. +//================================================== +template +Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; +} + +// Backward-compatible wrapper. +// Deprecated name: the default path is exact-Hdot, not finite-difference. +template +Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double /*eps*/ = 1e-5) { + return laplace_logdet_gradient_exact(model, params, theta, u_hat, + default_laplace_options()); +} + +template struct LaplaceResult { + double value; + std::vector grad_x; + std::vector grad_u; +}; + +template +LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + // This is currently finite-difference based through Hdot + trace(H^{-1}Hdot). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; +} + +#ifndef QUADRA_USE_ORIGINAL_HAD +//================================================== +// Optional third-order directional diagnostic. +// This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 +// using the scalar-templated model path. It is intentionally +// separate from LBFGS/Laplace so it can be enabled only when needed. +//================================================== +template +ThirdDirectionalResult +third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 { return model(x_ad3); }, xv, + dv); +} +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.before_logdet_theta_only_diagnostic.20260613_142400 b/core/laplace.hpp.before_logdet_theta_only_diagnostic.20260613_142400 new file mode 100644 index 0000000..fbab3e9 --- /dev/null +++ b/core/laplace.hpp.before_logdet_theta_only_diagnostic.20260613_142400 @@ -0,0 +1,1565 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra { + +using Eigen::MatrixXd; +using Eigen::VectorXd; + +//================================================== +// Laplace options +//================================================== +struct LaplaceOptions { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; +}; + +inline LaplaceOptions &default_laplace_options() { + static LaplaceOptions options; + return options; +} + +//============================== +// Build fixed index map +//============================== +inline std::vector build_fixed_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +//============================== +// Build random index map +//============================== +inline std::vector build_random_index(const ParameterVector ¶ms) { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; +} + +std::vector +build_u_init_from_cache(const std::vector &random_idx) { + return std::vector(random_idx.size(), 0.0); +} + +inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } +} + +template +inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) { + p[fixed_idx[k]] = x_ad[k]; + } +} + +template +inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); +} + +inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } +} + +template +inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = u_ad[k]; + } +} + +template +inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) { + for (size_t k = 0; k < random_idx.size(); ++k) { + p[random_idx[k]] = Scalar(u[k]); + } +} + +template +std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) { + if (params.params[i].is_random) { + p[i] = u[u_k++]; + } else { + p[i] = x[x_k++]; + } + } + + return p; +} + +//================================================== +// Laplace-local Hessian pattern representation +//================================================== +// Do not name this HessianPattern. autodiff.hpp may define a +// graph-level HessianPattern helper for ADGraph sparsity discovery. +// Keeping the Laplace cache as SparseHessianPattern avoids redefinition +// errors and keeps this file independent of the exact autodiff helper API. +using SparseHessianPattern = std::vector>; + +inline std::unordered_map &laplace_pattern_cache() { + static std::unordered_map cache; + return cache; +} + +//================================================== +// Discover Hessian sparsity from had::ADGraph +//================================================== +// This replaces the older dense pattern probe. It reads the sparse +// edge-pushed Hessian storage that had::PropagateAdjoint() has already +// populated inside scope.backward(nll). +// +// NOTE: this is still a numeric sparsity pattern. If a structurally +// nonzero Hessian entry evaluates to exactly zero at the discovery point, +// it can be missed. Diagonals are included by default for Newton stability. +inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } else { + for (int i = 0; i < n; ++i) { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; +} + +inline const SparseHessianPattern & +get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; +} + +inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; +} + +inline SparseHessianPattern dense_hessian_pattern(int n) { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; +} + +inline Eigen::SparseMatrix +extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; +} + +inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) { + if (D[i] <= 0.0) { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; +} + +//================================================== +// Sparse factorization helpers +// +// Adaptive jitter is only applied if the original Hessian fails +// to factorize. This avoids biasing gradients near valid optima +// while still protecting against near-singular random-effect +// Hessians during stress tests or weakly identified models. +//================================================== +inline Eigen::SparseMatrix +add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) { + if (it.row() == it.col()) { + it.valueRef() += jitter; + } + } + } + + return H_reg; +} + +inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); +} + +inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) { + if (it.row() == it.col()) { + if (it.value() <= 0.0) { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; +} +//================================================== +// Solve for random effects u* via Newton +//================================================== +template +std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector* u_init_override = nullptr) { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) { + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) { + u[i] -= step[i]; + } + } + + return u; +} + +//================================================== +// Compute sparse random-effect Hessian at current params +//================================================== +template +Eigen::SparseMatrix +compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; +} + +//================================================== +// Laplace log-determinant at supplied fixed/random state +//================================================== +template +double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); +} + +//================================================== +// trace(H^{-1} Hdot), using an existing sparse factorization +//================================================== +//================================================== +// Stochastic Hutchinson trace estimator +// +// Approximates: +// +// trace(H^{-1} Hdot) +// +// using: +// +// E[zᵀ H^{-1} Hdot z] +// +// with Rademacher (+/-1) probe vectors. +// +// This avoids catastrophic dense materialization for large +// random-effect systems. +//================================================== +template +double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) { + if (Hdot.rows() != Hdot.cols()) { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); +} + +//================================================== +// Finite-difference directional derivative of random Hessian +// Hdot = d H_u(theta)[direction] +//================================================== + +//================================================== +// Implicit sensitivity of optimized random effects +// +// u*(theta) satisfies f_u(theta, u*) = 0. +// Differentiating: +// +// H_uu du*/dtheta_i + H_u theta_i = 0 +// +// so: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// This avoids re-solving the random effects for theta +/- eps. +//================================================== +template +Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; +} + +//================================================== +// Fast implicit sensitivities for all fixed effects +// +// Reuses one H_uu factorization and computes: +// +// du*/dtheta_i = - H_uu^{-1} H_u theta_i +// +// for every fixed-effect direction. +// +// Columns of the returned matrix correspond to fixed effects. +//================================================== +template +Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) { + if (Huu_reuse != nullptr) { + solver_local.compute(*Huu_reuse); + } else { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + + return du; +} + +//================================================== +// Same as random_hessian_directional_implicit_fd(), but accepts +// a precomputed du*/dtheta_i vector. This avoids refactorizing +// H_uu inside every fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Implicit-direction finite-difference derivative of H_uu +// +// Instead of expensive profiled FD: +// +// H(theta +/- eps, u*(theta +/- eps)) +// +// this uses: +// +// u*(theta +/- eps e_i) +// ~= u*(theta) +/- eps du*/dtheta_i +// +// and computes: +// +// Hdot_i ~= [H(theta+eps e_i, u+eps du_i) +// - H(theta-eps e_i, u-eps du_i)] / (2 eps) +// +// This is still a finite-difference bridge, but it avoids nested +// random-effect Newton solves for each fixed-effect direction. +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +template +Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) { + if (theta.size() != direction.size()) { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); +} + +//================================================== +// Finite-difference Laplace logdet gradient contribution +// +// Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. +// This is intentionally written through Hdot + trace(H^{-1}Hdot) +// so exact third-order AD can replace random_hessian_directional_fd() +// later without changing this public interface. +//================================================== + +//================================================== +// Exact directional derivative of H_uu using directional edge-pushing +// +// Computes: +// +// Hdot = D H_uu(theta, u*) [theta_direction, u_direction] +// +// This is the intended replacement for: +// +// (Hplus - Hminus) / (2 eps) +// +// and avoids finite-difference Hessian rebuilds. +// +// Requires had_quadra_hdot.hpp / updated had_quadra.h support for: +// had::PropagateAdjointDirectional() +// had::GetAdjointDot(...) +//================================================== +template +Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; +} + +template +std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; +} + + +template +Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); +} + +//================================================== +// Exact Laplace log-determinant gradient contribution +// +// Computes gradient of: +// +// 0.5 * log det(H_uu(theta, u*(theta))) +// +// using: +// +// du*/dtheta_i = - H_uu^{-1} H_{u theta_i} +// +// and exact directional Hessian propagation: +// +// Hdot_i = D H_uu [e_i, du*/dtheta_i] +// +// No finite-difference Hplus/Hminus path is used in production. +// +// Note: +// The derivative propagation is exact. The trace may still be stochastic +// if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. +//================================================== +template +Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; +} + +// Backward-compatible wrapper. +// Deprecated name: the default path is exact-Hdot, not finite-difference. +template +Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double /*eps*/ = 1e-5) { + return laplace_logdet_gradient_exact(model, params, theta, u_hat, + default_laplace_options()); +} + +template struct LaplaceResult { + + // Component breakdown of the Laplace objective: + // + // value = joint_objective + 0.5 * laplace_logdet - laplace_constant + // + // These are intentionally stored for diagnostics/reporting and for + // optimizer-side bookkeeping. They do not change the objective math. + double joint_objective = 0.0; + double laplace_logdet = 0.0; + double laplace_constant = 0.0; + + double value; + std::vector grad_x; + std::vector grad_u; +}; + +template +LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + // This is currently finite-difference based through Hdot + trace(H^{-1}Hdot). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; +} + +#ifndef QUADRA_USE_ORIGINAL_HAD +//================================================== +// Optional third-order directional diagnostic. +// This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 +// using the scalar-templated model path. It is intentionally +// separate from LBFGS/Laplace so it can be enabled only when needed. +//================================================== +template +ThirdDirectionalResult +third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 { return model(x_ad3); }, xv, + dv); +} +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.broken_after_bad_cleanup.20260613_111249 b/core/laplace.hpp.broken_after_bad_cleanup.20260613_111249 new file mode 100644 index 0000000..7f536e3 --- /dev/null +++ b/core/laplace.hpp.broken_after_bad_cleanup.20260613_111249 @@ -0,0 +1,1840 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.broken_after_bad_cleanup.20260613_112529 b/core/laplace.hpp.broken_after_bad_cleanup.20260613_112529 new file mode 100644 index 0000000..7f536e3 --- /dev/null +++ b/core/laplace.hpp.broken_after_bad_cleanup.20260613_112529 @@ -0,0 +1,1840 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.pre_bad_tail_cleanup.20260613_111124.bak b/core/laplace.hpp.pre_bad_tail_cleanup.20260613_111124.bak new file mode 100644 index 0000000..b762866 --- /dev/null +++ b/core/laplace.hpp.pre_bad_tail_cleanup.20260613_111124.bak @@ -0,0 +1,1927 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + } + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); +#endif + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double + { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) + { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace.hpp.saved_before_git_restore.20260613_112952 b/core/laplace.hpp.saved_before_git_restore.20260613_112952 new file mode 100644 index 0000000..b762866 --- /dev/null +++ b/core/laplace.hpp.saved_before_git_restore.20260613_112952 @@ -0,0 +1,1927 @@ +#include "laplace/exact_gradient_workspace.hpp" +#include +#include +#ifndef QUADRA_LAPLACE_HPP +#define QUADRA_LAPLACE_HPP +#pragma once + +#include "../external/eigen/Eigen/Dense" +#include "../external/eigen/Eigen/Sparse" +#include "../external/eigen/Eigen/SparseCholesky" +#include "../model/parameter.hpp" +#include "autodiff.hpp" +#include "evaluation.hpp" +#include "laplace/laplace_evaluator_exact_gradient_integration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra +{ + + using Eigen::MatrixXd; + using Eigen::VectorXd; + + //================================================== + // Laplace options + //================================================== + struct LaplaceOptions + { + // Trace strategy for tr(H^{-1} Hdot). + // For very large random-effect systems Hutchinson avoids dense RHS solves. + bool use_hutchinson_trace = true; + int hutchinson_probes = 8; + unsigned int hutchinson_seed = 12345; + + // Adaptive diagonal jitter for sparse factorizations. + // Jitter is only applied if the unmodified Hessian fails to factorize. + double jitter_initial = 1e-12; + int jitter_max_attempts = 12; + + // Validation/debugging knobs. + // Compile-time validation is still controlled by QUADRA_VALIDATE_HDOT, + // but this flag lets the runtime call sites opt out if desired. + bool validate_hdot = true; + + // Threshold for dropping sparse Hessian entries. + // Use 0.0 for logdet paths so very small curvature is not dropped. + double hessian_drop_tol = 0.0; + }; + + inline LaplaceOptions &default_laplace_options() + { + static LaplaceOptions options; + return options; + } + + //============================== + // Build fixed index map + //============================== + inline std::vector build_fixed_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (!params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + //============================== + // Build random index map + //============================== + inline std::vector build_random_index(const ParameterVector ¶ms) + { + std::vector idx; + for (size_t i = 0; i < params.params.size(); ++i) + { + if (params.params[i].is_random) + idx.push_back(i); + } + return idx; + } + + std::vector + build_u_init_from_cache(const std::vector &random_idx) + { + return std::vector(random_idx.size(), 0.0); + } + + inline void inject_fixed_params(const Eigen::VectorXd &x, + ParameterVector ¶ms, + const std::vector &fixed_idx) + { + assert(x.size() == fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const int idx = fixed_idx[k]; + params.params[idx].value = x[k]; + } + } + + template + inline void inject_fixed_params(const std::vector &x_ad, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + p[fixed_idx[k]] = x_ad[k]; + } + } + + template + inline void inject_fixed_params(const Eigen::VectorXd &x, + std::vector &p, + const std::vector &fixed_idx) + { + for (size_t k = 0; k < fixed_idx.size(); ++k) + p[fixed_idx[k]] = Scalar(x[k]); + } + + inline void inject_random_params(const std::vector &u, + ParameterVector ¶ms, + const std::vector &random_idx) + { + assert(u.size() == random_idx.size()); + + for (size_t k = 0; k < random_idx.size(); ++k) + { + const int idx = random_idx[k]; + params.params[idx].value = u[k]; + } + } + + template + inline void inject_random_params(const std::vector &u_ad, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = u_ad[k]; + } + } + + template + inline void inject_random_params(const std::vector &u, + std::vector &p, + const std::vector &random_idx) + { + for (size_t k = 0; k < random_idx.size(); ++k) + { + p[random_idx[k]] = Scalar(u[k]); + } + } + + template + std::vector pack_params(const std::vector &u, const std::vector &x, + const ParameterVector ¶ms, + const std::vector &random_idx, + const std::vector &fixed_idx) + { + std::vector p(params.params.size()); + + int u_k = 0; + int x_k = 0; + + for (size_t i = 0; i < p.size(); i++) + { + if (params.params[i].is_random) + { + p[i] = u[u_k++]; + } + else + { + p[i] = x[x_k++]; + } + } + + return p; + } + + //================================================== + // Laplace-local Hessian pattern representation + //================================================== + // Do not name this HessianPattern. autodiff.hpp may define a + // graph-level HessianPattern helper for ADGraph sparsity discovery. + // Keeping the Laplace cache as SparseHessianPattern avoids redefinition + // errors and keeps this file independent of the exact autodiff helper API. + using SparseHessianPattern = std::vector>; + + inline std::unordered_map &laplace_pattern_cache() + { + static std::unordered_map cache; + return cache; + } + + //================================================== + // Discover Hessian sparsity from had::ADGraph + //================================================== + // This replaces the older dense pattern probe. It reads the sparse + // edge-pushed Hessian storage that had::PropagateAdjoint() has already + // populated inside scope.backward(nll). + // + // NOTE: this is still a numeric sparsity pattern. If a structurally + // nonzero Hessian entry evaluates to exactly zero at the discovery point, + // it can be missed. Diagonals are included by default for Newton stability. + inline SparseHessianPattern discover_pattern_from_graph( + const std::vector &p_full, const std::vector &random_idx, + bool symmetric = true, bool include_diagonal = true, double tol = 1e-12) + { + std::cout << "Quadra: Discovering Hessian pattern from AD graph for " + << random_idx.size() << " random variables ...\n"; + + const int n = static_cast(random_idx.size()); + SparseHessianPattern pattern; + + if (n == 0 || had::g_ADGraph == nullptr) + return pattern; + + std::unordered_map random_var_to_local; + random_var_to_local.reserve(static_cast(n)); + + for (int local = 0; local < n; ++local) + { + const int full_index = random_idx[static_cast(local)]; + random_var_to_local.emplace(p_full[full_index].varId, local); + } + + std::set> unique_pairs; + + if (include_diagonal) + { + for (int i = 0; i < n; ++i) + unique_pairs.emplace(i, i); + } + else + { + for (int i = 0; i < n; ++i) + { + const int full_index = random_idx[static_cast(i)]; + const had::VertexId vi = p_full[full_index].varId; + + if (vi < had::g_ADGraph->selfSoEdges.size() && + std::abs(had::g_ADGraph->selfSoEdges[vi]) > tol) + { + unique_pairs.emplace(i, i); + } + } + } + + // had stores an off-diagonal Hessian entry in soEdges[max_id] + // under key min_id. Walk the graph-level sparse storage and retain + // only entries where both endpoints are random-effect variables. + for (had::VertexId hi = 0; + hi < static_cast(had::g_ADGraph->soEdges.size()); ++hi) + { + auto hi_it = random_var_to_local.find(hi); + if (hi_it == random_var_to_local.end()) + continue; + + const int i = hi_it->second; + const auto &tree = had::g_ADGraph->soEdges[hi]; + + for (const auto &node : tree.nodes) + { + if (std::abs(node.val) <= tol) + continue; + + auto lo_it = random_var_to_local.find(node.key); + if (lo_it == random_var_to_local.end()) + continue; + + const int j = lo_it->second; + unique_pairs.emplace(i, j); + if (symmetric) + unique_pairs.emplace(j, i); + } + } + + pattern.reserve(unique_pairs.size()); + for (const auto &ij : unique_pairs) + pattern.emplace_back(ij.first, ij.second); + + std::cout << "Quadra: Model structure aware now => Hessian pattern has " + << pattern.size() << " entries.\n"; + return pattern; + } + + inline const SparseHessianPattern & + get_pattern(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx) + { + // See extract_sparse_hessian(): g_ADGraph may have been changed by + // nested derivative/logdet helper evaluations. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + auto &cache = laplace_pattern_cache(); + + auto it = cache.find(n); + if (it != cache.end()) + return it->second; + + auto pattern = discover_pattern_from_graph(p_full, random_idx); + auto res = cache.emplace(n, std::move(pattern)); + return res.first->second; + } + + inline SparseHessianPattern banded_hessian_pattern(int n, int bandwidth) + { + SparseHessianPattern pattern; + + for (int i = 0; i < n; ++i) + { + int j0 = std::max(0, i - bandwidth); + int j1 = std::min(n - 1, i + bandwidth); + + for (int j = j0; j <= j1; ++j) + pattern.emplace_back(i, j); + } + + return pattern; + } + + inline SparseHessianPattern dense_hessian_pattern(int n) + { + SparseHessianPattern pattern; + pattern.reserve(n * n); + + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + pattern.emplace_back(i, j); + + return pattern; + } + + inline Eigen::SparseMatrix + extract_sparse_hessian(const ADScope &scope, const std::vector &p_full, + const std::vector &random_idx, + const SparseHessianPattern &pattern, + double drop_tol = 1e-12) + { + // Important: + // had::g_ADGraph is global/thread-local. Other helper calls may build + // temporary graphs and leave this pointer changed. Always restore it + // before reading adjoints/Hessian entries from this ADScope. + had::g_ADGraph = &scope.graph; + + const int n = (int)random_idx.size(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + double hij = scope.hess(p_full[random_idx[i]], p_full[random_idx[j]]); + if (std::abs(hij) > drop_tol) + triplets.emplace_back(i, j, hij); + } + + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(triplets.begin(), triplets.end()); + return H; + } + + inline double sparse_logdet_ldlt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LDLT factorization failed"); + } + + const auto &D = solver.vectorD(); + + double logdet = 0.0; + + for (int i = 0; i < D.size(); ++i) + { + if (D[i] <= 0.0) + { + throw std::runtime_error("Sparse Hessian is not positive definite"); + } + + logdet += std::log(D[i]); + } + + return logdet; + } + + //================================================== + // Sparse factorization helpers + // + // Adaptive jitter is only applied if the original Hessian fails + // to factorize. This avoids biasing gradients near valid optima + // while still protecting against near-singular random-effect + // Hessians during stress tests or weakly identified models. + //================================================== + inline Eigen::SparseMatrix + add_diagonal_jitter(const Eigen::SparseMatrix &H, double jitter) + { + Eigen::SparseMatrix H_reg = H; + H_reg.makeCompressed(); + + for (int k = 0; k < H_reg.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(H_reg, k); it; ++it) + { + if (it.row() == it.col()) + { + it.valueRef() += jitter; + } + } + } + + return H_reg; + } + + inline Eigen::SparseMatrix factorize_with_adaptive_jitter( + const Eigen::SparseMatrix &H, + Eigen::SimplicialLDLT> &solver, + const char *context, + const LaplaceOptions &options = default_laplace_options()) + { + Eigen::SparseMatrix H_factor = H; + H_factor.makeCompressed(); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + return H_factor; + } + + double jitter = options.jitter_initial; + + for (int attempt = 0; attempt < options.jitter_max_attempts; ++attempt) + { + H_factor = add_diagonal_jitter(H, jitter); + + solver.compute(H_factor); + + if (solver.info() == Eigen::Success) + { + std::cout << "Quadra: " << context + << " succeeded with diagonal jitter = " << jitter << "\\n"; + + return H_factor; + } + + jitter *= 10.0; + } + + throw std::runtime_error(std::string(context) + + ": sparse factorization failed"); + } + + inline double sparse_logdet_llt(const Eigen::SparseMatrix &H) + { + Eigen::SimplicialLLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse LLT factorization failed"); + } + + Eigen::SparseMatrix L = solver.matrixL(); + + double logdet = 0.0; + + for (int k = 0; k < L.outerSize(); ++k) + { + for (Eigen::SparseMatrix::InnerIterator it(L, k); it; ++it) + { + if (it.row() == it.col()) + { + if (it.value() <= 0.0) + { + throw std::runtime_error("Non-positive Cholesky diagonal"); + } + + logdet += 2.0 * std::log(it.value()); + } + } + } + + return logdet; + } + //================================================== + // Solve for random effects u* via Newton + //================================================== + template + std::vector solve_random_effects_laplace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x, + const std::vector &fixed_idx, const std::vector &random_idx, + had::ADGraph &graph, + const std::vector *u_init_override = nullptr) + { + const int max_iter = 20; + const double tol = 1e-8; + + std::vector u = + (u_init_override != nullptr && u_init_override->size() == random_idx.size()) + ? *u_init_override + : std::vector(random_idx.size(), 0.0); + + for (int iter = 0; iter < max_iter; ++iter) + { + + // -------------------------------------------------- + // AD scope: binds graph, clears graph + // -------------------------------------------------- + ADScope scope(graph); + + // -------------------------------------------------- + // Build full AD parameter vector + // -------------------------------------------------- + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // -------------------------------------------------- + // Forward pass + // -------------------------------------------------- + AD nll = model(p_full); + + // -------------------------------------------------- + // Reverse pass + // -------------------------------------------------- + scope.backward(nll); + + // -------------------------------------------------- + // Gradient wrt random effects + // -------------------------------------------------- + Eigen::VectorXd g(random_idx.size()); + + for (size_t i = 0; i < random_idx.size(); ++i) + { + g[i] = scope.grad(p_full[random_idx[i]]); + } + + if (g.norm() < tol) + { + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + return u; + } + + // -------------------------------------------------- + // Sparse Hessian wrt random effects + // -------------------------------------------------- + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + // Optional diagnostics + // std::cout << "pattern nnz = " << H.nonZeros() << "\n"; + + // -------------------------------------------------- + // Sparse Newton solve: H step = g + // -------------------------------------------------- + Eigen::SimplicialLDLT> solver; + solver.compute(H); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("Sparse Hessian factorization failed in " + "solve_random_effects_laplace"); + } + + Eigen::VectorXd step = solver.solve(g); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "Sparse Hessian solve failed in solve_random_effects_laplace"); + } + if (false) + std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1 + << ", fx = " << std::setw(14) << std::fixed + << std::setprecision(6) << nll.val + << ", |grad| = " << std::setw(12) << std::fixed + << std::setprecision(6) << g.norm() << "\n"; + // -------------------------------------------------- + // Newton update + // -------------------------------------------------- + for (size_t i = 0; i < u.size(); ++i) + { + u[i] -= step[i]; + } + } + + return u; + } + + //================================================== + // Compute sparse random-effect Hessian at current params + //================================================== + template + Eigen::SparseMatrix + compute_random_hessian_sparse(Model &model, ParameterVector ¶ms) + { + had::ADGraph *previous_graph = had::g_ADGraph; + + had::ADGraph graph; + ADScope scope(graph); + + const std::vector random_idx = build_random_index(params); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(params.params[static_cast(i)].value)); + } + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + const auto actual_pattern = + discover_pattern_from_graph(p_full, random_idx); + if (actual_pattern.size() != pattern.size()) + { + std::cout << "Quadra compute_random_hessian_sparse pattern size " + << "cached=" << pattern.size() + << " actual=" << actual_pattern.size() << "\n"; + } +#endif + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + had::g_ADGraph = previous_graph; + return H; + } + + //================================================== + // Laplace log-determinant at supplied fixed/random state + //================================================== + template + double laplace_logdet(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + inject_fixed_params(theta, params, fixed_idx); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix H = compute_random_hessian_sparse(model, params); + + return sparse_logdet_ldlt(H); + } + + //================================================== + // trace(H^{-1} Hdot), using an existing sparse factorization + //================================================== + //================================================== + // Stochastic Hutchinson trace estimator + // + // Approximates: + // + // trace(H^{-1} Hdot) + // + // using: + // + // E[zᵀ H^{-1} Hdot z] + // + // with Rademacher (+/-1) probe vectors. + // + // This avoids catastrophic dense materialization for large + // random-effect systems. + //================================================== + template + double logdet_directional_derivative_from_hdot( + SolverType &solver, const Eigen::SparseMatrix &Hdot, + const LaplaceOptions &options = default_laplace_options()) + { + if (Hdot.rows() != Hdot.cols()) + { + throw std::invalid_argument( + "logdet_directional_derivative_from_hdot: Hdot not square"); + } + + const Eigen::Index n = Hdot.rows(); + + if (!options.use_hutchinson_trace) + { + // Deterministic exact trace for small/moderate systems. + // This should not be used for very large random-effect systems. + Eigen::MatrixXd rhs = Eigen::MatrixXd(Hdot); + Eigen::MatrixXd X = solver.solve(rhs); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error( + "logdet_directional_derivative_from_hdot: dense trace solve failed"); + } + + return X.diagonal().sum(); + } + + std::mt19937 rng(options.hutchinson_seed); + std::uniform_int_distribution rademacher(0, 1); + + double trace_est = 0.0; + + for (int sample = 0; sample < options.hutchinson_probes; ++sample) + { + Eigen::VectorXd z(n); + + for (Eigen::Index i = 0; i < n; ++i) + { + z[i] = (rademacher(rng) == 0) ? -1.0 : 1.0; + } + + Eigen::VectorXd y = Hdot * z; + Eigen::VectorXd x = solver.solve(y); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("logdet_directional_derivative_from_hdot: " + "Hutchinson sparse solve failed"); + } + + trace_est += z.dot(x); + } + + return trace_est / static_cast(options.hutchinson_probes); + } + + //================================================== + // Finite-difference directional derivative of random Hessian + // Hdot = d H_u(theta)[direction] + //================================================== + + //================================================== + // Implicit sensitivity of optimized random effects + // + // u*(theta) satisfies f_u(theta, u*) = 0. + // Differentiating: + // + // H_uu du*/dtheta_i + H_u theta_i = 0 + // + // so: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // This avoids re-solving the random effects for theta +/- eps. + //================================================== + template + Eigen::VectorXd implicit_du_dtheta_i(Model &model, ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + Eigen::Index theta_i) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range("implicit_du_dtheta_i: theta_i out of range"); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix Huu = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::VectorXd Hu_theta(static_cast(random_idx.size())); + + const int fixed_full_index = fixed_idx[static_cast(theta_i)]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta[static_cast(r)] = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + + Eigen::SimplicialLDLT> solver; + solver.compute(Huu); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: H_uu factorization failed"); + } + + Eigen::VectorXd du = -solver.solve(Hu_theta); + + if (solver.info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_i: solve failed"); + } + + return du; + } + + //================================================== + // Fast implicit sensitivities for all fixed effects + // + // Reuses one H_uu factorization and computes: + // + // du*/dtheta_i = - H_uu^{-1} H_u theta_i + // + // for every fixed-effect direction. + // + // Columns of the returned matrix correspond to fixed effects. + //================================================== + template + Eigen::MatrixXd implicit_du_dtheta_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const Eigen::SparseMatrix *Huu_reuse = nullptr, + Eigen::SimplicialLDLT> *solver_reuse = + nullptr) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + scope.backward(nll); + + Eigen::SparseMatrix Huu_local; + Eigen::SimplicialLDLT> solver_local; + + Eigen::SimplicialLDLT> *solver_ptr = solver_reuse; + + if (solver_ptr == nullptr) + { + if (Huu_reuse != nullptr) + { + solver_local.compute(*Huu_reuse); + } + else + { + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Huu_local = extract_sparse_hessian(scope, p_full, random_idx, pattern); + + solver_local.compute(Huu_local); + } + + if (solver_local.info() != Eigen::Success) + { + throw std::runtime_error( + "implicit_du_dtheta_all: H_uu factorization failed"); + } + + solver_ptr = &solver_local; + } + + Eigen::MatrixXd Hu_theta(static_cast(random_idx.size()), + static_cast(fixed_idx.size())); + + for (size_t j = 0; j < fixed_idx.size(); ++j) + { + const int fixed_full_index = fixed_idx[j]; + + for (size_t r = 0; r < random_idx.size(); ++r) + { + Hu_theta(static_cast(r), static_cast(j)) = + scope.hess(p_full[random_idx[r]], p_full[fixed_full_index]); + } + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all: Hu_theta block\n" + << " Hu_theta(0, 0)=" << Hu_theta(0, 0) << "\n" + << " Hu_theta(1, 0)=" << Hu_theta(1, 0) << "\n" + << " Hu_theta norm=" << Hu_theta.norm() << "\n"; +#endif + + Eigen::MatrixXd du = -solver_ptr->solve(Hu_theta); + + if (solver_ptr->info() != Eigen::Success) + { + throw std::runtime_error("implicit_du_dtheta_all: solve failed"); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << " implicit_du_dtheta_all result (du/dtheta):\n" + << " du(0, 0)=" << du(0, 0) << "\n" + << " du(1, 0)=" << du(1, 0) << "\n" + << " du norm=" << du.norm() << "\n"; +#endif + + return du; + } + + //================================================== + // Same as random_hessian_directional_implicit_fd(), but accepts + // a precomputed du*/dtheta_i vector. This avoids refactorizing + // H_uu inside every fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd_with_du( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd_with_du: theta_i out of range"); + } + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Implicit-direction finite-difference derivative of H_uu + // + // Instead of expensive profiled FD: + // + // H(theta +/- eps, u*(theta +/- eps)) + // + // this uses: + // + // u*(theta +/- eps e_i) + // ~= u*(theta) +/- eps du*/dtheta_i + // + // and computes: + // + // Hdot_i ~= [H(theta+eps e_i, u+eps du_i) + // - H(theta-eps e_i, u-eps du_i)] / (2 eps) + // + // This is still a finite-difference bridge, but it avoids nested + // random-effect Newton solves for each fixed-effect direction. + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_implicit_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_implicit_fd: theta_i out of range"); + } + + Eigen::VectorXd du = + implicit_du_dtheta_i(model, params, theta, u_hat, theta_i); + + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + + theta_plus[theta_i] += eps; + theta_minus[theta_i] -= eps; + + Eigen::VectorXd u_plus = u_hat + eps * du; + + Eigen::VectorXd u_minus = u_hat - eps * du; + + std::vector u_plus_std(u_plus.data(), u_plus.data() + u_plus.size()); + + std::vector u_minus_std(u_minus.data(), + u_minus.data() + u_minus.size()); + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u_plus_std, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u_minus_std, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + template + Eigen::SparseMatrix random_hessian_directional_fd( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::VectorXd &direction, + double eps = 1e-5) + { + if (theta.size() != direction.size()) + { + throw std::invalid_argument( + "random_hessian_directional_fd: theta and direction sizes differ"); + } + + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + Eigen::VectorXd theta_plus = theta + eps * direction; + + Eigen::VectorXd theta_minus = theta - eps * direction; + + inject_fixed_params(theta_plus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hplus = + compute_random_hessian_sparse(model, params); + + inject_fixed_params(theta_minus, params, fixed_idx); + inject_random_params(u, params, random_idx); + + Eigen::SparseMatrix Hminus = + compute_random_hessian_sparse(model, params); + + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + return (Hplus - Hminus) * (0.5 / eps); + } + + //================================================== + // Finite-difference Laplace logdet gradient contribution + // + // Returns the gradient of 0.5 * log det(H_u) wrt fixed effects. + // This is intentionally written through Hdot + trace(H^{-1}Hdot) + // so exact third-order AD can replace random_hessian_directional_fd() + // later without changing this public interface. + //================================================== + + //================================================== + // Exact directional derivative of H_uu using directional edge-pushing + // + // Computes: + // + // Hdot = D H_uu(theta, u*) [theta_direction, u_direction] + // + // This is the intended replacement for: + // + // (Hplus - Hminus) / (2 eps) + // + // and avoids finite-difference Hessian rebuilds. + // + // Requires had_quadra_hdot.hpp / updated had_quadra.h support for: + // had::PropagateAdjointDirectional() + // had::GetAdjointDot(...) + //================================================== + template + Eigen::SparseMatrix random_hessian_directional_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, Eigen::Index theta_i, + const Eigen::VectorXd &du, const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (theta_i < 0 || theta_i >= theta.size()) + { + throw std::out_of_range( + "random_hessian_directional_exact: theta_i out of range"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + // Seed full primal tangent: + // theta direction is e_i, random direction is du*/dtheta_i. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + + p_full[fixed_idx[k]].dot = d; + graph.vertices[p_full[fixed_idx[k]].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = du[static_cast(r)]; + + p_full[random_idx[r]].dot = d; + graph.vertices[p_full[random_idx[r]].varId].dot = d; + } + + AD nll = model(p_full); + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + // Ensure scope/had reads this graph. + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot(p_full[random_idx[static_cast(i)]], + p_full[random_idx[static_cast(j)]]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + } + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + + return Hdot; + } + + template + std::vector> random_hessian_directional_exact_all( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_directional_exact_all: du_dtheta has wrong shape"); + } + + had::ADGraph graph; + ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + inject_fixed_params(theta, p_full, fixed_idx); + inject_random_params(u, p_full, random_idx); + + AD nll = model(p_full); + + had::g_ADGraph = &scope.graph; + + const int n = static_cast(random_idx.size()); + std::vector> out( + static_cast(theta.size())); + + for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) + { + // Reset primal tangents. + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0; + p_full[static_cast(fixed_idx[k])].dot = d; + graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d; + } + + for (size_t r = 0; r < random_idx.size(); ++r) + { + const double d = + du_dtheta(static_cast(r), theta_i); + p_full[static_cast(random_idx[r])].dot = d; + graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d; + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << "Quadra random_hessian_directional_exact_all direction 0\n" + << " du_dtheta col 0 norm = " + << du_dtheta.col(0).norm() + << "\n"; + } +#endif + + laplace::reset_had_quadra_directional_reverse_state(graph); + laplace::retangent_had_quadra_graph(graph); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && n >= 2) + { + std::cout << " after retangle: u[0].dot=" + << p_full[static_cast(random_idx[0])].dot + << " u[1].dot=" + << p_full[static_cast(random_idx[1])].dot << "\n"; + } +#endif + + had::SetAdjoint(nll, 1.0); + had::PropagateAdjointDirectional(); + + std::vector> triplets; + triplets.reserve(pattern.size()); + + int sample_count = 0; +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + double sample_hdot_0_0 = 0.0; + double sample_hdot_0_1 = 0.0; +#endif + + for (const auto &[i, j] : pattern) + { + const double hij_dot = + had::GetAdjointDot( + p_full[static_cast(random_idx[static_cast(i)])], + p_full[static_cast(random_idx[static_cast(j)])]); + + if (std::abs(hij_dot) > 1e-12) + { + triplets.emplace_back(i, j, hij_dot); + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0 && sample_count < 2) + { + if (i == 0 && j == 0) + sample_hdot_0_0 = hij_dot; + if (i == 0 && j == 1) + sample_hdot_0_1 = hij_dot; + sample_count++; + } +#endif + } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta_i == 0) + { + std::cout << " Hdot(0,0)=" << sample_hdot_0_0 + << " Hdot(0,1)=" << sample_hdot_0_1 << "\n"; + } +#endif + + Eigen::SparseMatrix Hdot(n, n); + Hdot.setFromTriplets(triplets.begin(), triplets.end()); + Hdot.makeCompressed(); + + out[static_cast(theta_i)] = std::move(Hdot); + } + + return out; + } + + template + Eigen::VectorXd random_hessian_trace_terms_exact_workspace( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta, + const SparseHessianPattern &pattern, + SelectedInverseAccessor &&selected_inverse) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) + { + throw std::invalid_argument( + "random_hessian_trace_terms_exact_workspace: du_dtheta has wrong shape"); + } + + std::vector workspace_pattern; + workspace_pattern.reserve(pattern.size()); + for (const auto &[i, j] : pattern) + { + workspace_pattern.emplace_back(i, j); + } + + laplace::ExactGradientWorkspace workspace; + std::vector fixed_effects; + std::vector random_effects; + + auto builder = [&]() + { + fixed_effects.clear(); + random_effects.clear(); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + fixed_effects.emplace_back(theta[i]); + } + for (Eigen::Index i = 0; i < u_hat.size(); ++i) + { + random_effects.emplace_back(u_hat[i]); + } + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int ip = 0; ip < params.size(); ++ip) + { + p_full.emplace_back(AD(0.0)); + } + + for (std::size_t k = 0; k < fixed_idx.size(); ++k) + { + p_full[static_cast(fixed_idx[k])] = fixed_effects[k]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) + { + p_full[static_cast(random_idx[k])] = random_effects[k]; + } + + return model(p_full); + }; + + workspace.Build(builder, &fixed_effects, &random_effects); + + workspace.PropagateBaseAdjoint(); + + workspace.SeedTotalDirections( + static_cast(theta.size()), + [&](std::size_t k, Eigen::VectorXd &theta_direction, + Eigen::VectorXd &random_direction) + { + theta_direction = Eigen::VectorXd::Zero(theta.size()); + random_direction = du_dtheta.col(static_cast(k)); + theta_direction[static_cast(k)] = 1.0; + }); + + workspace.PropagateDirectionalBatch(); + + return workspace.TraceTermsSelectedInverse( + std::forward(selected_inverse), + workspace_pattern); + } + + //================================================== + // Exact Laplace log-determinant gradient contribution + // + // Computes gradient of: + // + // 0.5 * log det(H_uu(theta, u*(theta))) + // + // using: + // + // du*/dtheta_i = - H_uu^{-1} H_{u theta_i} + // + // and exact directional Hessian propagation: + // + // Hdot_i = D H_uu [e_i, du*/dtheta_i] + // + // No finite-difference Hplus/Hminus path is used in production. + // + // Note: + // The derivative propagation is exact. The trace may still be stochastic + // if logdet_directional_derivative_from_hdot(...) uses Hutchinson probes. + //================================================== + template + Eigen::VectorXd laplace_logdet_gradient_exact( + Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + const LaplaceOptions &options = default_laplace_options()) + { + const auto timing_logdet_exact_start = std::chrono::steady_clock::now(); + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u(u_hat.data(), u_hat.data() + u_hat.size()); + + // Keep ParameterVector state synchronized with the evaluation point. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + // -------------------------------------------------- + // Build baseline graph once. + // This gives us: + // 1. the graph-discovered H_uu sparsity pattern, + // 2. the baseline H_uu numeric values, + // 3. the matrix used for log-det trace solves. + // -------------------------------------------------- + const auto timing_baseline_start = std::chrono::steady_clock::now(); + + had::ADGraph pattern_graph; + ADScope pattern_scope(pattern_graph); + + std::vector p_pattern; + p_pattern.reserve(static_cast(params.size())); + + for (int ip = 0; ip < params.size(); ++ip) + { + p_pattern.emplace_back(AD(0.0)); + } + + inject_fixed_params(theta, p_pattern, fixed_idx); + inject_random_params(u, p_pattern, random_idx); + + AD nll_pattern = model(p_pattern); + + pattern_scope.backward(nll_pattern); + + const auto &get_pattern_for_logdet = + get_pattern(pattern_scope, p_pattern, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(pattern_scope, p_pattern, random_idx, + get_pattern_for_logdet, options.hessian_drop_tol); + + const auto timing_baseline_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Factorize H_uu. + // + // Adaptive jitter is applied only if the unmodified H fails. + // -------------------------------------------------- + const auto timing_factor_start = std::chrono::steady_clock::now(); + + Eigen::SimplicialLDLT> solver; + + Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter( + H, solver, "laplace_logdet_gradient_exact", options); + + const auto timing_factor_end = std::chrono::steady_clock::now(); + + // -------------------------------------------------- + // Compute all implicit random-effect sensitivities: + // + // dU.col(i) = du*/dtheta_i + // + // Reuse the same H_uu factorization used for trace solves. + // -------------------------------------------------- + const auto timing_du_start = std::chrono::steady_clock::now(); + + Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (theta.size() > 0) + { + const auto dense_pattern = dense_hessian_pattern(H.rows()); + const auto Hdots_dense = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, dense_pattern); + const Eigen::SparseMatrix &Hdot0_dense = Hdots_dense[0]; + const Eigen::SparseMatrix Hdot0_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, 0, dU.col(0)); + const double exact_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_dense, + options); + const double fd_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_fd, + options); + const auto Hdots_sparse = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + const Eigen::SparseMatrix &Hdot0_sparse = Hdots_sparse[0]; + const double sparse_trace0 = + logdet_directional_derivative_from_hdot(solver, Hdot0_sparse, + options); + std::cout << "Quadra Hdot direction 0 exact norm=" + << Hdot0_dense.norm() + << " sparse norm=" << Hdot0_sparse.norm() + << " fd norm=" << Hdot0_fd.norm() + << " sparse trace=" << sparse_trace0 + << " dense trace=" << exact_trace0 + << " trace diff=" << (sparse_trace0 - exact_trace0) + << " pattern size=" << get_pattern_for_logdet.size() + << "\n"; + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); + } + const auto timing_hdot_start = std::chrono::steady_clock::now(); + + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + + const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + + const auto timing_hdot_end = std::chrono::steady_clock::now(); +#endif + +#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE + auto selected_inverse = [&](int row, int col) -> double + { + Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows()); + e[col] = 1.0; + Eigen::VectorXd x = solver.solve(e); + return x[row]; + }; + + const Eigen::VectorXd workspace_trace = + random_hessian_trace_terms_exact_workspace( + model, params, theta, u_hat, dU, get_pattern_for_logdet, + selected_inverse); + + Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index ii = 0; ii < theta.size(); ++ii) + { + trusted_trace[ii] = 2.0 * grad[ii]; + } + + const double workspace_rel_err = + (workspace_trace - trusted_trace).norm() / + std::max(1.0e-12, trusted_trace.norm()); + + std::cout << "ExactGradientWorkspace trace rel_err=" + << workspace_rel_err + << " workspace_norm=" << workspace_trace.norm() + << " trusted_norm=" << trusted_trace.norm() + << "\n"; +#endif + + // Restore baseline state for caller hygiene. + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u, params, random_idx); + + const auto timing_logdet_exact_end = std::chrono::steady_clock::now(); + const double total_ms = + std::chrono::duration( + timing_logdet_exact_end - timing_logdet_exact_start) + .count(); + const double baseline_ms = + std::chrono::duration( + timing_baseline_end - timing_baseline_start) + .count(); + const double factor_ms = + std::chrono::duration( + timing_factor_end - timing_factor_start) + .count(); + const double du_ms = + std::chrono::duration( + timing_du_end - timing_du_start) + .count(); + const double hdot_ms = + std::chrono::duration( + timing_hdot_end - timing_hdot_start) + .count(); + +#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT + std::cout << "laplace_logdet_gradient_exact ms = " << total_ms + << " baseline=" << baseline_ms + << " factor=" << factor_ms + << " du=" << du_ms + << " hdot_trace=" << hdot_ms + << "\n"; +#endif + return grad; + } + + // Backward-compatible wrapper. + // Deprecated name: the default path is exact-Hdot, not finite-difference. + template + Eigen::VectorXd laplace_logdet_gradient_fd(Model &model, + ParameterVector ¶ms, + const Eigen::VectorXd &theta, + const Eigen::VectorXd &u_hat, + double eps = 1e-5) + { + const auto fixed_idx = build_fixed_index(params); + const auto random_idx = build_random_index(params); + + std::vector u_base(u_hat.data(), u_hat.data() + u_hat.size()); + Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size()); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::MatrixXd dU = implicit_du_dtheta_all(model, params, theta, u_hat); +#endif + + for (Eigen::Index i = 0; i < theta.size(); ++i) + { + Eigen::VectorXd theta_plus = theta; + Eigen::VectorXd theta_minus = theta; + theta_plus[i] += eps; + theta_minus[i] -= eps; + + had::ADGraph graph_plus; + std::vector u_plus = solve_random_effects_laplace( + model, params, theta_plus, fixed_idx, random_idx, graph_plus, + &u_base); + + had::ADGraph graph_minus; + std::vector u_minus = solve_random_effects_laplace( + model, params, theta_minus, fixed_idx, random_idx, graph_minus, + &u_base); + + Eigen::VectorXd u_plus_e = Eigen::Map( + u_plus.data(), static_cast(u_plus.size())); + Eigen::VectorXd u_minus_e = Eigen::Map( + u_minus.data(), static_cast(u_minus.size())); + + const double logdet_plus = laplace_logdet(model, params, theta_plus, + u_plus_e); + const double logdet_minus = laplace_logdet(model, params, theta_minus, + u_minus_e); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + if (i == 0) + { + Eigen::VectorXd u_plus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) + + eps * dU.col(0); + Eigen::VectorXd u_minus_approx = + Eigen::Map(u_base.data(), + static_cast(u_base.size())) - + eps * dU.col(0); + + std::cout << "Quadra logdet_fd direction 0 details\n" + << " logdet_plus=" << logdet_plus + << " logdet_minus=" << logdet_minus + << " dlogdet_fd=" << (logdet_plus - logdet_minus) / (2.0 * eps) + << " u_plus_diff=" << (u_plus_e - u_plus_approx).norm() + << " u_minus_diff=" << (u_minus_e - u_minus_approx).norm(); + + { + const double eps_small = 1e-6; + Eigen::VectorXd theta_plus_small = theta; + Eigen::VectorXd theta_minus_small = theta; + theta_plus_small[i] += eps_small; + theta_minus_small[i] -= eps_small; + + had::ADGraph graph_plus_small; + std::vector u_plus_small = solve_random_effects_laplace( + model, params, theta_plus_small, fixed_idx, random_idx, + graph_plus_small, &u_base); + + had::ADGraph graph_minus_small; + std::vector u_minus_small = solve_random_effects_laplace( + model, params, theta_minus_small, fixed_idx, random_idx, + graph_minus_small, &u_base); + + Eigen::VectorXd u_plus_small_e = Eigen::Map( + u_plus_small.data(), + static_cast(u_plus_small.size())); + Eigen::VectorXd u_minus_small_e = Eigen::Map( + u_minus_small.data(), + static_cast(u_minus_small.size())); + + const double logdet_plus_small = laplace_logdet( + model, params, theta_plus_small, u_plus_small_e); + const double logdet_minus_small = laplace_logdet( + model, params, theta_minus_small, u_minus_small_e); + + std::cout << " dlogdet_fd_small=" + << (logdet_plus_small - logdet_minus_small) / + (2.0 * eps_small) + << " u_plus_small_diff=" + << (u_plus_small_e - u_plus_approx).norm() + << " u_minus_small_diff=" + << (u_minus_small_e - u_minus_approx).norm(); + } + + std::cout << "\n"; + } +#endif + + grad[i] = 0.5 * (logdet_plus - logdet_minus) / (2.0 * eps); + } + + inject_fixed_params(theta, params, fixed_idx); + inject_random_params(u_base, params, random_idx); + + return grad; + } + + template + struct LaplaceResult + { + double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); + std::vector grad_x; + std::vector grad_u; + }; + + template + LaplaceResult laplace_eval_at_u_star( + Model &model, ParameterVector ¶ms, const std::vector &fixed_idx, + const std::vector &random_idx, const Eigen::VectorXd &x, + const std::vector &u_star, had::ADGraph &graph, + const LaplaceOptions &options = default_laplace_options()) + { + ADScope scope(graph); + + using Result = LaplaceResult; + Result res; + + std::vector p_full; + p_full.reserve(params.size()); + + for (int i = 0; i < params.size(); ++i) + { + p_full.emplace_back(AD(0.0)); + } + + inject_fixed_params(x, p_full, fixed_idx); + inject_random_params(u_star, p_full, random_idx); + + AD nll = model(p_full); + + scope.backward(nll); + + res.grad_x.resize(fixed_idx.size()); + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); + } + + // Add fixed-effect contribution from 0.5 * log det(H_u). + { + Eigen::Map u_star_eigen( + u_star.data(), static_cast(u_star.size())); + + Eigen::VectorXd g_logdet = + laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen, + options.hessian_drop_tol > 0 ? 1e-5 : 1e-5); + std::cout << " logdet_fd_grad= " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad_diff= " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + + // laplace_logdet_gradient_exact builds temporary AD graphs. + // Restore the graph for this outer evaluation before any + // further grad/hess access through scope. + had::g_ADGraph = &scope.graph; + + for (size_t k = 0; k < fixed_idx.size(); ++k) + { + res.grad_x[k] += g_logdet[static_cast(k)]; + } + } + + res.grad_u.resize(random_idx.size()); + for (size_t k = 0; k < random_idx.size(); ++k) + { + res.grad_u[k] = scope.grad(p_full[random_idx[k]]); + } + + const auto &pattern = get_pattern(scope, p_full, random_idx); + + Eigen::SparseMatrix H = + extract_sparse_hessian(scope, p_full, random_idx, pattern); + + double logdet = sparse_logdet_ldlt(H); + // Or, if vectorD() is unavailable: + // double logdet = sparse_logdet_llt(H); + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.value = value_of(nll) + 0.5 * logdet - laplace_constant; + + return res; + } + +#ifndef QUADRA_USE_ORIGINAL_HAD + //================================================== + // Optional third-order directional diagnostic. + // This evaluates D^k f(x)[direction,...] for k = 0,1,2,3 + // using the scalar-templated model path. It is intentionally + // separate from LBFGS/Laplace so it can be enabled only when needed. + //================================================== + template + ThirdDirectionalResult + third_directional_fixed_effects(Model &model, const Eigen::VectorXd &x, + const Eigen::VectorXd &direction) + { + if (x.size() != direction.size()) + throw std::invalid_argument( + "third_directional_fixed_effects: x and direction must have same size"); + + std::vector xv(static_cast(x.size())); + std::vector dv(static_cast(direction.size())); + for (int i = 0; i < x.size(); ++i) + { + xv[static_cast(i)] = x[i]; + dv[static_cast(i)] = direction[i]; + } + + return evaluate_third_directional( + [&](const std::vector &x_ad3) -> AD3 + { return model(x_ad3); }, xv, + dv); + } +#endif + +} // namespace quadra + +#endif // QUADRA_LAPLACE_HPP diff --git a/core/laplace/exact_gradient_workspace.hpp b/core/laplace/exact_gradient_workspace.hpp index 463a960..4e34d27 100644 --- a/core/laplace/exact_gradient_workspace.hpp +++ b/core/laplace/exact_gradient_workspace.hpp @@ -217,11 +217,7 @@ class ExactGradientWorkspace { (*random_effects_)[static_cast(entry.col)], static_cast(k)); - if (entry.row == entry.col) { - trace += Hinv(entry.row, entry.col) * hdot; - } else { - trace += 2.0 * Hinv(entry.row, entry.col) * hdot; - } + trace += Hinv(entry.row, entry.col) * hdot; } traces[static_cast(k)] = trace; @@ -254,12 +250,7 @@ class ExactGradientWorkspace { static_cast(k)); const double hinv = selected_inverse(entry.row, entry.col); - - if (entry.row == entry.col) { - trace += hinv * hdot; - } else { - trace += 2.0 * hinv * hdot; - } + trace += hinv * hdot; } traces[static_cast(k)] = trace; diff --git a/core/laplace/functional_analysis_report.hpp b/core/laplace/functional_analysis_report.hpp new file mode 100644 index 0000000..dfbb054 --- /dev/null +++ b/core/laplace/functional_analysis_report.hpp @@ -0,0 +1,1183 @@ +#pragma once + +#include "laplace_structure_report.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra { + +struct FunctionalOptimizationSummary { + double objective_value = std::numeric_limits::quiet_NaN(); + double gradient_norm = std::numeric_limits::quiet_NaN(); + std::string max_gradient_parameter; + double max_gradient_value = std::numeric_limits::quiet_NaN(); + double max_abs_gradient = std::numeric_limits::quiet_NaN(); + int iterations = 0; + bool converged = false; + std::string message; +}; + +struct FunctionalUncertaintySummary { + std::size_t covariance_rows = 0; + std::size_t covariance_cols = 0; + bool covariance_available = false; + bool correlation_available = false; + + double min_variance = std::numeric_limits::quiet_NaN(); + double max_variance = std::numeric_limits::quiet_NaN(); + std::size_t min_variance_index = 0; + std::size_t max_variance_index = 0; + + double max_abs_correlation = std::numeric_limits::quiet_NaN(); + std::size_t max_abs_correlation_i = 0; + std::size_t max_abs_correlation_j = 0; + + std::size_t corr_abs_gt_0_5 = 0; + std::size_t corr_abs_gt_0_8 = 0; + std::size_t corr_abs_gt_0_9 = 0; +}; + +struct FunctionalLatentStateSummary { + std::size_t count = 0; + double mean = std::numeric_limits::quiet_NaN(); + double sd = std::numeric_limits::quiet_NaN(); + double min_value = std::numeric_limits::quiet_NaN(); + double max_value = std::numeric_limits::quiet_NaN(); + std::size_t min_index = 0; + std::size_t max_index = 0; + double l2_norm = std::numeric_limits::quiet_NaN(); +}; + +struct FunctionalParameterInfluenceRow { + std::size_t index = 0; + std::string name; + double variance = std::numeric_limits::quiet_NaN(); + double sd = std::numeric_limits::quiet_NaN(); + double variance_share = std::numeric_limits::quiet_NaN(); + + // Sum of absolute correlations to all other parameters. This is a simple + // uncertainty-network centrality score. + double correlation_centrality = std::numeric_limits::quiet_NaN(); + double correlation_centrality_share = + std::numeric_limits::quiet_NaN(); + + // Precision-side local curvature from the supplied Hessian, if available. + double curvature_diagonal = std::numeric_limits::quiet_NaN(); + double curvature_column_norm = std::numeric_limits::quiet_NaN(); + + // Composite scale-sensitive importance score: + // + // sd * curvature_column_norm * (1 + correlation_centrality) + // + // If curvature is unavailable, falls back to: + // + // sd * (1 + correlation_centrality) + // + // This is intended for ranking, not as a formal statistical estimator. + double importance_score = std::numeric_limits::quiet_NaN(); + double importance_share = std::numeric_limits::quiet_NaN(); +}; + +struct FunctionalCorrelationInfluenceRow { + std::size_t i = 0; + std::size_t j = 0; + std::string name_i; + std::string name_j; + double correlation = std::numeric_limits::quiet_NaN(); + double abs_correlation = std::numeric_limits::quiet_NaN(); +}; + +struct FunctionalParameterInfluenceSummary { + bool available = false; + std::vector variance_rows; + std::vector top_correlation_rows; +}; + +struct FunctionalCorrelationGraphSummary { + bool available = false; + double abs_correlation_threshold = 0.5; + std::size_t node_count = 0; + std::size_t edge_count = 0; + double average_degree = std::numeric_limits::quiet_NaN(); + std::size_t maximum_degree = 0; + std::size_t maximum_degree_index = 0; + std::string maximum_degree_name; + std::size_t connected_components = 0; + std::size_t largest_component_size = 0; + int graph_diameter = -1; +}; + +struct FunctionalGradientVolatilitySummary { + bool available = false; + double perturbation_scale = 0.0; + std::size_t samples = 0; + double baseline_gradient_norm = std::numeric_limits::quiet_NaN(); + double mean_gradient_norm = std::numeric_limits::quiet_NaN(); + double sd_gradient_norm = std::numeric_limits::quiet_NaN(); + double max_gradient_norm = std::numeric_limits::quiet_NaN(); + double gradient_norm_cv = std::numeric_limits::quiet_NaN(); + std::string most_volatile_parameter; + std::size_t most_volatile_parameter_index = 0; + double most_volatile_parameter_sd = std::numeric_limits::quiet_NaN(); + std::string most_sign_flips_parameter; + std::size_t most_sign_flips_parameter_index = 0; + std::size_t most_sign_flips = 0; +}; + +struct FunctionalParameterGeometryRow { + std::size_t index = 0; + std::string name; + double gradient = std::numeric_limits::quiet_NaN(); + double abs_gradient = std::numeric_limits::quiet_NaN(); + double curvature_column_norm = std::numeric_limits::quiet_NaN(); + double curvature_diagonal = std::numeric_limits::quiet_NaN(); + double curvature_share = std::numeric_limits::quiet_NaN(); +}; + +struct FunctionalParameterGeometrySummary { + bool available = false; + std::vector rows; + std::string dominant_parameter; + std::size_t dominant_parameter_index = 0; + double dominant_curvature_column_norm = + std::numeric_limits::quiet_NaN(); +}; + +struct FunctionalSpectralStructureSummary { + bool available = false; + std::size_t eigen_count = 0; + double eigen_sum = std::numeric_limits::quiet_NaN(); + double largest_eigen_share = std::numeric_limits::quiet_NaN(); + double effective_rank_entropy = std::numeric_limits::quiet_NaN(); + + std::size_t eigen_count_for_50 = 0; + std::size_t eigen_count_for_90 = 0; + std::size_t eigen_count_for_95 = 0; + std::size_t eigen_count_for_99 = 0; + + std::vector eigenvalues_desc; + std::vector cumulative_share; +}; + +struct FunctionalAnalysisReport { + FunctionalOptimizationSummary optimization; + LaplaceStructureReport laplace_structure; + FunctionalUncertaintySummary uncertainty; + FunctionalLatentStateSummary latent_states; + FunctionalParameterInfluenceSummary parameter_influence; + FunctionalCorrelationGraphSummary correlation_graph; + FunctionalGradientVolatilitySummary gradient_volatility; + FunctionalParameterGeometrySummary parameter_geometry; + FunctionalSpectralStructureSummary spectral_structure; +}; + +inline FunctionalLatentStateSummary +summarize_latent_states(const std::vector &u) { + FunctionalLatentStateSummary out; + out.count = u.size(); + + if (u.empty()) { + return out; + } + + double sum = 0.0; + double sumsq = 0.0; + out.min_value = u[0]; + out.max_value = u[0]; + out.min_index = 0; + out.max_index = 0; + + for (std::size_t i = 0; i < u.size(); ++i) { + const double x = u[i]; + sum += x; + sumsq += x * x; + + if (x < out.min_value) { + out.min_value = x; + out.min_index = i; + } + if (x > out.max_value) { + out.max_value = x; + out.max_index = i; + } + } + + out.mean = sum / static_cast(u.size()); + const double var = + sumsq / static_cast(u.size()) - out.mean * out.mean; + out.sd = std::sqrt(std::max(0.0, var)); + out.l2_norm = std::sqrt(sumsq); + return out; +} + +inline FunctionalUncertaintySummary +summarize_covariance_correlation(const Eigen::MatrixXd &cov) { + FunctionalUncertaintySummary out; + out.covariance_rows = static_cast(cov.rows()); + out.covariance_cols = static_cast(cov.cols()); + + if (cov.rows() == 0 || cov.cols() == 0 || cov.rows() != cov.cols()) { + return out; + } + + out.covariance_available = true; + + out.min_variance = cov(0, 0); + out.max_variance = cov(0, 0); + out.min_variance_index = 0; + out.max_variance_index = 0; + + for (Eigen::Index i = 0; i < cov.rows(); ++i) { + const double v = cov(i, i); + if (v < out.min_variance) { + out.min_variance = v; + out.min_variance_index = static_cast(i); + } + if (v > out.max_variance) { + out.max_variance = v; + out.max_variance_index = static_cast(i); + } + } + + out.correlation_available = true; + out.max_abs_correlation = 0.0; + + for (Eigen::Index i = 0; i < cov.rows(); ++i) { + for (Eigen::Index j = i + 1; j < cov.cols(); ++j) { + const double denom = std::sqrt(std::abs(cov(i, i) * cov(j, j))); + if (denom <= 0.0 || !std::isfinite(denom)) { + out.correlation_available = false; + continue; + } + + const double corr = cov(i, j) / denom; + const double ac = std::abs(corr); + + if (ac > out.max_abs_correlation) { + out.max_abs_correlation = ac; + out.max_abs_correlation_i = static_cast(i); + out.max_abs_correlation_j = static_cast(j); + } + + if (ac > 0.5) + ++out.corr_abs_gt_0_5; + if (ac > 0.8) + ++out.corr_abs_gt_0_8; + if (ac > 0.9) + ++out.corr_abs_gt_0_9; + } + } + + return out; +} + +inline FunctionalParameterInfluenceSummary summarize_parameter_influence( + const Eigen::MatrixXd &cov, const std::vector &names = {}, + std::size_t top_correlation_count = 10, + const Eigen::MatrixXd *precision_hessian = nullptr) { + FunctionalParameterInfluenceSummary out; + + if (cov.rows() == 0 || cov.cols() == 0 || cov.rows() != cov.cols()) { + return out; + } + + out.available = true; + + const std::size_t n = static_cast(cov.rows()); + + double variance_sum = 0.0; + for (Eigen::Index i = 0; i < cov.rows(); ++i) { + variance_sum += std::max(0.0, cov(i, i)); + } + + std::vector centrality(n, 0.0); + std::vector corr_rows; + + for (Eigen::Index i = 0; i < cov.rows(); ++i) { + for (Eigen::Index j = i + 1; j < cov.cols(); ++j) { + const double denom = std::sqrt(std::abs(cov(i, i) * cov(j, j))); + if (denom <= 0.0 || !std::isfinite(denom)) { + continue; + } + + FunctionalCorrelationInfluenceRow row; + row.i = static_cast(i); + row.j = static_cast(j); + row.name_i = row.i < names.size() ? names[row.i] + : ("param_" + std::to_string(row.i)); + row.name_j = row.j < names.size() ? names[row.j] + : ("param_" + std::to_string(row.j)); + row.correlation = cov(i, j) / denom; + row.abs_correlation = std::abs(row.correlation); + corr_rows.push_back(row); + + centrality[row.i] += row.abs_correlation; + centrality[row.j] += row.abs_correlation; + } + } + + const double centrality_sum = + std::accumulate(centrality.begin(), centrality.end(), 0.0); + + std::vector raw_importance(n, 0.0); + double importance_sum = 0.0; + + out.variance_rows.reserve(n); + for (Eigen::Index i = 0; i < cov.rows(); ++i) { + const std::size_t ii = static_cast(i); + + FunctionalParameterInfluenceRow row; + row.index = ii; + row.name = ii < names.size() ? names[ii] : ("param_" + std::to_string(ii)); + row.variance = cov(i, i); + row.sd = row.variance >= 0.0 ? std::sqrt(row.variance) + : std::numeric_limits::quiet_NaN(); + row.variance_share = + variance_sum > 0.0 ? std::max(0.0, row.variance) / variance_sum : 0.0; + + row.correlation_centrality = centrality[ii]; + row.correlation_centrality_share = + centrality_sum > 0.0 ? centrality[ii] / centrality_sum : 0.0; + + if (precision_hessian != nullptr && + precision_hessian->rows() == cov.rows() && + precision_hessian->cols() == cov.cols()) { + row.curvature_diagonal = (*precision_hessian)(i, i); + row.curvature_column_norm = precision_hessian->col(i).norm(); + } + + const double curvature_factor = std::isfinite(row.curvature_column_norm) + ? row.curvature_column_norm + : 1.0; + const double sd_factor = std::isfinite(row.sd) ? row.sd : 0.0; + const double centrality_factor = std::isfinite(row.correlation_centrality) + ? (1.0 + row.correlation_centrality) + : 1.0; + + row.importance_score = sd_factor * curvature_factor * centrality_factor; + raw_importance[ii] = row.importance_score; + importance_sum += row.importance_score; + + out.variance_rows.push_back(row); + } + + for (auto &row : out.variance_rows) { + row.importance_share = + importance_sum > 0.0 ? row.importance_score / importance_sum : 0.0; + } + + std::sort(out.variance_rows.begin(), out.variance_rows.end(), + [](const FunctionalParameterInfluenceRow &a, + const FunctionalParameterInfluenceRow &b) { + return a.importance_score > b.importance_score; + }); + + std::sort(corr_rows.begin(), corr_rows.end(), + [](const FunctionalCorrelationInfluenceRow &a, + const FunctionalCorrelationInfluenceRow &b) { + return a.abs_correlation > b.abs_correlation; + }); + + if (corr_rows.size() > top_correlation_count) { + corr_rows.resize(top_correlation_count); + } + + out.top_correlation_rows = std::move(corr_rows); + return out; +} + +inline FunctionalCorrelationGraphSummary +summarize_correlation_graph(const Eigen::MatrixXd &cov, + const std::vector &names = {}, + double abs_corr_threshold = 0.5) { + FunctionalCorrelationGraphSummary out; + out.abs_correlation_threshold = abs_corr_threshold; + out.node_count = static_cast(cov.rows()); + + if (cov.rows() == 0 || cov.cols() == 0 || cov.rows() != cov.cols()) { + return out; + } + + out.available = true; + std::vector> adj(out.node_count); + + for (Eigen::Index i = 0; i < cov.rows(); ++i) { + for (Eigen::Index j = i + 1; j < cov.cols(); ++j) { + const double denom = std::sqrt(std::abs(cov(i, i) * cov(j, j))); + if (denom <= 0.0 || !std::isfinite(denom)) { + continue; + } + + const double ac = std::abs(cov(i, j) / denom); + if (ac >= abs_corr_threshold) { + const auto ii = static_cast(i); + const auto jj = static_cast(j); + adj[ii].push_back(jj); + adj[jj].push_back(ii); + ++out.edge_count; + } + } + } + + out.average_degree = out.node_count > 0 + ? (2.0 * static_cast(out.edge_count)) / + static_cast(out.node_count) + : 0.0; + + for (std::size_t i = 0; i < adj.size(); ++i) { + if (adj[i].size() > out.maximum_degree) { + out.maximum_degree = adj[i].size(); + out.maximum_degree_index = i; + } + } + out.maximum_degree_name = + out.maximum_degree_index < names.size() + ? names[out.maximum_degree_index] + : ("param_" + std::to_string(out.maximum_degree_index)); + + std::vector component(out.node_count, -1); + std::size_t component_id = 0; + + for (std::size_t start = 0; start < out.node_count; ++start) { + if (component[start] != -1) { + continue; + } + + std::queue q; + q.push(start); + component[start] = static_cast(component_id); + std::size_t component_size = 0; + + while (!q.empty()) { + const std::size_t v = q.front(); + q.pop(); + ++component_size; + + for (const std::size_t nb : adj[v]) { + if (component[nb] == -1) { + component[nb] = static_cast(component_id); + q.push(nb); + } + } + } + + out.largest_component_size = + std::max(out.largest_component_size, component_size); + ++component_id; + } + + out.connected_components = component_id; + + auto bfs_max_distance = [&](std::size_t source) -> int { + std::vector dist(out.node_count, -1); + std::queue q; + dist[source] = 0; + q.push(source); + int max_dist = 0; + + while (!q.empty()) { + const std::size_t v = q.front(); + q.pop(); + max_dist = std::max(max_dist, dist[v]); + + for (const std::size_t nb : adj[v]) { + if (dist[nb] == -1) { + dist[nb] = dist[v] + 1; + q.push(nb); + } + } + } + + return max_dist; + }; + + out.graph_diameter = 0; + for (std::size_t i = 0; i < out.node_count; ++i) { + if (!adj[i].empty()) { + out.graph_diameter = std::max(out.graph_diameter, bfs_max_distance(i)); + } + } + + return out; +} + +inline FunctionalParameterGeometrySummary +summarize_parameter_geometry(const Eigen::MatrixXd &H, + const std::vector &gradient = {}, + const std::vector &names = {}) { + FunctionalParameterGeometrySummary out; + + if (H.rows() == 0 || H.cols() == 0 || H.rows() != H.cols()) { + return out; + } + + out.available = true; + + std::vector column_norms(static_cast(H.cols()), 0.0); + double total_column_norm = 0.0; + + for (Eigen::Index j = 0; j < H.cols(); ++j) { + const double norm = H.col(j).norm(); + column_norms[static_cast(j)] = norm; + total_column_norm += norm; + } + + out.rows.reserve(static_cast(H.cols())); + for (Eigen::Index j = 0; j < H.cols(); ++j) { + const std::size_t jj = static_cast(j); + + FunctionalParameterGeometryRow row; + row.index = jj; + row.name = jj < names.size() ? names[jj] : ("param_" + std::to_string(jj)); + if (jj < gradient.size()) { + row.gradient = gradient[jj]; + row.abs_gradient = std::abs(gradient[jj]); + } + row.curvature_column_norm = column_norms[jj]; + row.curvature_diagonal = H(j, j); + row.curvature_share = total_column_norm > 0.0 + ? row.curvature_column_norm / total_column_norm + : 0.0; + out.rows.push_back(row); + } + + std::sort(out.rows.begin(), out.rows.end(), + [](const FunctionalParameterGeometryRow &a, + const FunctionalParameterGeometryRow &b) { + return a.curvature_column_norm > b.curvature_column_norm; + }); + + if (!out.rows.empty()) { + out.dominant_parameter = out.rows.front().name; + out.dominant_parameter_index = out.rows.front().index; + out.dominant_curvature_column_norm = out.rows.front().curvature_column_norm; + } + + return out; +} + +inline FunctionalGradientVolatilitySummary summarize_gradient_volatility( + const std::vector> &gradient_samples, + const std::vector &baseline_gradient, + const std::vector &names = {}, + double perturbation_scale = 0.0) { + FunctionalGradientVolatilitySummary out; + out.perturbation_scale = perturbation_scale; + out.samples = gradient_samples.size(); + + if (baseline_gradient.empty() || gradient_samples.empty()) { + return out; + } + + out.available = true; + + double baseline_sumsq = 0.0; + for (const double g : baseline_gradient) { + baseline_sumsq += g * g; + } + out.baseline_gradient_norm = std::sqrt(baseline_sumsq); + + std::vector norms; + norms.reserve(gradient_samples.size()); + + for (const auto &g : gradient_samples) { + double ss = 0.0; + for (const double v : g) { + ss += v * v; + } + norms.push_back(std::sqrt(ss)); + } + + const double norm_sum = std::accumulate(norms.begin(), norms.end(), 0.0); + out.mean_gradient_norm = norm_sum / static_cast(norms.size()); + + double norm_var = 0.0; + out.max_gradient_norm = norms[0]; + for (const double x : norms) { + norm_var += (x - out.mean_gradient_norm) * (x - out.mean_gradient_norm); + out.max_gradient_norm = std::max(out.max_gradient_norm, x); + } + out.sd_gradient_norm = + std::sqrt(norm_var / static_cast(norms.size())); + out.gradient_norm_cv = + std::abs(out.mean_gradient_norm) > 0.0 + ? out.sd_gradient_norm / std::abs(out.mean_gradient_norm) + : std::numeric_limits::quiet_NaN(); + + const std::size_t p = baseline_gradient.size(); + std::vector component_sd(p, 0.0); + std::vector sign_flips(p, 0); + + for (std::size_t j = 0; j < p; ++j) { + double mean = 0.0; + std::size_t count = 0; + for (const auto &g : gradient_samples) { + if (j < g.size()) { + mean += g[j]; + ++count; + } + } + if (count == 0) + continue; + mean /= static_cast(count); + + double var = 0.0; + for (const auto &g : gradient_samples) { + if (j < g.size()) { + var += (g[j] - mean) * (g[j] - mean); + + if ((baseline_gradient[j] > 0.0 && g[j] < 0.0) || + (baseline_gradient[j] < 0.0 && g[j] > 0.0)) { + ++sign_flips[j]; + } + } + } + component_sd[j] = std::sqrt(var / static_cast(count)); + } + + for (std::size_t j = 0; j < p; ++j) { + if (j == 0 || component_sd[j] > out.most_volatile_parameter_sd) { + out.most_volatile_parameter_sd = component_sd[j]; + out.most_volatile_parameter_index = j; + } + if (sign_flips[j] > out.most_sign_flips) { + out.most_sign_flips = sign_flips[j]; + out.most_sign_flips_parameter_index = j; + } + } + + out.most_volatile_parameter = + out.most_volatile_parameter_index < names.size() + ? names[out.most_volatile_parameter_index] + : ("param_" + std::to_string(out.most_volatile_parameter_index)); + out.most_sign_flips_parameter = + out.most_sign_flips_parameter_index < names.size() + ? names[out.most_sign_flips_parameter_index] + : ("param_" + std::to_string(out.most_sign_flips_parameter_index)); + + return out; +} + +inline FunctionalSpectralStructureSummary +summarize_spectral_structure(const Eigen::MatrixXd &H) { + FunctionalSpectralStructureSummary out; + + if (H.rows() == 0 || H.cols() == 0 || H.rows() != H.cols()) { + return out; + } + + Eigen::SelfAdjointEigenSolver solver(H); + if (solver.info() != Eigen::Success) { + return out; + } + + std::vector vals; + vals.reserve(static_cast(H.rows())); + for (Eigen::Index i = 0; i < solver.eigenvalues().size(); ++i) { + vals.push_back(std::max(0.0, solver.eigenvalues()(i))); + } + + std::sort(vals.begin(), vals.end(), std::greater()); + + const double total = std::accumulate(vals.begin(), vals.end(), 0.0); + if (total <= 0.0) { + return out; + } + + out.available = true; + out.eigen_count = vals.size(); + out.eigen_sum = total; + out.largest_eigen_share = vals.empty() ? 0.0 : vals.front() / total; + out.eigenvalues_desc = vals; + + double entropy = 0.0; + double cumulative = 0.0; + out.cumulative_share.reserve(vals.size()); + + auto needed_for = [&](double target) { + std::size_t k = 0; + double cum = 0.0; + for (double v : vals) { + ++k; + cum += v / total; + if (cum >= target) { + return k; + } + } + return vals.size(); + }; + + for (double v : vals) { + const double p = v / total; + if (p > 0.0) { + entropy -= p * std::log(p); + } + cumulative += p; + out.cumulative_share.push_back(cumulative); + } + + out.effective_rank_entropy = std::exp(entropy); + out.eigen_count_for_50 = needed_for(0.50); + out.eigen_count_for_90 = needed_for(0.90); + out.eigen_count_for_95 = needed_for(0.95); + out.eigen_count_for_99 = needed_for(0.99); + + return out; +} + +inline Eigen::MatrixXd +covariance_from_positive_definite_hessian(const Eigen::MatrixXd &H) { + if (H.rows() == 0 || H.cols() == 0 || H.rows() != H.cols()) { + return Eigen::MatrixXd(); + } + + Eigen::LLT llt(H); + if (llt.info() != Eigen::Success) { + return Eigen::MatrixXd(); + } + + return llt.solve(Eigen::MatrixXd::Identity(H.rows(), H.cols())); +} + +inline FunctionalAnalysisReport make_functional_analysis_report( + const FunctionalOptimizationSummary &optimization, + const Eigen::MatrixXd &Huu, const std::vector &latent_states, + double nonzero_tol = 1.0e-8, + const std::vector &random_effect_names = {}, + std::size_t top_correlation_count = 10) { + FunctionalAnalysisReport report; + report.optimization = optimization; + report.laplace_structure = + summarize_laplace_hessian_structure(Huu, nonzero_tol); + + const Eigen::MatrixXd cov = covariance_from_positive_definite_hessian(Huu); + report.uncertainty = summarize_covariance_correlation(cov); + report.latent_states = summarize_latent_states(latent_states); + report.parameter_influence = summarize_parameter_influence( + cov, random_effect_names, top_correlation_count, &Huu); + report.correlation_graph = + summarize_correlation_graph(cov, random_effect_names, 0.5); + report.spectral_structure = summarize_spectral_structure(Huu); + + return report; +} + +inline void +write_functional_analysis_report_text(const FunctionalAnalysisReport &report, + std::ostream &out) { + out << std::setprecision(15); + out << "Functional Analysis Report\n"; + out << "==========================\n\n"; + + out << "Optimization\n"; + out << "------------\n"; + out << "objective_value: " << report.optimization.objective_value + << "\n"; + out << "gradient_norm: " << report.optimization.gradient_norm + << "\n"; + out << "max_gradient_parameter: " + << report.optimization.max_gradient_parameter << "\n"; + out << "max_gradient_value: " + << report.optimization.max_gradient_value << "\n"; + out << "max_abs_gradient: " << report.optimization.max_abs_gradient + << "\n"; + out << "iterations: " << report.optimization.iterations + << "\n"; + out << "converged: " + << (report.optimization.converged ? "yes" : "no") << "\n"; + out << "message: " << report.optimization.message + << "\n\n"; + + out << "Curvature\n"; + out << "---------\n"; + out << "positive_definite: " + << (report.laplace_structure.positive_definite ? "yes" : "no") << "\n"; + out << "min_eigenvalue: " + << report.laplace_structure.min_eigenvalue << "\n"; + out << "max_eigenvalue: " + << report.laplace_structure.max_eigenvalue << "\n"; + out << "condition_number_abs: " + << report.laplace_structure.condition_number_abs << "\n\n"; + + out << "Spectral Structure\n"; + out << "------------------\n"; + out << "available: " + << (report.spectral_structure.available ? "yes" : "no") << "\n"; + out << "eigen_count: " << report.spectral_structure.eigen_count + << "\n"; + out << "largest_eigen_share: " + << report.spectral_structure.largest_eigen_share << "\n"; + out << "effective_rank_entropy: " + << report.spectral_structure.effective_rank_entropy << "\n"; + out << "eigen_count_for_50%: " + << report.spectral_structure.eigen_count_for_50 << "\n"; + out << "eigen_count_for_90%: " + << report.spectral_structure.eigen_count_for_90 << "\n"; + out << "eigen_count_for_95%: " + << report.spectral_structure.eigen_count_for_95 << "\n"; + out << "eigen_count_for_99%: " + << report.spectral_structure.eigen_count_for_99 << "\n\n"; + + out << "Huu Structure\n"; + out << "-------------\n"; + out << "random_effects: " + << report.laplace_structure.random_effects << "\n"; + out << "total_entries: " + << report.laplace_structure.total_entries << "\n"; + out << "structural_nonzeros: " + << report.laplace_structure.structural_nonzeros << "\n"; + out << "structural_density: " + << report.laplace_structure.structural_density << "\n\n"; + + out << "Effective Sparsity\n"; + out << "------------------\n"; + out << "curvature_retained,entries_required,entry_share,compression_vs_" + "structural\n"; + for (const auto &row : report.laplace_structure.effective_sparsity) { + out << row.label << "," << row.entries_required << "," << row.entry_share + << "," << row.compression_vs_structural << "\n"; + } + out << "\n"; + + out << "Effective Bandwidth\n"; + out << "-------------------\n"; + out << "curvature_retained,bandwidth,entry_count_if_banded,entry_share_if_" + "banded\n"; + for (const auto &row : report.laplace_structure.effective_bandwidth) { + out << row.label << "," << row.bandwidth << "," << row.entry_count_if_banded + << "," << row.entry_share_if_banded << "\n"; + } + out << "\n"; + + out << "Uncertainty\n"; + out << "-----------\n"; + out << "covariance_available: " + << (report.uncertainty.covariance_available ? "yes" : "no") << "\n"; + out << "correlation_available: " + << (report.uncertainty.correlation_available ? "yes" : "no") << "\n"; + out << "covariance_size: " << report.uncertainty.covariance_rows + << " x " << report.uncertainty.covariance_cols << "\n"; + out << "min_variance: " << report.uncertainty.min_variance + << "\n"; + out << "max_variance: " << report.uncertainty.max_variance + << "\n"; + out << "min_variance_index: " << report.uncertainty.min_variance_index + << "\n"; + out << "max_variance_index: " << report.uncertainty.max_variance_index + << "\n"; + out << "max_abs_correlation: " + << report.uncertainty.max_abs_correlation << "\n"; + out << "max_abs_correlation_pair: " + << report.uncertainty.max_abs_correlation_i << "," + << report.uncertainty.max_abs_correlation_j << "\n"; + out << "count_abs_corr_gt_0_5: " << report.uncertainty.corr_abs_gt_0_5 + << "\n"; + out << "count_abs_corr_gt_0_8: " << report.uncertainty.corr_abs_gt_0_8 + << "\n"; + out << "count_abs_corr_gt_0_9: " << report.uncertainty.corr_abs_gt_0_9 + << "\n\n"; + + out << "Parameter Influence\n"; + out << "-------------------\n"; + out << "available: " + << (report.parameter_influence.available ? "yes" : "no") << "\n"; + out << "Top parameter importance\n"; + out << "index,name,variance,sd,variance_share,correlation_centrality," + "correlation_centrality_share,curvature_column_norm," + "curvature_diagonal,importance_score,importance_share\n"; + for (const auto &row : report.parameter_influence.variance_rows) { + out << row.index << "," << row.name << "," << row.variance << "," << row.sd + << "," << row.variance_share << "," << row.correlation_centrality << "," + << row.correlation_centrality_share << "," << row.curvature_column_norm + << "," << row.curvature_diagonal << "," << row.importance_score << "," + << row.importance_share << "\n"; + } + out << "\nTop correlation pairs\n"; + out << "i,j,name_i,name_j,correlation,abs_correlation\n"; + for (const auto &row : report.parameter_influence.top_correlation_rows) { + out << row.i << "," << row.j << "," << row.name_i << "," << row.name_j + << "," << row.correlation << "," << row.abs_correlation << "\n"; + } + out << "\n"; + + out << "Correlation Graph\n"; + out << "-----------------\n"; + out << "available: " + << (report.correlation_graph.available ? "yes" : "no") << "\n"; + out << "abs_correlation_threshold: " + << report.correlation_graph.abs_correlation_threshold << "\n"; + out << "node_count: " << report.correlation_graph.node_count + << "\n"; + out << "edge_count: " << report.correlation_graph.edge_count + << "\n"; + out << "average_degree: " + << report.correlation_graph.average_degree << "\n"; + out << "maximum_degree: " + << report.correlation_graph.maximum_degree << "\n"; + out << "maximum_degree_parameter: " + << report.correlation_graph.maximum_degree_name << "\n"; + out << "connected_components: " + << report.correlation_graph.connected_components << "\n"; + out << "largest_component_size: " + << report.correlation_graph.largest_component_size << "\n"; + out << "graph_diameter: " + << report.correlation_graph.graph_diameter << "\n\n"; + + out << "Parameter Geometry\n"; + out << "------------------\n"; + out << "available: " + << (report.parameter_geometry.available ? "yes" : "no") << "\n"; + out << "dominant_parameter: " + << report.parameter_geometry.dominant_parameter << "\n"; + out << "dominant_parameter_index: " + << report.parameter_geometry.dominant_parameter_index << "\n"; + out << "dominant_curvature_norm: " + << report.parameter_geometry.dominant_curvature_column_norm << "\n"; + out << "index,name,gradient,abs_gradient,curvature_column_norm," + "curvature_diagonal,curvature_share\n"; + for (const auto &row : report.parameter_geometry.rows) { + out << row.index << "," << row.name << "," << row.gradient << "," + << row.abs_gradient << "," << row.curvature_column_norm << "," + << row.curvature_diagonal << "," << row.curvature_share << "\n"; + } + out << "\n"; + + out << "Gradient Volatility\n"; + out << "-------------------\n"; + out << "available: " + << (report.gradient_volatility.available ? "yes" : "no") << "\n"; + out << "perturbation_scale: " + << report.gradient_volatility.perturbation_scale << "\n"; + out << "samples: " << report.gradient_volatility.samples + << "\n"; + out << "baseline_gradient_norm: " + << report.gradient_volatility.baseline_gradient_norm << "\n"; + out << "mean_gradient_norm: " + << report.gradient_volatility.mean_gradient_norm << "\n"; + out << "sd_gradient_norm: " + << report.gradient_volatility.sd_gradient_norm << "\n"; + out << "max_gradient_norm: " + << report.gradient_volatility.max_gradient_norm << "\n"; + out << "gradient_norm_cv: " + << report.gradient_volatility.gradient_norm_cv << "\n"; + out << "most_volatile_parameter: " + << report.gradient_volatility.most_volatile_parameter << "\n"; + out << "most_volatile_parameter_sd: " + << report.gradient_volatility.most_volatile_parameter_sd << "\n"; + out << "most_sign_flips_parameter: " + << report.gradient_volatility.most_sign_flips_parameter << "\n"; + out << "most_sign_flips: " + << report.gradient_volatility.most_sign_flips << "\n\n"; + + out << "Latent States\n"; + out << "-------------\n"; + out << "count: " << report.latent_states.count << "\n"; + out << "mean: " << report.latent_states.mean << "\n"; + out << "sd: " << report.latent_states.sd << "\n"; + out << "min_value: " << report.latent_states.min_value + << "\n"; + out << "max_value: " << report.latent_states.max_value + << "\n"; + out << "min_index: " << report.latent_states.min_index + << "\n"; + out << "max_index: " << report.latent_states.max_index + << "\n"; + out << "l2_norm: " << report.latent_states.l2_norm << "\n"; +} + +inline void +write_functional_analysis_report_text(const FunctionalAnalysisReport &report, + const std::string &path) { + std::ofstream out(path); + write_functional_analysis_report_text(report, out); +} + +inline void +write_functional_analysis_report_csv(const FunctionalAnalysisReport &report, + std::ostream &out) { + out << std::setprecision(15); + out << "section,metric,target,value,extra\n"; + + out << "optimization,objective_value,," << report.optimization.objective_value + << ",\n"; + out << "optimization,gradient_norm,," << report.optimization.gradient_norm + << ",\n"; + out << "optimization,max_gradient_parameter,," + << report.optimization.max_gradient_parameter << ",\n"; + out << "optimization,max_gradient_value,," + << report.optimization.max_gradient_value << ",\n"; + out << "optimization,max_abs_gradient,," + << report.optimization.max_abs_gradient << ",\n"; + out << "optimization,iterations,," << report.optimization.iterations << ",\n"; + out << "optimization,converged,," + << (report.optimization.converged ? "yes" : "no") << ",\n"; + out << "optimization,message,," << report.optimization.message << ",\n"; + + out << "curvature,positive_definite,," + << (report.laplace_structure.positive_definite ? "yes" : "no") << ",\n"; + out << "curvature,min_eigenvalue,," << report.laplace_structure.min_eigenvalue + << ",\n"; + out << "curvature,max_eigenvalue,," << report.laplace_structure.max_eigenvalue + << ",\n"; + out << "curvature,condition_number_abs,," + << report.laplace_structure.condition_number_abs << ",\n"; + + out << "spectral_structure,available,," + << (report.spectral_structure.available ? "yes" : "no") << ",\n"; + out << "spectral_structure,largest_eigen_share,," + << report.spectral_structure.largest_eigen_share << ",\n"; + out << "spectral_structure,effective_rank_entropy,," + << report.spectral_structure.effective_rank_entropy << ",\n"; + out << "spectral_structure,eigen_count_for_50%,," + << report.spectral_structure.eigen_count_for_50 << ",\n"; + out << "spectral_structure,eigen_count_for_90%,," + << report.spectral_structure.eigen_count_for_90 << ",\n"; + out << "spectral_structure,eigen_count_for_95%,," + << report.spectral_structure.eigen_count_for_95 << ",\n"; + out << "spectral_structure,eigen_count_for_99%,," + << report.spectral_structure.eigen_count_for_99 << ",\n"; + + out << "huu_structure,random_effects,," + << report.laplace_structure.random_effects << ",\n"; + out << "huu_structure,total_entries,," + << report.laplace_structure.total_entries << ",\n"; + out << "huu_structure,structural_nonzeros,," + << report.laplace_structure.structural_nonzeros << ",\n"; + out << "huu_structure,structural_density,," + << report.laplace_structure.structural_density << ",\n"; + + for (const auto &row : report.laplace_structure.effective_sparsity) { + out << "effective_sparsity,entries_required," << row.label << "," + << row.entries_required + << ",compression_vs_structural=" << row.compression_vs_structural + << "\n"; + } + + for (const auto &row : report.laplace_structure.effective_bandwidth) { + out << "effective_bandwidth,bandwidth," << row.label << "," << row.bandwidth + << ",entry_count_if_banded=" << row.entry_count_if_banded << "\n"; + } + + out << "uncertainty,covariance_available,," + << (report.uncertainty.covariance_available ? "yes" : "no") << ",\n"; + out << "uncertainty,correlation_available,," + << (report.uncertainty.correlation_available ? "yes" : "no") << ",\n"; + out << "uncertainty,min_variance,," << report.uncertainty.min_variance + << ",index=" << report.uncertainty.min_variance_index << "\n"; + out << "uncertainty,max_variance,," << report.uncertainty.max_variance + << ",index=" << report.uncertainty.max_variance_index << "\n"; + out << "uncertainty,max_abs_correlation,," + << report.uncertainty.max_abs_correlation + << ",pair=" << report.uncertainty.max_abs_correlation_i << ";" + << report.uncertainty.max_abs_correlation_j << "\n"; + out << "uncertainty,count_abs_corr_gt_0_5,," + << report.uncertainty.corr_abs_gt_0_5 << ",\n"; + out << "uncertainty,count_abs_corr_gt_0_8,," + << report.uncertainty.corr_abs_gt_0_8 << ",\n"; + out << "uncertainty,count_abs_corr_gt_0_9,," + << report.uncertainty.corr_abs_gt_0_9 << ",\n"; + + for (const auto &row : report.parameter_influence.variance_rows) { + out << "parameter_influence,importance," << row.name << "," + << row.importance_score << ",index=" << row.index << ";sd=" << row.sd + << ";variance=" << row.variance + << ";variance_share=" << row.variance_share + << ";correlation_centrality=" << row.correlation_centrality + << ";curvature_column_norm=" << row.curvature_column_norm + << ";importance_share=" << row.importance_share << "\n"; + } + + for (const auto &row : report.parameter_influence.top_correlation_rows) { + out << "parameter_influence,correlation_pair," << row.name_i << "__" + << row.name_j << "," << row.correlation << ",i=" << row.i + << ";j=" << row.j << ";abs_correlation=" << row.abs_correlation << "\n"; + } + + out << "correlation_graph,abs_correlation_threshold,," + << report.correlation_graph.abs_correlation_threshold << ",\n"; + out << "correlation_graph,node_count,," << report.correlation_graph.node_count + << ",\n"; + out << "correlation_graph,edge_count,," << report.correlation_graph.edge_count + << ",\n"; + out << "correlation_graph,average_degree,," + << report.correlation_graph.average_degree << ",\n"; + out << "correlation_graph,maximum_degree,," + << report.correlation_graph.maximum_degree + << ",parameter=" << report.correlation_graph.maximum_degree_name << "\n"; + out << "correlation_graph,connected_components,," + << report.correlation_graph.connected_components << ",\n"; + out << "correlation_graph,largest_component_size,," + << report.correlation_graph.largest_component_size << ",\n"; + out << "correlation_graph,graph_diameter,," + << report.correlation_graph.graph_diameter << ",\n"; + + out << "parameter_geometry,available,," + << (report.parameter_geometry.available ? "yes" : "no") << ",\n"; + out << "parameter_geometry,dominant_parameter,," + << report.parameter_geometry.dominant_parameter + << ",index=" << report.parameter_geometry.dominant_parameter_index + << ";curvature_column_norm=" + << report.parameter_geometry.dominant_curvature_column_norm << "\n"; + + for (const auto &row : report.parameter_geometry.rows) { + out << "parameter_geometry,curvature_column_norm," << row.name << "," + << row.curvature_column_norm << ",index=" << row.index + << ";gradient=" << row.gradient << ";abs_gradient=" << row.abs_gradient + << ";curvature_diagonal=" << row.curvature_diagonal + << ";curvature_share=" << row.curvature_share << "\n"; + } + + out << "gradient_volatility,available,," + << (report.gradient_volatility.available ? "yes" : "no") << ",\n"; + out << "gradient_volatility,perturbation_scale,," + << report.gradient_volatility.perturbation_scale << ",\n"; + out << "gradient_volatility,samples,," << report.gradient_volatility.samples + << ",\n"; + out << "gradient_volatility,baseline_gradient_norm,," + << report.gradient_volatility.baseline_gradient_norm << ",\n"; + out << "gradient_volatility,mean_gradient_norm,," + << report.gradient_volatility.mean_gradient_norm << ",\n"; + out << "gradient_volatility,sd_gradient_norm,," + << report.gradient_volatility.sd_gradient_norm << ",\n"; + out << "gradient_volatility,max_gradient_norm,," + << report.gradient_volatility.max_gradient_norm << ",\n"; + out << "gradient_volatility,gradient_norm_cv,," + << report.gradient_volatility.gradient_norm_cv << ",\n"; + out << "gradient_volatility,most_volatile_parameter,," + << report.gradient_volatility.most_volatile_parameter + << ",sd=" << report.gradient_volatility.most_volatile_parameter_sd + << "\n"; + out << "gradient_volatility,most_sign_flips_parameter,," + << report.gradient_volatility.most_sign_flips_parameter + << ",sign_flips=" << report.gradient_volatility.most_sign_flips << "\n"; + + out << "latent_states,count,," << report.latent_states.count << ",\n"; + out << "latent_states,mean,," << report.latent_states.mean << ",\n"; + out << "latent_states,sd,," << report.latent_states.sd << ",\n"; + out << "latent_states,min_value,," << report.latent_states.min_value + << ",index=" << report.latent_states.min_index << "\n"; + out << "latent_states,max_value,," << report.latent_states.max_value + << ",index=" << report.latent_states.max_index << "\n"; + out << "latent_states,l2_norm,," << report.latent_states.l2_norm << ",\n"; +} + +inline void +write_functional_analysis_report_csv(const FunctionalAnalysisReport &report, + const std::string &path) { + std::ofstream out(path); + write_functional_analysis_report_csv(report, out); +} + +} // namespace quadra diff --git a/core/laplace/laplace_gradient_diagnostics.hpp b/core/laplace/laplace_gradient_diagnostics.hpp new file mode 100644 index 0000000..0ba235a --- /dev/null +++ b/core/laplace/laplace_gradient_diagnostics.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include + +#include +#include + +namespace quadra { +namespace laplace { +namespace diagnostics { + +inline void print_du_dtheta_summary(const Eigen::MatrixXd &dU) { +#ifdef QUADRA_DEBUG_DU_DTHETA_NORMS + std::cout << "Quadra dU diagnostic\n"; + + std::cout << " dU_col_norms = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).norm(); + if (j + 1 < dU.cols()) + std::cout << " "; + } + std::cout << "\n"; + + std::cout << " dU_col_maxabs = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).cwiseAbs().maxCoeff(); + if (j + 1 < dU.cols()) + std::cout << " "; + } + std::cout << "\n"; + + std::cout << " dU_first_rows ="; + const Eigen::Index nprint = std::min(5, dU.rows()); + for (Eigen::Index r = 0; r < nprint; ++r) { + std::cout << "\n row " << r << ": "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU(r, j); + if (j + 1 < dU.cols()) + std::cout << " "; + } + } + std::cout << "\n"; +#else + (void)dU; +#endif +} + +inline void +print_theta_only_vs_total_logdet_gradient(const Eigen::VectorXd &theta_only, + const Eigen::VectorXd &total) { +#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + std::cout << "Quadra logdet Hdot diagnostic\n"; + std::cout << " theta_only_logdet_grad = " << theta_only.transpose() << "\n"; + std::cout << " total_logdet_grad = " << total.transpose() << "\n"; + std::cout << " implicit_u_contribution= " << (total - theta_only).transpose() + << "\n"; +#else + (void)theta_only; + (void)total; +#endif +} + +inline void +print_hdot_exact_vs_fd_trace(const Eigen::VectorXd &exact_trace, + const Eigen::VectorXd &fd_trace, + const Eigen::VectorXd &rel_hdot_matrix_err) { +#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE + std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\n"; + std::cout << " exact_total_logdet_grad = " << exact_trace.transpose() + << "\n"; + std::cout << " fd_total_logdet_grad = " << fd_trace.transpose() << "\n"; + std::cout << " exact_minus_fd = " + << (exact_trace - fd_trace).transpose() << "\n"; + std::cout << " rel_Hdot_matrix_err = " << rel_hdot_matrix_err.transpose() + << "\n"; +#else + (void)exact_trace; + (void)fd_trace; + (void)rel_hdot_matrix_err; +#endif +} + +inline void print_gradient_parts(const Eigen::VectorXd &joint_grad, + const Eigen::VectorXd &logdet_grad, + const Eigen::VectorXd &total_grad) { +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << "Quadra gradient parts\n"; + std::cout << " joint_grad = " << joint_grad.transpose() << "\n"; + std::cout << " logdet_grad = " << logdet_grad.transpose() << "\n"; + std::cout << " total_grad = " << total_grad.transpose() << "\n"; +#else + (void)joint_grad; + (void)logdet_grad; + (void)total_grad; +#endif +} + +inline void +print_logdet_gradient_comparison(const Eigen::VectorXd &exact_logdet_grad, + const Eigen::VectorXd &fd_logdet_grad) { +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + std::cout << "Quadra logdet gradient parts\n"; + std::cout << " logdet_grad = " << exact_logdet_grad.transpose() << "\n"; + std::cout << " logdet_fd_grad = " << fd_logdet_grad.transpose() << "\n"; + std::cout << " logdet_grad diff = " + << (exact_logdet_grad - fd_logdet_grad).transpose() << "\n"; +#else + (void)exact_logdet_grad; + (void)fd_logdet_grad; +#endif +} + +} // namespace diagnostics +} // namespace laplace +} // namespace quadra diff --git a/core/laplace/laplace_structure_report.hpp b/core/laplace/laplace_structure_report.hpp new file mode 100644 index 0000000..b5c6be6 --- /dev/null +++ b/core/laplace/laplace_structure_report.hpp @@ -0,0 +1,297 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quadra { + +struct LaplaceEffectiveSparsityRow { + double curvature_retained = 0.0; + std::string label; + std::size_t entries_required = 0; + double entry_share = 0.0; + double compression_vs_structural = 0.0; +}; + +struct LaplaceEffectiveBandwidthRow { + double curvature_retained = 0.0; + std::string label; + std::size_t bandwidth = 0; + std::size_t entry_count_if_banded = 0; + double entry_share_if_banded = 0.0; +}; + +struct LaplaceStructureReport { + std::size_t random_effects = 0; + std::size_t total_entries = 0; + std::size_t structural_nonzeros = 0; + double structural_density = 0.0; + double nonzero_tolerance = 1.0e-8; + double max_abs_entry = 0.0; + + bool eigen_success = false; + bool positive_definite = false; + double min_eigenvalue = std::numeric_limits::quiet_NaN(); + double max_eigenvalue = std::numeric_limits::quiet_NaN(); + double condition_number_abs = std::numeric_limits::quiet_NaN(); + + std::vector effective_sparsity; + std::vector effective_bandwidth; +}; + +inline std::vector> +default_laplace_structure_targets() { + return {{0.90, "90%"}, {0.95, "95%"}, {0.97, "97%"}, {0.98, "98%"}, + {0.99, "99%"}, {0.995, "99.5%"}, {0.999, "99.9%"}, {1.00, "100%"}}; +} + +inline std::size_t banded_square_entry_count(std::size_t n, + std::size_t bandwidth) { + std::size_t out = 0; + for (std::size_t i = 0; i < n; ++i) { + for (std::size_t j = 0; j < n; ++j) { + const std::size_t d = (i > j) ? (i - j) : (j - i); + if (d <= bandwidth) { + ++out; + } + } + } + return out; +} + +inline LaplaceStructureReport summarize_laplace_hessian_structure( + const Eigen::MatrixXd &H, double nonzero_tol = 1.0e-8, + const std::vector> &targets = + default_laplace_structure_targets()) { + LaplaceStructureReport report; + report.random_effects = static_cast(H.rows()); + report.total_entries = static_cast(H.rows() * H.cols()); + report.nonzero_tolerance = nonzero_tol; + + if (H.rows() == 0 || H.cols() == 0) { + return report; + } + + if (H.rows() != H.cols()) { + throw std::runtime_error( + "summarize_laplace_hessian_structure requires a square matrix"); + } + + std::vector abs_values; + abs_values.reserve(report.total_entries); + + double total_abs = 0.0; + for (Eigen::Index i = 0; i < H.rows(); ++i) { + for (Eigen::Index j = 0; j < H.cols(); ++j) { + const double av = std::abs(H(i, j)); + report.max_abs_entry = std::max(report.max_abs_entry, av); + if (av > nonzero_tol) { + ++report.structural_nonzeros; + abs_values.push_back(av); + total_abs += av; + } + } + } + + report.structural_density = + report.total_entries > 0 + ? static_cast(report.structural_nonzeros) / + static_cast(report.total_entries) + : 0.0; + + std::sort(abs_values.begin(), abs_values.end(), std::greater()); + + auto entries_for_share = [&](double target) -> std::size_t { + if (target >= 1.0) { + return report.structural_nonzeros; + } + double running = 0.0; + for (std::size_t i = 0; i < abs_values.size(); ++i) { + running += abs_values[i]; + if (total_abs > 0.0 && running / total_abs >= target) { + return i + 1; + } + } + return abs_values.size(); + }; + + for (const auto &target : targets) { + const std::size_t entries = entries_for_share(target.first); + LaplaceEffectiveSparsityRow row; + row.curvature_retained = target.first; + row.label = target.second; + row.entries_required = entries; + row.entry_share = report.total_entries > 0 + ? static_cast(entries) / + static_cast(report.total_entries) + : 0.0; + row.compression_vs_structural = + entries > 0 ? static_cast(report.structural_nonzeros) / + static_cast(entries) + : 0.0; + report.effective_sparsity.push_back(row); + } + + std::vector band_abs(report.random_effects, 0.0); + double upper_abs_total = 0.0; + + for (Eigen::Index i = 0; i < H.rows(); ++i) { + for (Eigen::Index j = i; j < H.cols(); ++j) { + const std::size_t d = static_cast(j - i); + const double av = std::abs(H(i, j)); + band_abs[d] += av; + upper_abs_total += av; + } + } + + auto bandwidth_for_share = [&](double target) -> std::size_t { + if (target >= 1.0) { + return report.random_effects > 0 ? report.random_effects - 1 : 0; + } + double running = 0.0; + for (std::size_t d = 0; d < band_abs.size(); ++d) { + running += band_abs[d]; + if (upper_abs_total > 0.0 && running / upper_abs_total >= target) { + return d; + } + } + return report.random_effects > 0 ? report.random_effects - 1 : 0; + }; + + for (const auto &target : targets) { + const std::size_t bw = bandwidth_for_share(target.first); + LaplaceEffectiveBandwidthRow row; + row.curvature_retained = target.first; + row.label = target.second; + row.bandwidth = bw; + row.entry_count_if_banded = + banded_square_entry_count(report.random_effects, bw); + row.entry_share_if_banded = + report.total_entries > 0 + ? static_cast(row.entry_count_if_banded) / + static_cast(report.total_entries) + : 0.0; + report.effective_bandwidth.push_back(row); + } + + Eigen::SelfAdjointEigenSolver eig(H); + report.eigen_success = eig.info() == Eigen::Success; + if (report.eigen_success && eig.eigenvalues().size() > 0) { + report.min_eigenvalue = eig.eigenvalues().minCoeff(); + report.max_eigenvalue = eig.eigenvalues().maxCoeff(); + report.positive_definite = report.min_eigenvalue > 0.0; + report.condition_number_abs = + std::abs(report.max_eigenvalue) / + std::max(std::abs(report.min_eigenvalue), 1.0e-300); + } + + return report; +} + +inline void +write_laplace_structure_report_text(const LaplaceStructureReport &report, + std::ostream &out) { + out << std::setprecision(15); + out << "Laplace Structure Report\n"; + out << "========================\n\n"; + out << "Random effects: " << report.random_effects << "\n"; + + if (report.random_effects == 0) { + out << "No random-effect Hessian available.\n"; + return; + } + + out << "Matrix size: " << report.random_effects << " x " + << report.random_effects << "\n"; + out << "Total entries: " << report.total_entries << "\n"; + out << "Structural nonzeros: " << report.structural_nonzeros << " / " + << report.total_entries << " (" << 100.0 * report.structural_density + << "%)\n"; + out << "Nonzero tolerance: " << report.nonzero_tolerance << "\n"; + out << "Max |H_ij|: " << report.max_abs_entry << "\n"; + out << "Positive definite: " + << (report.positive_definite ? "yes" : "no") << "\n"; + out << "Min eigenvalue: " << report.min_eigenvalue << "\n"; + out << "Max eigenvalue: " << report.max_eigenvalue << "\n"; + out << "Condition number: " << report.condition_number_abs + << "\n\n"; + + out << "Effective sparsity\n"; + out << "------------------\n"; + out << "curvature_retained,entries_required,entry_share," + "compression_vs_structural\n"; + for (const auto &row : report.effective_sparsity) { + out << row.label << "," << row.entries_required << "," << row.entry_share + << "," << row.compression_vs_structural << "\n"; + } + + out << "\nEffective bandwidth\n"; + out << "-------------------\n"; + out << "curvature_retained,bandwidth,entry_count_if_banded," + "entry_share_if_banded\n"; + for (const auto &row : report.effective_bandwidth) { + out << row.label << "," << row.bandwidth << "," << row.entry_count_if_banded + << "," << row.entry_share_if_banded << "\n"; + } + + out << "\nInterpretation\n"; + out << "--------------\n"; + out << "This report measures numerical curvature concentration, not only " + "symbolic sparsity.\n"; + out << "A dense structural Hessian can still be effectively sparse if most " + "curvature is carried by relatively few entries or bands.\n"; +} + +inline void +write_laplace_structure_report_text(const LaplaceStructureReport &report, + const std::string &path) { + std::ofstream out(path); + write_laplace_structure_report_text(report, out); +} + +inline void +write_laplace_structure_report_csv(const LaplaceStructureReport &report, + std::ostream &out) { + out << std::setprecision(15); + out << "metric,target,value,extra\n"; + out << "random_effects,," << report.random_effects << ",\n"; + out << "total_entries,," << report.total_entries << ",\n"; + out << "structural_nonzeros,," << report.structural_nonzeros << ",\n"; + out << "structural_density,," << report.structural_density << ",\n"; + out << "positive_definite,," << (report.positive_definite ? "yes" : "no") + << ",\n"; + out << "min_eigenvalue,," << report.min_eigenvalue << ",\n"; + out << "max_eigenvalue,," << report.max_eigenvalue << ",\n"; + out << "condition_number,," << report.condition_number_abs << ",\n"; + + for (const auto &row : report.effective_sparsity) { + out << "effective_sparsity_entries," << row.label << "," + << row.entries_required + << ",compression_vs_structural=" << row.compression_vs_structural + << "\n"; + } + + for (const auto &row : report.effective_bandwidth) { + out << "effective_bandwidth," << row.label << "," << row.bandwidth << ",\n"; + } +} + +inline void +write_laplace_structure_report_csv(const LaplaceStructureReport &report, + const std::string &path) { + std::ofstream out(path); + write_laplace_structure_report_csv(report, out); +} + +} // namespace quadra diff --git a/core/laplace/structured_value_backend.hpp b/core/laplace/structured_value_backend.hpp index 540f291..4e67e43 100644 --- a/core/laplace/structured_value_backend.hpp +++ b/core/laplace/structured_value_backend.hpp @@ -20,6 +20,10 @@ struct TridiagonalValues { Eigen::VectorXd offdiag; // offdiag[i - 1] = H(i, i - 1) }; +struct SparseMatrixValues { + Eigen::SparseMatrix H; +}; + struct BandedValues { int bandwidth = 0; Eigen::VectorXd diag; @@ -94,6 +98,29 @@ inline double lower_band_value(const BandedValues &H, const int i, return band[j]; } +inline double logdet_sparse_matrix_values_ldlt(const SparseMatrixValues &H) { + Eigen::SimplicialLDLT> solver; + solver.compute(H.H); + + if (solver.info() != Eigen::Success) { + throw std::runtime_error("SparseMatrixValues LDLT factorization failed"); + } + + const auto d = solver.vectorD(); + double logdet = 0.0; + + for (Eigen::Index i = 0; i < d.size(); ++i) { + const double di = std::abs(d[i]); + if (!(di > 0.0) || !std::isfinite(di)) { + throw std::runtime_error( + "SparseMatrixValues Hessian has invalid LDLT diagonal"); + } + logdet += std::log(di); + } + + return logdet; +} + inline double logdet_banded_values_ldlt(const BandedValues &H) { const int n = static_cast(H.diag.size()); if (n == 0) diff --git a/core/laplace/structured_value_factory.hpp b/core/laplace/structured_value_factory.hpp index fddc16b..b777237 100644 --- a/core/laplace/structured_value_factory.hpp +++ b/core/laplace/structured_value_factory.hpp @@ -11,8 +11,8 @@ namespace quadra { namespace laplace { -using StructuredValues = - std::variant; +using StructuredValues = std::variant; inline DiagonalValues extract_diagonal_values(const Eigen::SparseMatrix &H) { @@ -105,9 +105,11 @@ extract_structured_values(const Eigen::SparseMatrix &H, return extract_banded_values(H, rec.bandwidth); case LaplaceBackendKind::SparseLDLT: - case LaplaceBackendKind::DenseLDLT: - throw std::invalid_argument( - "Structured value extraction is not implemented for requested backend"); + case LaplaceBackendKind::DenseLDLT: { + SparseMatrixValues values; + values.H = H; + return values; + } } throw std::invalid_argument("Unknown backend recommendation"); @@ -236,8 +238,11 @@ update_structured_values_from_hessian(StructuredValues &values, case LaplaceBackendKind::SparseLDLT: case LaplaceBackendKind::DenseLDLT: - throw std::invalid_argument( - "Structured value update is not implemented for requested backend"); + if (!std::holds_alternative(values)) { + values = SparseMatrixValues(); + } + std::get(values).H = H; + return; } throw std::invalid_argument("Unknown backend recommendation"); @@ -254,6 +259,8 @@ inline double logdet_structured_values(const StructuredValues &values) { return logdet_tridiagonal_values_ldlt(v); } else if constexpr (std::is_same_v) { return logdet_banded_values_ldlt(v); + } else if constexpr (std::is_same_v) { + return logdet_sparse_matrix_values_ldlt(v); } else { throw std::invalid_argument("Unsupported structured value type"); } diff --git a/core/optimizer.hpp b/core/optimizer.hpp index bf8bb85..c4d8f9c 100644 --- a/core/optimizer.hpp +++ b/core/optimizer.hpp @@ -2,6 +2,7 @@ #define OPTIMIZER_HPP #pragma once +#include #include #include #include @@ -37,6 +38,12 @@ struct OptPatternInfo { }; struct OptResult { + Eigen::VectorXd x; // fitted fixed-effect vector + // Final fixed-effect gradient diagnostics for optimizer troubleshooting. + // Entries correspond to fixed_index and fixed_gradient_names. + std::vector fixed_gradient_names; + std::vector fixed_gradient; + // Backward-compatible fixed-effect estimate. std::vector par; @@ -49,6 +56,9 @@ struct OptResult { // Objective and outer-gradient diagnostics. double value = std::numeric_limits::quiet_NaN(); + double joint_objective = std::numeric_limits::quiet_NaN(); + double laplace_logdet = std::numeric_limits::quiet_NaN(); + double laplace_constant = std::numeric_limits::quiet_NaN(); int iterations = 0; double grad_norm = std::numeric_limits::quiet_NaN(); @@ -183,6 +193,13 @@ LaplaceResult laplace_eval_at_u_star_persistent_structured( const std::vector &random_idx, const Eigen::VectorXd &x, const std::vector &u_star, had::ADGraph &graph, laplace::PersistentStructuredRuntimeState &structured_runtime, + Eigen::VectorXd *last_logdet_x = nullptr, + Eigen::VectorXd *last_logdet_u = nullptr, + Eigen::VectorXd *last_logdet_grad = nullptr, + bool *last_logdet_available = nullptr, double *timing_joint_ad_ms = nullptr, + double *timing_logdet_gradient_ms = nullptr, + double *timing_hessian_extract_ms = nullptr, + double *timing_structured_logdet_ms = nullptr, const LaplaceOptions &options = default_laplace_options()) { ADScope scope(graph); @@ -199,16 +216,34 @@ LaplaceResult laplace_eval_at_u_star_persistent_structured( inject_fixed_params(x, p_full, fixed_idx); inject_random_params(u_star, p_full, random_idx); + const auto timing_joint_start = std::chrono::steady_clock::now(); + AD nll = model(p_full); scope.backward(nll); + const auto timing_joint_end = std::chrono::steady_clock::now(); + if (timing_joint_ad_ms != nullptr) { + *timing_joint_ad_ms += std::chrono::duration( + timing_joint_end - timing_joint_start) + .count(); + } + res.grad_x.resize(fixed_idx.size()); for (size_t k = 0; k < fixed_idx.size(); ++k) { res.grad_x[k] = scope.grad(p_full[fixed_idx[k]]); } - // Keep the existing exact logdet-gradient path for fixed effects. +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd joint_grad_debug = Eigen::Map( + res.grad_x.data(), static_cast(res.grad_x.size())); +#endif + + // Fast comparison mode: skip exact logdet-gradient contribution. + // The objective still includes the Laplace logdet term, but the fixed-effect + // gradient uses the joint objective contribution only. This is useful for + // profiling optimizer overhead before the logdet-gradient path is cached. +#if !defined(QUADRA_SKIP_EXACT_LOGDET_GRADIENT) { Eigen::Map u_star_eigen( u_star.data(), static_cast(u_star.size())); @@ -216,26 +251,52 @@ LaplaceResult laplace_eval_at_u_star_persistent_structured( Eigen::VectorXd g_logdet = laplace_logdet_gradient_exact(model, params, x, u_star_eigen, options); - // laplace_logdet_gradient_exact builds temporary AD graphs. - // Restore the graph for this outer evaluation before any further - // grad/hess access through scope. +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd g_logdet_fd = + laplace_logdet_gradient_fd(model, params, x, u_star_eigen); + std::cout << "Quadra logdet gradient parts\n"; + std::cout << " logdet_grad = " << g_logdet.transpose() << "\n"; + std::cout << " logdet_fd_grad = " << g_logdet_fd.transpose() << "\n"; + std::cout << " logdet_grad diff = " << (g_logdet - g_logdet_fd).transpose() + << "\n"; +#endif + had::g_ADGraph = &scope.graph; for (size_t k = 0; k < fixed_idx.size(); ++k) { res.grad_x[k] += g_logdet[static_cast(k)]; } + +#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS + Eigen::VectorXd total_grad_debug = Eigen::Map( + res.grad_x.data(), static_cast(res.grad_x.size())); + std::cout << "Quadra gradient parts\n"; + std::cout << " joint_grad = " << joint_grad_debug.transpose() << "\n"; + std::cout << " logdet_grad = " << g_logdet.transpose() << "\n"; + std::cout << " total_grad = " << total_grad_debug.transpose() << "\n"; +#endif } +#endif res.grad_u.resize(random_idx.size()); for (size_t k = 0; k < random_idx.size(); ++k) { res.grad_u[k] = scope.grad(p_full[random_idx[k]]); } + const auto timing_hessian_start = std::chrono::steady_clock::now(); + const auto &pattern = get_pattern(scope, p_full, random_idx); Eigen::SparseMatrix H = extract_sparse_hessian( scope, p_full, random_idx, pattern, options.hessian_drop_tol); + const auto timing_hessian_end = std::chrono::steady_clock::now(); + if (timing_hessian_extract_ms != nullptr) { + *timing_hessian_extract_ms += std::chrono::duration( + timing_hessian_end - timing_hessian_start) + .count(); + } + // Persistent structured bridge: // First call: detect structure and choose backend. // Later calls: update structured values only and reuse recommendation. @@ -243,6 +304,8 @@ LaplaceResult laplace_eval_at_u_star_persistent_structured( detector_options.prefer_dense_for_small_matrices = false; detector_options.dense_size_cutoff = 0; + const auto timing_structured_start = std::chrono::steady_clock::now(); + if (!structured_runtime.initialized) { structured_runtime.update_from_hessian(H, detector_options); } else { @@ -251,7 +314,21 @@ LaplaceResult laplace_eval_at_u_star_persistent_structured( const double logdet = structured_runtime.logdet(); - res.value = value_of(nll) + 0.5 * logdet; + const auto timing_structured_end = std::chrono::steady_clock::now(); + if (timing_structured_logdet_ms != nullptr) { + *timing_structured_logdet_ms += + std::chrono::duration(timing_structured_end - + timing_structured_start) + .count(); + } + + const double laplace_constant = + 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI); + + res.joint_objective = value_of(nll); + res.laplace_logdet = logdet; + res.laplace_constant = laplace_constant; + res.value = res.joint_objective + 0.5 * logdet - laplace_constant; return res; } @@ -290,10 +367,23 @@ template class LBFGSObjective { int iter = 0; int print_every = 10; + double timing_total_ms = 0.0; + double timing_mode_solve_ms = 0.0; + double timing_laplace_eval_ms = 0.0; + double timing_joint_ad_ms = 0.0; + double timing_logdet_gradient_ms = 0.0; + double timing_hessian_extract_ms = 0.0; + double timing_structured_logdet_ms = 0.0; + int timing_eval_count = 0; + double last_fx = std::numeric_limits::quiet_NaN(); + double last_joint_objective = std::numeric_limits::quiet_NaN(); + double last_laplace_logdet = std::numeric_limits::quiet_NaN(); + double last_laplace_constant = std::numeric_limits::quiet_NaN(); Eigen::VectorXd last_grad; Eigen::VectorXd last_x; std::vector last_u_star; + Eigen::VectorXd best_converged_x; Eigen::VectorXd best_converged_grad; std::vector best_converged_u_star; @@ -314,6 +404,11 @@ template class LBFGSObjective { double best_converged_grad_norm = std::numeric_limits::infinity(); laplace::PersistentStructuredRuntimeState structured_runtime; + Eigen::VectorXd last_logdet_x; + Eigen::VectorXd last_logdet_u; + Eigen::VectorXd last_logdet_grad; + bool last_logdet_grad_available = false; + LBFGSObjective(Model &m, ParameterVector &p, std::vector fixed, std::vector random, const LaplaceOptions &opts = default_laplace_options()) @@ -327,14 +422,26 @@ template class LBFGSObjective { had::ADGraph &graph = tape.graph; ++iter; + ++timing_eval_count; + const auto timing_eval_start = std::chrono::steady_clock::now(); std::vector u_star; const bool verbose_inner = ((iter % print_every) == 0) || iter == 1; try { + const auto timing_mode_start = std::chrono::steady_clock::now(); + + const std::vector *u_warm_start = + (last_u_star.size() == random_idx.size()) ? &last_u_star : nullptr; + u_star = solve_random_effects_laplace(model, params, x, fixed_idx, - random_idx, graph); + random_idx, graph, u_warm_start); last_u_star = u_star; + + const auto timing_mode_end = std::chrono::steady_clock::now(); + timing_mode_solve_ms += std::chrono::duration( + timing_mode_end - timing_mode_start) + .count(); } catch (const std::exception &e) { std::cerr << "L-BFGS: random-effect mode solve failed; returning " "penalty. reason=" @@ -353,16 +460,27 @@ template class LBFGSObjective { Result res; try { + const auto timing_laplace_start = std::chrono::steady_clock::now(); + res = laplace_eval_at_u_star_persistent_structured( model, params, fixed_idx, random_idx, x, u_star, graph, - structured_runtime, options); + structured_runtime, &last_logdet_x, &last_logdet_u, &last_logdet_grad, + &last_logdet_grad_available, &timing_joint_ad_ms, + &timing_logdet_gradient_ms, &timing_hessian_extract_ms, + &timing_structured_logdet_ms, options); + + const auto timing_laplace_end = std::chrono::steady_clock::now(); + timing_laplace_eval_ms += std::chrono::duration( + timing_laplace_end - timing_laplace_start) + .count(); } catch (const std::exception &e) { std::cerr << "L-BFGS: Laplace evaluation failed; returning penalty. reason=" << e.what() << std::endl; grad.resize(x.size()); - grad.setZero(); + // grad.setZero(); + grad.setConstant(1.0e100); last_grad = grad; last_x = x; last_fx = std::numeric_limits::max() / 1.0e100; @@ -383,6 +501,9 @@ template class LBFGSObjective { grad = to_eigen(res.grad_x); last_fx = res.value; + last_joint_objective = res.joint_objective; + last_laplace_logdet = res.laplace_logdet; + last_laplace_constant = res.laplace_constant; last_grad = grad; last_x = x; @@ -396,6 +517,7 @@ template class LBFGSObjective { best_converged_u_star = u_star; best_converged_fx = res.value; best_converged_iter = iter; + best_converged_grad_norm = gnorm; has_best_converged = true; } @@ -407,6 +529,11 @@ template class LBFGSObjective { print(iter, res.value, gnorm); } + const auto timing_eval_end = std::chrono::steady_clock::now(); + timing_total_ms += std::chrono::duration( + timing_eval_end - timing_eval_start) + .count(); + return res.value; } }; @@ -433,108 +560,227 @@ optimize_lbfgs(Model &model, ParameterVector ¶ms, } LBFGSObjective fun(model, params, fixed_idx, random_idx, options); - fun.print_every = 10; + fun.print_every = 25; LBFGSParam param; - param.max_iterations = 400; - // param.max_linesearch = 20; + param.max_iterations = 100; + param.m = 20; + param.max_linesearch = 50; +#ifdef QUADRA_LBFGS_GRAD_TOL + param.epsilon = QUADRA_LBFGS_GRAD_TOL; +#else param.epsilon = 1.0e-4; +#endif fun.epsilon = param.epsilon; LBFGSSolver solver(param); double fx = std::numeric_limits::quiet_NaN(); int niter = 0; - try { - niter = solver.minimize(fun, x, fx); - - // quadra_lbfgs_honest_convergence_report_v1 - double quadra_final_fixed_grad_norm = - std::numeric_limits::quiet_NaN(); - if (fun.last_grad.size() > 0) { - quadra_final_fixed_grad_norm = 0.0; - for (int quadra_i = 0; quadra_i < fun.last_grad.size(); ++quadra_i) { - quadra_final_fixed_grad_norm += - fun.last_grad[quadra_i] * fun.last_grad[quadra_i]; + int line_search_recovery_attempts = 0; + bool line_search_recovery_used = false; + std::string line_search_recovery_message; + + constexpr int max_line_search_recovery_attempts = 2; + constexpr double recovery_step_initial = 1.0e-3; + constexpr double recovery_step_shrink = 0.5; + constexpr double recovery_step_min = 1.0e-12; + + while (true) { + try { + niter = solver.minimize(fun, x, fx); + + // quadra_lbfgs_honest_convergence_report_v1 + double quadra_final_fixed_grad_norm = + std::numeric_limits::quiet_NaN(); + if (fun.last_grad.size() > 0) { + quadra_final_fixed_grad_norm = 0.0; + for (int quadra_i = 0; quadra_i < fun.last_grad.size(); ++quadra_i) { + quadra_final_fixed_grad_norm += + fun.last_grad[quadra_i] * fun.last_grad[quadra_i]; + } + quadra_final_fixed_grad_norm = std::sqrt(quadra_final_fixed_grad_norm); } - quadra_final_fixed_grad_norm = std::sqrt(quadra_final_fixed_grad_norm); - } - const bool quadra_requested_tol_met = - std::isfinite(quadra_final_fixed_grad_norm) && - quadra_final_fixed_grad_norm <= 1.0e-4; - - std::cout << "L-BFGS minimize status report" << std::endl; - std::cout << " iterations returned by solver: " << niter << std::endl; - std::cout << " final objective returned by solver: " << fx << std::endl; - std::cout << " final fixed-gradient norm: " << quadra_final_fixed_grad_norm - << std::endl; - std::cout << " requested gradient tolerance: " << std::scientific << 1.0e-4 - << std::defaultfloat << std::endl; - std::cout << " configured max-iteration field: " << 400 - << " (LBFGSpp max_iterations)" << std::endl; - std::cout << " requested tolerance met: " - << (quadra_requested_tol_met ? "yes" : "no") << std::endl; - std::cout << " outer convergence interpretation: " - << (quadra_requested_tol_met - ? "converged to requested gradient tolerance" - : "stopped before requested gradient tolerance; inspect " - "LBFGS status/max iterations/line search") - << std::endl; - } catch (const LBFGSConvergedByGradient &) { - if (fun.has_best_converged) { - - std::cout << "L-BFGS: stopped at first iterate satisfying requested " - "fixed-effect gradient tolerance." - << std::endl; - } else { - throw; - } - } catch (const std::runtime_error &e) { - const double gnorm = safe_eigen_norm(fun.last_grad); - const double max_grad = (fun.last_grad.size() > 0) - ? fun.last_grad.cwiseAbs().maxCoeff() - : std::numeric_limits::infinity(); + const bool quadra_requested_tol_met = + std::isfinite(quadra_final_fixed_grad_norm) && + quadra_final_fixed_grad_norm <= 1.0e-4; + + std::cout << "L-BFGS minimize status report" << std::endl; + std::cout << " iterations returned by solver: " << niter << std::endl; + std::cout << " final objective returned by solver: " << fx << std::endl; + std::cout << " final fixed-gradient norm: " + << quadra_final_fixed_grad_norm << std::endl; + std::cout << " requested gradient tolerance: " << std::scientific + << 1.0e-4 << std::defaultfloat << std::endl; + std::cout << " configured max-iteration field: " << 400 + << " (LBFGSpp max_iterations)" << std::endl; + std::cout << " requested tolerance met: " + << (quadra_requested_tol_met ? "yes" : "no") << std::endl; + std::cout + << " outer convergence interpretation: " + << (quadra_requested_tol_met + ? "converged to requested gradient tolerance" + : "stopped before requested gradient tolerance; inspect " + "LBFGS status/max iterations/line search") + << std::endl; + break; + } catch (const LBFGSConvergedByGradient &) { + if (fun.has_best_converged) { + std::cout << "L-BFGS: stopped at first iterate satisfying requested " + "fixed-effect gradient tolerance." + << std::endl; + fx = fun.best_converged_fx; + x = fun.best_converged_x; + niter = fun.best_converged_iter; + break; + } else { + throw; + } + } catch (const std::runtime_error &e) { + const double gnorm = safe_eigen_norm(fun.last_grad); + const double max_grad = (fun.last_grad.size() > 0) + ? fun.last_grad.cwiseAbs().maxCoeff() + : std::numeric_limits::infinity(); - const std::string msg = e.what(); + const std::string msg = e.what(); - const bool line_search_failed = - msg.find("line search") != std::string::npos || - msg.find("Line search") != std::string::npos; + std::cout << "L-BFGS runtime_error caught: " << msg << "\n"; + std::cout << " gnorm = " << gnorm << "\n"; + std::cout << " max|grad| = " << max_grad << "\n"; - // LBFGSpp may throw a line-search failure after the objective has - // effectively plateaued. For public examples and diagnostic workflows, - // return the best finite iterate instead of aborting, while keeping - // result.converged honest via the stricter param.epsilon check below. - const double convergence_like_grad = 2e-2; + const bool line_search_failed = + msg.find("line search") != std::string::npos || + msg.find("Line search") != std::string::npos || + msg.find("sufficiently decrease") != std::string::npos; - if (gnorm <= param.epsilon) { - std::cout << "L-BFGS: optimization reached convergence criterion " - << "(|grad| <= epsilon). max|grad| = " << max_grad << std::endl; + const double convergence_like_grad = 2e-2; - if (fun.last_x.size() == x.size()) { - x = fun.last_x; + if (gnorm <= param.epsilon) { + std::cout << "L-BFGS: optimization reached convergence criterion " + << "(|grad| <= epsilon). max|grad| = " << max_grad + << std::endl; + + if (fun.last_x.size() == x.size()) { + x = fun.last_x; + } + + fx = fun.last_fx; + niter = fun.iter; + break; } - fx = fun.last_fx; - niter = fun.iter; - } else if (line_search_failed && max_grad < convergence_like_grad) { - std::cout - << "L-BFGS: line search failed after a small fixed-effect gradient. " - << "Returning the last finite iterate as a non-converged result. " - << "max|grad| = " << max_grad << std::endl; + if (line_search_failed && + line_search_recovery_attempts < max_line_search_recovery_attempts && + fun.last_x.size() == x.size() && fun.last_grad.size() == x.size() && + std::isfinite(fun.last_fx) && fun.last_grad.allFinite() && + safe_eigen_norm(fun.last_grad) > 0.0) { + ++line_search_recovery_attempts; + line_search_recovery_used = true; + line_search_recovery_message = + "L-BFGS line search stalled; recovered with gradient restart."; + + const Eigen::VectorXd x0 = fun.last_x; + const Eigen::VectorXd g = fun.last_grad; + const double f0 = fun.last_fx; + + bool accepted = false; + double alpha = recovery_step_initial; + const double min_meaningful_decrease = + 1.0e-10 * std::max(1.0, std::abs(f0)); + + while (alpha >= recovery_step_min) { + Eigen::VectorXd trial = x0 - alpha * g; + Eigen::VectorXd trial_grad; + const double f_trial = fun(trial, trial_grad); + + if (std::isfinite(f_trial) && + f_trial < f0 - min_meaningful_decrease) { + x = trial; + fx = f_trial; + accepted = true; + break; + } + + alpha *= recovery_step_shrink; + } + + if (accepted) { + std::cout << "L-BFGS: line-search recovery accepted gradient restart " + << "step. attempt = " << line_search_recovery_attempts + << ", alpha = " << alpha << ", fx = " << fx << std::endl; + + // LBFGSSolver stores param by reference and is not assignable. + // Calling minimize() again from the accepted recovery point + // rebuilds the quasi-Newton history inside LBFGSpp. + continue; + } + + std::cout << "L-BFGS: line-search recovery failed to find a decreasing " + << "gradient step." << std::endl; + } + + if (line_search_failed && max_grad < convergence_like_grad) { + std::cout + << "L-BFGS: line search failed after a small fixed-effect " + "gradient. " + << "Returning the last finite iterate as a non-converged result. " + << "max|grad| = " << max_grad << std::endl; + + if (fun.last_x.size() == x.size()) { + x = fun.last_x; + } + + fx = fun.last_fx; + niter = fun.iter; + break; + } + + if (line_search_failed && fun.last_x.size() == x.size() && + std::isfinite(fun.last_fx)) { + std::cout + << "L-BFGS: line search failed after recovery attempts. " + << "Returning the best finite iterate as a non-converged result " + << "so callers can inspect diagnostics. max|grad| = " << max_grad + << std::endl; - if (fun.last_x.size() == x.size()) { x = fun.last_x; + fx = fun.last_fx; + niter = fun.iter; + + if (line_search_recovery_message.empty()) { + line_search_recovery_message = + "L-BFGS line search failed; returned best finite iterate."; + } + + break; } - fx = fun.last_fx; - niter = fun.iter; - } else { throw; } } +#ifdef QUADRA_PROFILE_OPTIMIZER_TIMING + std::cout << "Quadra timing summary\n"; + std::cout << " objective evals: " << fun.timing_eval_count << "\n"; + std::cout << " total eval ms: " << fun.timing_total_ms << "\n"; + std::cout << " mode solve ms: " << fun.timing_mode_solve_ms << "\n"; + std::cout << " laplace eval ms: " << fun.timing_laplace_eval_ms + << "\n"; + std::cout << " joint AD ms: " << fun.timing_joint_ad_ms << "\n"; + std::cout << " logdet gradient ms: " << fun.timing_logdet_gradient_ms + << "\n"; + std::cout << " Hessian extract ms: " << fun.timing_hessian_extract_ms + << "\n"; + std::cout << " structured logdet ms:" << fun.timing_structured_logdet_ms + << "\n"; + std::cout << " other eval ms: " + << (fun.timing_total_ms - fun.timing_mode_solve_ms - + fun.timing_laplace_eval_ms) + << "\n"; + +#endif + OptResult result; Eigen::VectorXd selected_x; @@ -543,6 +789,9 @@ optimize_lbfgs(Model &model, ParameterVector ¶ms, double selected_grad_norm = std::numeric_limits::infinity(); if (fun.has_best_converged) { + selected_x = fun.best_converged_x; + selected_u_hat = fun.best_converged_u_star; + selected_fx = fun.best_converged_fx; selected_grad_norm = fun.best_converged_grad_norm; } else if (fun.best_available) { selected_x = fun.best_x; @@ -571,7 +820,53 @@ optimize_lbfgs(Model &model, ParameterVector ¶ms, result.fixed_index = fixed_idx; result.random_index = random_idx; + result.fixed_gradient_names.clear(); + result.fixed_gradient.clear(); + result.fixed_gradient_names.reserve(fixed_idx.size()); + + for (size_t k = 0; k < fixed_idx.size(); ++k) { + const auto idx = static_cast(fixed_idx[k]); + result.fixed_gradient_names.push_back(params.params[idx].name); + } + + if (fun.last_grad.size() == static_cast(fixed_idx.size())) { + result.fixed_gradient.assign(fun.last_grad.data(), + fun.last_grad.data() + fun.last_grad.size()); + } + result.value = selected_fx; + result.joint_objective = fun.last_joint_objective; + +#ifdef QUADRA_DEBUG_FD_FINAL_GRADIENT + { + const double eps = 1.0e-5; + Eigen::VectorXd fd = Eigen::VectorXd::Zero(selected_x.size()); + + for (Eigen::Index j = 0; j < selected_x.size(); ++j) { + Eigen::VectorXd xp = selected_x; + Eigen::VectorXd xm = selected_x; + xp[j] += eps; + xm[j] -= eps; + + Eigen::VectorXd gp_vec; + Eigen::VectorXd gm_vec; + + const double fp = fun(xp, gp_vec); + const double fm = fun(xm, gm_vec); + + fd[j] = (fp - fm) / (2.0 * eps); + } + + std::cout << "Quadra final profiled FD gradient = " << fd.transpose() + << "\n"; + std::cout << "Quadra final analytic gradient = " + << fun.last_grad.transpose() << "\n"; + std::cout << "Quadra final profiled FD-analytic diff = " + << (fd - fun.last_grad).transpose() << "\n"; + } +#endif + result.laplace_logdet = fun.last_laplace_logdet; + result.laplace_constant = fun.last_laplace_constant; result.iterations = niter; result.grad_norm = std::isfinite(selected_grad_norm) ? selected_grad_norm @@ -585,11 +880,28 @@ optimize_lbfgs(Model &model, ParameterVector ¶ms, ? "converged to requested fixed-effect gradient tolerance" : "stopped before requested fixed-effect gradient tolerance"; + if (line_search_recovery_used) { + result.message += "; "; + result.message += line_search_recovery_message; + result.message += " attempts="; + result.message += std::to_string(line_search_recovery_attempts); + } + Eigen::VectorXd pattern_x = selected_x; - result.pattern = analyze_final_random_effect_pattern( - model, params, pattern_x, result.u_hat, fixed_idx, random_idx, options); + if (!random_idx.empty()) { + result.pattern = analyze_final_random_effect_pattern( + model, params, pattern_x, result.u_hat, fixed_idx, random_idx, options); + } else { + result.pattern.available = false; + result.pattern.detected_structure = "none"; + result.pattern.backend = "none"; + result.pattern.solver = "none"; + result.pattern.complexity = "none"; + result.pattern.random_effect_count = 0; + } + result.x = x; return result; } diff --git a/docs/exact_laplace_gradient_validation.md b/docs/exact_laplace_gradient_validation.md new file mode 100644 index 0000000..dd93f73 --- /dev/null +++ b/docs/exact_laplace_gradient_validation.md @@ -0,0 +1,219 @@ +Laplace Gradient Validation (Completed) + +Status + +Completed: June 2026 + +The exact Laplace log-determinant gradient implementation in Quadra has been validated against multiple independent calculations and finite-difference checks. + +⸻ + +Motivation + +During development of the exact Laplace gradient machinery, a discrepancy was observed between: + +* Quadra exact Laplace gradients +* TMB Laplace gradients + +The objective of this investigation was to determine whether the discrepancy originated from: + +1. The implicit random-effect sensitivity calculation + du*/dθ +2. Exact directional Hessian propagation + Ḣ = D Huu [direction] +3. Trace contraction + tr(Huu⁻¹ Ḣ) +4. Objective construction +5. TMB implementation details + +⸻ + +Test Case + +SEFSC Red Snapper recruitment-deviation model. + +Characteristics: + +* 5 fixed effects +* 20 random effects +* Laplace approximation +* Exact sparse Hessian extraction +* Exact directional-Hessian propagation + +⸻ + +Validation Results + +1. Objective Agreement + +Quadra and TMB converged to essentially identical objective values. + +Quadra: + +110.643356126 + +TMB: + +110.642013166 + +Difference: + +0.001343 + +This establishes that both systems are evaluating effectively the same model. + +⸻ + +2. Random Effect Agreement + +Quadra and TMB produced identical random-effect estimates. + +Maximum absolute difference: + +0 + +This confirms that both systems are operating at the same profiled solution. + +⸻ + +3. Huu Agreement + +The random-effect Hessian extracted by Quadra matched the Hessian evaluated by TMB. + +Example: + +log det(Huu) + +Quadra: + +46.0040003451 + +TMB: + +46.004 + +Agreement was effectively exact. + +⸻ + +4. Implicit Sensitivity Validation + +Quadra computes + +du*/dθ = -Huu⁻¹ Huθ + +using the implicit function theorem. + +Independent finite-difference profiling in TMB reproduced the same sensitivities. + +Example column norms: + +4.595290 +2.500484 +1.681409 +0.062973 +0.103435 + +Quadra and TMB matched to numerical precision. + +Conclusion: + +The implicit random-effect sensitivity calculation is correct. + +⸻ + +5. Exact Ḣ Validation + +Quadra computes + +Ḣ = D Huu [eθ , du*/dθ] + +using exact directional automatic differentiation. + +An independent finite-difference implementation was constructed: + +ḢFD ≈ [H(θ+h,u+h du) - H(θ-h,u-h du)] / (2h) + +Results: + +* Matrix-level agreement +* Trace-level agreement +* Relative errors approximately machine precision + +Representative output: + +rel_Hdot_matrix_err ≈ 1e-10 + +Conclusion: + +The exact directional Hessian propagation is correct. + +⸻ + +6. Exact Trace Validation + +The exact log-determinant contribution + +0.5 tr(Huu⁻¹ Ḣ) + +matched the finite-difference Ḣ calculation exactly. + +Representative result: + +Exact: +7.280645 3.830002 2.748981 -0.073873 0.164400 + +FD: +7.280645 3.830002 2.748981 -0.073873 0.164400 + +Difference: + +~0 + +Conclusion: + +The sparse trace contraction implementation is correct. + +⸻ + +Final Conclusion + +The following components have been independently validated: + +✓ Random-effect optimization + +✓ Huu extraction + +✓ Implicit sensitivities du*/dθ + +✓ Exact directional Hessian propagation + +✓ Sparse trace contraction + +✓ Exact Laplace log-determinant gradient + +The Quadra exact Laplace gradient implementation is considered validated. + +⸻ + +Remaining Observation + +TMB’s reported Laplace gradient contribution differs from the validated profiled finite-difference interpretation. + +Because: + +* objective values agree +* random effects agree +* Hessians agree +* implicit sensitivities agree +* exact Ḣ agrees with finite differences + +the remaining discrepancy is attributable to how TMB internally forms or reports its Laplace gradient contribution rather than an identified defect in Quadra. + +No further action is required for Quadra validation. + +⸻ + +Outcome + +This investigation established end-to-end validation of Quadra’s exact Laplace gradient implementation and closed the primary uncertainty surrounding the exact-gradient machinery. \ No newline at end of file diff --git a/docs/opakapaka_nmfs_reorg_and_huu_diagnostics.md b/docs/opakapaka_nmfs_reorg_and_huu_diagnostics.md new file mode 100644 index 0000000..7a1a860 --- /dev/null +++ b/docs/opakapaka_nmfs_reorg_and_huu_diagnostics.md @@ -0,0 +1,142 @@ +# Opakapaka NMFS Reorganization and Huu Diagnostic Cleanup + +## Status + +Completed: June 2026 + +The PIFSC Opakapaka assessment-style example was moved under the NMFS +assessment examples directory and its final random-effect Hessian diagnostics +were corrected. + +## Directory Reorganization + +The Opakapaka example was moved from: + +```text +examples/pifsc_opakapaka +``` + +to: + +```text +examples/NMFS/pifsc_opakapaka +``` + +This keeps fisheries assessment applications separate from smaller framework +examples. + +The NMFS examples directory now contains assessment-oriented examples such as: + +```text +examples/NMFS/sefsc_red_snapper +examples/NMFS/pifsc_opakapaka +``` + +## Build Path Updates + +After the move, relative include paths were updated because the example is now +one directory deeper. + +For example, includes of the form: + +```cpp +#include "../../../core/..." +``` + +were updated to: + +```cpp +#include "../../../../core/..." +``` + +The Opakapaka executable is built from: + +```bash +clang++ -std=c++17 -g -I"external/eigen/" \ + examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection.cpp \ + examples/NMFS/pifsc_opakapaka/quadra/opakapaka_adgraph_global.cpp \ + -o build/examples/pifsc_opakapaka +``` + +## Diagnostic Issue + +After the move, the example built and ran, but the optimizer structure report +showed stale metadata: + +```text +random effects 0 +pattern available no +detected structure unknown +Hessian nonzeros 0 +``` + +This was inconsistent with the actual Laplace evaluation, which reported: + +```text +Quadra: Discovering Hessian pattern from AD graph for 20 random variables ... +Quadra: Model structure aware now => Hessian pattern has 58 entries. +``` + +## Root Cause + +The Opakapaka example can fall back to a local safeguarded one-dimensional +`log_q` polish after an L-BFGS line-search stall. That fallback returned a valid +fit and valid random effects, but it did not preserve the optimizer pattern +metadata in `fit.pattern`. + +As a result, the final report was reading stale metadata even though the fitted +random-effect vector was present. + +## Fix + +The example now reconstructs the final random-effect Hessian after fitting: + +```cpp +const Eigen::SparseMatrix Huu_final = + compute_final_random_effect_hessian(model, params, opts, fit); +``` + +That final Hessian is reused for: + +- optimizer structure diagnostics +- Hessian nonzero reporting +- random-effect uncertainty output + +This avoids relying on stale `fit.pattern` metadata when the fallback path was +used. + +## Validation + +After the fix, the Opakapaka example reported: + +```text +random effects 20 +pattern available yes +detected structure sparse +Laplace backend final Huu reconstruction +random solver Laplace mode solve +Hessian nonzeros 58 +``` + +The example also completed the fit and projection workflow and wrote outputs to: + +```text +examples/NMFS/pifsc_opakapaka/outputs +``` + +## Remaining Note + +The example still uses a local safeguarded `log_q` fallback after an L-BFGS +line-search stall: + +```text +L-BFGS line-search stall detected in Opakapaka example. +Using local safeguarded one-dimensional log_q fallback. +``` + +This is an optimizer robustness issue, not a structural diagnostics or +uncertainty-reporting issue. The final polished fit reports a near-zero gradient +and coherent output. + +Future work can replace the local fallback with a more general optimizer +robustness improvement. diff --git a/docs/quadra_functional_analysis.md b/docs/quadra_functional_analysis.md new file mode 100644 index 0000000..9217408 --- /dev/null +++ b/docs/quadra_functional_analysis.md @@ -0,0 +1,67 @@ +# Quadra Functional Analysis v1 + +## Purpose + +Quadra Functional Analysis v1 summarizes whether a mixed-effects model fit is +numerically healthy, how its latent-state curvature is structured, and whether +the apparent Hessian complexity is truly global or effectively local. + +## v1 Diagnostics + +- Model health assessment +- Optimization diagnostics +- Curvature diagnostics +- Spectral structure +- Huu structure +- Effective sparsity +- Effective bandwidth +- Uncertainty summary +- Parameter influence +- Parameter geometry +- Correlation graph topology +- Latent state summary +- Markdown and CSV reporting + +## Pollock Showcase + +The synthetic AFSC walleye-pollock-style example currently generates: + +- `examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_analysis.md` +- `examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.txt` +- `examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.csv` +- `examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.txt` +- `examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.csv` + +### Key Pollock Message + +The Pollock example demonstrates why functional analysis is more informative +than symbolic sparsity alone: the random-effect Hessian can appear structurally +dense while the numerical curvature and uncertainty graph reveal local, +AR(1)-style dependence. + +## Modernization Targets + +### Opakapaka + +Scaffolded at: + +`examples/NMFS/pifsc_opakapaka` + +Next target: wire the existing Quadra driver to the Functional Analysis v1 +report API and generate a matching markdown report. + +### Red Snapper + +Scaffolded at: + +`examples/NMFS/sefsc_red_snapper` + +Next target: wire the existing Quadra driver to the Functional Analysis v1 +report API and generate a matching markdown report. + +## Meeting Talking Point + +Functional Analysis v1 turns raw optimizer output into a reusable model-review +artifact. Instead of only reporting objective values and convergence codes, it +summarizes optimization health, curvature health, latent-state structure, +uncertainty topology, and numerical compressibility. diff --git a/docs/validation/science-center-example-roadmap.md b/docs/validation/science-center-example-roadmap.md new file mode 100644 index 0000000..5382da1 --- /dev/null +++ b/docs/validation/science-center-example-roadmap.md @@ -0,0 +1,57 @@ +# Science Center Example Validation Roadmap + +This document tracks a proposed validation suite with one representative assessment-style example from each NOAA Fisheries Science Center. + +The goal is to build examples that are: +- public-data-safe or synthetic, +- reproducible, +- paired with TMB reference implementations where practical, +- documented with expected outputs, +- capable of reporting uncertainty, derived quantities, and projections. + +## Proposed example set + +| Science Center | Example | Status | Main validation target | +|---|---|---:|---| +| PIFSC | Opakapaka projection example | In progress | Projection validation and Level-1 uncertainty reporting | +| SEFSC | Red-snapper-style age-structured model | Scaffolded | Age structure, selectivity, recruitment deviations, projections | +| NEFSC | Groundfish/index-heavy assessment | Planned | Multiple indices, survey likelihoods, retrospective-style diagnostics | +| NWFSC | West Coast age-structured model | Planned | Age composition, selectivity, biological reference points | +| AFSC | Pollock/sablefish-style model | Planned | Recruitment deviations, state-space/random-effect scalability | +| SWFSC | CPS/tuna-style model | Planned | Time-varying dynamics, index scaling, projection scenarios | + +## Shared validation requirements + +Each example should eventually include: + +1. Quadra implementation +2. TMB comparison implementation +3. synthetic or public-data-safe input data +4. reproducible runner +5. fit diagnostics +6. standard errors and confidence intervals +7. random-effect conditional uncertainty +8. derived quantity uncertainty +9. projection envelopes +10. comparison summary against TMB + +## Recommended directory layout + +```text +examples// + README.md + data/ + quadra/ + tmb/ + outputs/ + validation/ +``` + +## Development order + +1. Finish Opakapaka Level-1 uncertainty reporting. +2. Scaffold SEFSC red-snapper-style age-structured model. +3. Add minimal Quadra implementation. +4. Add TMB reference implementation. +5. Add validation summary and uncertainty outputs. +6. Repeat for the remaining science centers. diff --git a/examples/NMFS/README.md b/examples/NMFS/README.md new file mode 100644 index 0000000..9de236b --- /dev/null +++ b/examples/NMFS/README.md @@ -0,0 +1,49 @@ +# NMFS Assessment Examples + +This directory contains fisheries stock assessment examples implemented with +Quadra. + +These examples are application-oriented and are separated from smaller framework +examples so that the repository clearly distinguishes between: + +- core Quadra demonstrations +- fisheries assessment model applications +- validation and comparison studies + +## Current examples + +### SEFSC Red Snapper + +Path: + +```text +examples/NMFS/sefsc_red_snapper +``` + +This example includes: + +- age-structured population dynamics +- recruitment deviations as random effects +- Laplace approximation +- exact gradient validation +- comparison against a TMB implementation + +The Red Snapper example is currently treated as a completed validation model for +Quadra's exact Laplace machinery. + +### PIFSC Opakapaka + +Path: + +```text +examples/NMFS/pifsc_opakapaka +``` + +This example includes: + +- Pacific Islands assessment-style projection workflow +- synthetic data input +- uncertainty reporting +- derived quantities +- projection uncertainty outputs +- comparison against a TMB implementation diff --git a/examples/NMFS/afsc_walleye_pollock/README.md b/examples/NMFS/afsc_walleye_pollock/README.md new file mode 100644 index 0000000..ec61865 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/README.md @@ -0,0 +1,10 @@ +# Synthetic AFSC Walleye Pollock-Style Example + +Synthetic, public-data-safe Quadra example inspired by Alaska walleye pollock +assessment structure. This is not an official assessment. + +Initial scope: +- catch, index, and age-composition observations +- 5 fixed effects +- recruitment deviations as random effects +- Laplace fit through Quadra diff --git a/examples/NMFS/afsc_walleye_pollock/data/pollock_data.hpp b/examples/NMFS/afsc_walleye_pollock/data/pollock_data.hpp new file mode 100644 index 0000000..ce520c8 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/data/pollock_data.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include + +struct Obs { + int year; + double catch_mt; + double index; + std::vector age; +}; + +namespace pollock_example { + +struct PollockDataRow { + int year = 0; + double index = 0.0; + double catch_obs = 0.0; +}; + +struct PollockData { + std::vector rows; +}; + +inline PollockData load_pollock_synthetic_data(const std::string &path) { + PollockData data; + std::ifstream in(path); + if (!in) + throw std::runtime_error("Could not open Pollock data file: " + path); + + std::string line; + std::getline(in, line); + + while (std::getline(in, line)) { + if (line.empty()) + continue; + std::stringstream ss(line); + std::string item; + PollockDataRow row; + + std::getline(ss, item, ','); + row.year = std::stoi(item); + std::getline(ss, item, ','); + row.index = std::stod(item); + std::getline(ss, item, ','); + row.catch_obs = std::stod(item); + + data.rows.push_back(row); + } + return data; +} + +} // namespace pollock_example diff --git a/examples/NMFS/afsc_walleye_pollock/data/pollock_io.hpp b/examples/NMFS/afsc_walleye_pollock/data/pollock_io.hpp new file mode 100644 index 0000000..127b389 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/data/pollock_io.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "pollock_data.hpp" + +#include +#include +#include +#include +#include + +namespace pollock_example { + +std::vector split(const std::string &line) { + std::vector out; + std::stringstream ss(line); + std::string x; + while (std::getline(ss, x, ',')) + out.push_back(x); + return out; +} + +std::vector read_obs(const std::string &path) { + std::ifstream in(path); + if (!in) + throw std::runtime_error("could not open " + path); + std::string line; + std::getline(in, line); + std::vector rows; + while (std::getline(in, line)) { + if (line.empty()) + continue; + auto f = split(line); + Obs o{std::stoi(f[0]), std::stod(f[1]), std::stod(f[2]), {}}; + for (std::size_t i = 3; i < f.size(); ++i) + o.age.push_back(std::stod(f[i])); + rows.push_back(o); + } + return rows; +} + +} // namespace pollock_example + +// Compatibility aliases for current walleye_pollock.cpp call sites. +using pollock_example::read_obs; +using pollock_example::split; diff --git a/examples/NMFS/afsc_walleye_pollock/data/synthetic_walleye_pollock_observations.csv b/examples/NMFS/afsc_walleye_pollock/data/synthetic_walleye_pollock_observations.csv new file mode 100644 index 0000000..d8ff28c --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/data/synthetic_walleye_pollock_observations.csv @@ -0,0 +1,21 @@ +year,catch_mt,index,age1,age2,age3,age4,age5,age6,age7 +1,620,0.82,0.08,0.18,0.24,0.22,0.15,0.08,0.05 +2,645,0.86,0.10,0.17,0.23,0.21,0.16,0.08,0.05 +3,670,0.91,0.11,0.19,0.22,0.20,0.15,0.08,0.05 +4,710,0.95,0.09,0.18,0.25,0.21,0.14,0.08,0.05 +5,760,1.01,0.08,0.16,0.24,0.23,0.16,0.08,0.05 +6,805,1.08,0.12,0.18,0.22,0.21,0.15,0.07,0.05 +7,850,1.13,0.13,0.20,0.22,0.19,0.14,0.07,0.05 +8,875,1.17,0.10,0.19,0.24,0.20,0.14,0.08,0.05 +9,900,1.22,0.09,0.18,0.24,0.21,0.15,0.08,0.05 +10,925,1.26,0.11,0.18,0.23,0.20,0.15,0.08,0.05 +11,940,1.30,0.10,0.17,0.23,0.22,0.15,0.08,0.05 +12,965,1.33,0.09,0.17,0.24,0.22,0.15,0.08,0.05 +13,990,1.36,0.12,0.19,0.22,0.20,0.14,0.08,0.05 +14,1015,1.39,0.13,0.20,0.22,0.19,0.14,0.07,0.05 +15,1030,1.42,0.11,0.19,0.23,0.20,0.14,0.08,0.05 +16,1045,1.45,0.10,0.18,0.24,0.21,0.14,0.08,0.05 +17,1060,1.47,0.09,0.17,0.24,0.22,0.15,0.08,0.05 +18,1075,1.49,0.11,0.18,0.23,0.21,0.14,0.08,0.05 +19,1085,1.50,0.12,0.19,0.22,0.20,0.14,0.08,0.05 +20,1095,1.52,0.10,0.18,0.24,0.21,0.14,0.08,0.05 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/random_effect_scaling_summary.csv b/examples/NMFS/afsc_walleye_pollock/outputs/random_effect_scaling_summary.csv new file mode 100644 index 0000000..6d2eab0 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/random_effect_scaling_summary.csv @@ -0,0 +1,7 @@ +random_effects,exit_code,objective,grad_norm,converged,max_grad_param,max_grad_value,max_abs_grad,message +0,0,4.50715427008526,0.000390386819284328,yes,log_r0,-0.000287929677760686,0.00028793,converged to requested fixed-effect gradient tolerance +1,0,3.60648055858289,0.0051618556189938,yes,log_r0,-0.00513839544761058,0.0051384,converged to requested fixed-effect gradient tolerance +2,0,2.71970593968494,0.00911009377293731,yes,log_r0,-0.00910888327532164,0.00910888,converged to requested fixed-effect gradient tolerance +5,0,0.0758352782119642,0.0049615019647371,yes,log_r0,-0.00496143705959104,0.00496144,converged to requested fixed-effect gradient tolerance +10,0,-4.75367184554796,0.00476630837072591,yes,log_r0,0.00476517435115792,0.00476517,converged to requested fixed-effect gradient tolerance +20,0,-14.3868374903643,0.00522172190975378,yes,log_r0,0.00522172081699299,0.00522172,converged to requested fixed-effect gradient tolerance diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_analysis.md b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_analysis.md new file mode 100644 index 0000000..61ca6fe --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_analysis.md @@ -0,0 +1,143 @@ +# Synthetic Walleye Pollock Functional Analysis + +Synthetic and public-data-safe. Not an official assessment. + +## Executive Summary + +- **Overall status:** `HEALTHY`. +- **Confidence:** `HIGH`. +- **Optimization quality:** `EXCELLENT`. +- **Uncertainty structure:** `LOCAL`. +- **Optimization:** converged = `yes`, gradient norm = `5.19827398754203e-06`. +- **Curvature health:** positive definite = `yes`, condition number = `10.9840248198155`. +- **Latent structure:** `20` random effects were estimated. +- **Symbolic vs numerical structure:** structural density = `0.91`, but 95% of curvature is retained by `58` entries. +- **Spectral complexity:** entropy effective rank = `16.3107626636197`, with 90% curvature requiring `14` eigen-directions. + +## Model Health Assessment + +| Check | Status | Evidence | +|---|---:|---| +| Optimization | `PASS` | converged = `yes` | +| Gradient quality | `PASS` | gradient norm = `5.19827398754203e-06` | +| Curvature | `PASS` | positive definite = `yes` | +| Conditioning | `EXCELLENT` | condition number = `10.9840248198155` | +| Overall status | `HEALTHY` | rule-based v1 diagnostic | +| Confidence | `HIGH` | based on convergence, gradient, PD status, and conditioning | + +**Interpretation:** the rule-based health check is intentionally simple. It flags obvious numerical issues quickly, but it does not replace scientific review or model-specific diagnostics. + +## Model Complexity + +| Quantity | Value | +|---|---:| +| Fixed effects | `2` | +| Random effects | `20` | +| Total estimated quantities | `22` | +| Structural nonzeros | `364` | +| Structural density | `0.91` | +| Entries for 95% curvature | `58` | +| Effective bandwidth for 95% curvature | `1` | +| 95% curvature compression | `6.27586x` | + +## Optimization + +- Quality: `EXCELLENT` +- Objective value: `-14.3868675854755` +- Gradient norm: `5.19827398754203e-06` +- Converged: `yes` +- Max gradient parameter: `log_r0` + +## Curvature + +- Positive definite: `yes` +- Condition number: `10.9840248198155` +- Minimum eigenvalue: `10.2183884027573` +- Maximum eigenvalue: `112.239031834401` + +## Spectral Structure + +- Largest eigenvalue share: `0.095050325395682` +- Entropy effective rank: `16.3107626636197` +- Eigenvectors needed for 90% curvature: `14` +- Eigenvectors needed for 95% curvature: `16` + +**Interpretation:** curvature is distributed across many latent-state directions rather than being dominated by one or two modes. That is a good sign for numerical stability. + +## Effective Structure + +- Structural density: `0.91` +- Structural nonzeros: `364` +- Entries for 95% curvature: `58` +- Effective bandwidth for 95% curvature: `1` +- 95% curvature compression: `6.27586x` + +**Interpretation:** symbolic density alone overstates practical complexity. The detailed Laplace report below shows that large amounts of curvature can be retained with far fewer entries or a narrow effective bandwidth. + +## Correlation Graph + +- Classification: `LOCAL` +- Average degree: `1.5` +- Maximum degree: `2` +- Connected components: `5` +- Largest component size: `14` +- Graph diameter: `13` + +**Interpretation:** a LOCAL graph means the strongest uncertainty relationships are neighborhood-like rather than globally tangled. + +## Latent State Summary + +- Count: `20` +- Mean: `0.0824010336165072` +- Standard deviation: `0.0500130055529492` + +## Key Takeaway + +This report demonstrates why Quadra's functional analysis diagnostics are useful: a model can look dense from a symbolic Hessian pattern, while numerical curvature, graph structure, and effective bandwidth reveal a simpler local-dependence structure. + +## Full Laplace Structure Report + +```text +Laplace Structure Report +======================== + +Random effects: 20 +Matrix size: 20 x 20 +Total entries: 400 +Structural nonzeros: 364 / 400 (91%) +Nonzero tolerance: 1e-08 +Max |H_ij|: 61.5960537686533 +Positive definite: yes +Min eigenvalue: 10.2183884027573 +Max eigenvalue: 112.239031834401 +Condition number: 10.9840248198155 + +Effective sparsity +------------------ +curvature_retained,entries_required,entry_share,compression_vs_structural +90%,54,0.135,6.74074074074074 +95%,58,0.145,6.27586206896552 +97%,100,0.25,3.64 +98%,133,0.3325,2.73684210526316 +99%,183,0.4575,1.98907103825137 +99.5%,224,0.56,1.625 +99.9%,293,0.7325,1.24232081911263 +100%,364,0.91,1 + +Effective bandwidth +------------------- +curvature_retained,bandwidth,entry_count_if_banded,entry_share_if_banded +90%,1,58,0.145 +95%,1,58,0.145 +97%,2,94,0.235 +98%,3,128,0.32 +99%,5,190,0.475 +99.5%,7,244,0.61 +99.9%,10,310,0.775 +100%,19,400,1 + +Interpretation +-------------- +This report measures numerical curvature concentration, not only symbolic sparsity. +A dense structural Hessian can still be effectively sparse if most curvature is carried by relatively few entries or bands. +``` diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fit_summary.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fit_summary.csv new file mode 100644 index 0000000..9a18e58 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fit_summary.csv @@ -0,0 +1,7 @@ +field,value +objective,-14.3868675854755 +grad_norm,5.19827398754203e-06 +iterations,17 +converged,yes +message,converged to requested fixed-effect gradient tolerance +random_effects,20 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_gradient_diagnostics.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_gradient_diagnostics.csv new file mode 100644 index 0000000..2d01b77 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_gradient_diagnostics.csv @@ -0,0 +1,3 @@ +parameter,gradient,abs_gradient +log_r0,5.11138687136689e-06,5.11138687136689e-06 +log_fbar,-9.46454806514431e-07,9.46454806514431e-07 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_diagnostics.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_diagnostics.csv new file mode 100644 index 0000000..b39c3b5 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_diagnostics.csv @@ -0,0 +1,13 @@ +field,value +fixed_effects,2 +available,yes +fd_step,0.0001 +profile_objective,-14.386867585475 +min_diagonal,0.271912625748882 +max_diagonal,134.948869856544 +eigen_success,yes +min_eigenvalue,0.258341875865381 +max_eigenvalue,134.962440606427 +positive_definite,yes +condition_number_abs,522.417978712652 +eigenvalues,0.258341875865381;134.962440606427 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_matrix.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_matrix.csv new file mode 100644 index 0000000..59ae8b1 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_matrix.csv @@ -0,0 +1,3 @@ +parameter,log_r0,log_fbar +log_r0,134.948869856544,-1.35198057193975 +log_fbar,-1.35198057193975,0.271912625748882 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_parameter_estimates.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_parameter_estimates.csv new file mode 100644 index 0000000..49b335b --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_parameter_estimates.csv @@ -0,0 +1,3 @@ +parameter,estimate,exp_estimate +log_r0,7.40479763896654,1643.85215079671 +log_fbar,-5.06778215474103,0.00629636903905652 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.csv new file mode 100644 index 0000000..9191b88 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.csv @@ -0,0 +1,106 @@ +section,metric,target,value,extra +optimization,objective_value,,-14.3868675854755, +optimization,gradient_norm,,5.19827398754203e-06, +optimization,max_gradient_parameter,,log_r0, +optimization,max_gradient_value,,5.11138687136689e-06, +optimization,max_abs_gradient,,5.11138687136689e-06, +optimization,iterations,,17, +optimization,converged,,yes, +optimization,message,,converged to requested fixed-effect gradient tolerance, +curvature,positive_definite,,yes, +curvature,min_eigenvalue,,10.2183884027573, +curvature,max_eigenvalue,,112.239031834401, +curvature,condition_number_abs,,10.9840248198155, +spectral_structure,available,,yes, +spectral_structure,largest_eigen_share,,0.095050325395682, +spectral_structure,effective_rank_entropy,,16.3107626636197, +spectral_structure,eigen_count_for_50%,,6, +spectral_structure,eigen_count_for_90%,,14, +spectral_structure,eigen_count_for_95%,,16, +spectral_structure,eigen_count_for_99%,,19, +huu_structure,random_effects,,20, +huu_structure,total_entries,,400, +huu_structure,structural_nonzeros,,364, +huu_structure,structural_density,,0.91, +effective_sparsity,entries_required,90%,54,compression_vs_structural=6.74074074074074 +effective_sparsity,entries_required,95%,58,compression_vs_structural=6.27586206896552 +effective_sparsity,entries_required,97%,100,compression_vs_structural=3.64 +effective_sparsity,entries_required,98%,133,compression_vs_structural=2.73684210526316 +effective_sparsity,entries_required,99%,183,compression_vs_structural=1.98907103825137 +effective_sparsity,entries_required,99.5%,224,compression_vs_structural=1.625 +effective_sparsity,entries_required,99.9%,293,compression_vs_structural=1.24232081911263 +effective_sparsity,entries_required,100%,364,compression_vs_structural=1 +effective_bandwidth,bandwidth,90%,1,entry_count_if_banded=58 +effective_bandwidth,bandwidth,95%,1,entry_count_if_banded=58 +effective_bandwidth,bandwidth,97%,2,entry_count_if_banded=94 +effective_bandwidth,bandwidth,98%,3,entry_count_if_banded=128 +effective_bandwidth,bandwidth,99%,5,entry_count_if_banded=190 +effective_bandwidth,bandwidth,99.5%,7,entry_count_if_banded=244 +effective_bandwidth,bandwidth,99.9%,10,entry_count_if_banded=310 +effective_bandwidth,bandwidth,100%,19,entry_count_if_banded=400 +uncertainty,covariance_available,,yes, +uncertainty,correlation_available,,yes, +uncertainty,min_variance,,0.027865965883578,index=3 +uncertainty,max_variance,,0.0350480485905882,index=19 +uncertainty,max_abs_correlation,,0.598351221654447,pair=18;19 +uncertainty,count_abs_corr_gt_0_5,,15, +uncertainty,count_abs_corr_gt_0_8,,0, +uncertainty,count_abs_corr_gt_0_9,,0, +parameter_influence,importance,log_rec_dev_17,44.5488428332677,index=16;sd=0.183360328347728;variance=0.0336210100117867;variance_share=0.0554843153726583;correlation_centrality=2.42549081701974;curvature_column_norm=70.9264530813492;importance_share=0.0609918078147027 +parameter_influence,importance,log_rec_dev_16,44.2615446289814,index=15;sd=0.180556501484316;variance=0.0326006502282559;variance_share=0.0538004288980058;correlation_centrality=2.46325967176437;curvature_column_norm=70.7829044051472;importance_share=0.0605984679264609 +parameter_influence,importance,log_rec_dev_18,43.2618345586047,index=17;sd=0.185464578784088;variance=0.0343971099835591;variance_share=0.0567651030580797;correlation_centrality=2.28245716879513;curvature_column_norm=71.0632207342914;importance_share=0.0592297651587808 +parameter_influence,importance,log_rec_dev_15,42.6803069211919,index=14;sd=0.177501684946827;variance=0.0315068481589625;variance_share=0.0519953415747319;correlation_centrality=2.40347164485272;curvature_column_norm=70.6484954006843;importance_share=0.0584335958389004 +parameter_influence,importance,log_rec_dev_14,40.7022095683089,index=13;sd=0.17471439426877;variance=0.0305251195647032;variance_share=0.050375207649096;correlation_centrality=2.30268400109302;curvature_column_norm=70.5378594805887;importance_share=0.0557253833262352 +parameter_influence,importance,log_rec_dev_19,39.5757904308082,index=18;sd=0.186696793808497;variance=0.0348556928183725;variance_share=0.0575218963436578;correlation_centrality=1.97827497081705;curvature_column_norm=71.1750770735403;importance_share=0.0541832032114705 +parameter_influence,importance,log_rec_dev_13,38.3629205908768,index=12;sd=0.172552949412713;variance=0.0297745203510261;variance_share=0.0491365035329637;correlation_centrality=2.15515537253594;curvature_column_norm=70.4641999522685;importance_share=0.0525226634650575 +parameter_influence,importance,log_rec_dev_12,36.954537302952,index=11;sd=0.171102186326852;variance=0.0292759581658288;variance_share=0.0483137328456264;correlation_centrality=2.06650457767272;curvature_column_norm=70.4317683832589;importance_share=0.0505944463136481 +parameter_influence,importance,log_rec_dev_10,36.9349887676045,index=9;sd=0.169560165113695;variance=0.0287506495933834;variance_share=0.0474468229434163;correlation_centrality=2.09114199974086;curvature_column_norm=70.4685255303029;importance_share=0.0505676824195682 +parameter_influence,importance,log_rec_dev_9,36.9235537937839,index=8;sd=0.169053168481611;variance=0.0285789737736718;variance_share=0.0471635085718553;correlation_centrality=2.09710336077563;curvature_column_norm=70.5219771472488;importance_share=0.0505520268002237 +parameter_influence,importance,log_rec_dev_11,36.7110052728577,index=10;sd=0.170182113018083;variance=0.0289619515912994;variance_share=0.047795531881284;correlation_centrality=2.06255374123372;curvature_column_norm=70.4366426325561;importance_share=0.050261026681811 +parameter_influence,importance,log_rec_dev_8,36.5579094790204,index=7;sd=0.168555129685161;variance=0.0284108317431813;variance_share=0.0468860259666669;correlation_centrality=2.0723774999475;curvature_column_norm=70.5935143908074;importance_share=0.0500514232748289 +parameter_influence,importance,log_rec_dev_7,35.8623310634345,index=6;sd=0.1680214128777;variance=0.0282311951854186;variance_share=0.0465895740926789;correlation_centrality=2.0195748276892;curvature_column_norm=70.6851341925052;importance_share=0.0490991070675987 +parameter_influence,importance,log_rec_dev_4,35.2815189643711,index=3;sd=0.166931021333897;variance=0.027865965883578;variance_share=0.0459868409279241;correlation_centrality=1.97256613521238;curvature_column_norm=71.1014893750246;importance_share=0.0483039173910652 +parameter_influence,importance,log_rec_dev_3,35.1733312007332,index=2;sd=0.167672150352856;variance=0.0281139500039506;variance_share=0.0463960858952032;correlation_centrality=1.9416127474987;curvature_column_norm=71.3127309936786;importance_share=0.0481557975552167 +parameter_influence,importance,log_rec_dev_6,35.0391789469444,index=5;sd=0.167477586740294;variance=0.0280487420603527;variance_share=0.0462884740743174;correlation_centrality=1.95520531014378;curvature_column_norm=70.7961381811328;importance_share=0.047972129743426 +parameter_influence,importance,log_rec_dev_5,34.5564405955384,index=4;sd=0.167030715015572;variance=0.0278992597586132;variance_share=0.0460417853767012;correlation_centrality=1.91673643224868;curvature_column_norm=70.9309009284492;importance_share=0.0473112128063926 +parameter_influence,importance,log_rec_dev_2,33.2137603001327,index=1;sd=0.170015622385115;variance=0.0289053118549979;variance_share=0.0477020600614166;correlation_centrality=1.72998327242739;curvature_column_norm=71.55981868112;importance_share=0.0454729496030033 +parameter_influence,importance,log_rec_dev_20,23.5210608030845,index=19;sd=0.187211240556191;variance=0.0350480485905882;variance_share=0.0578393385717653;correlation_centrality=1.42403028275588;curvature_column_norm=51.8306842643709;importance_share=0.0322026775301188 +parameter_influence,importance,log_rec_dev_1,20.2839251690113,index=0;sd=0.17488116356916;variance=0.0305834213713032;variance_share=0.0504714223619514;correlation_centrality=1.20266181741194;curvature_column_norm=52.6576098027071;importance_share=0.0277707160714908 +parameter_influence,correlation_pair,log_rec_dev_19__log_rec_dev_20,0.598351221654447,i=18;j=19;abs_correlation=0.598351221654447 +parameter_influence,correlation_pair,log_rec_dev_18__log_rec_dev_19,0.594581208456059,i=17;j=18;abs_correlation=0.594581208456059 +parameter_influence,correlation_pair,log_rec_dev_17__log_rec_dev_18,0.586512046951335,i=16;j=17;abs_correlation=0.586512046951335 +parameter_influence,correlation_pair,log_rec_dev_16__log_rec_dev_17,0.573942598352128,i=15;j=16;abs_correlation=0.573942598352128 +parameter_influence,correlation_pair,log_rec_dev_15__log_rec_dev_16,0.558210612518368,i=14;j=15;abs_correlation=0.558210612518368 +parameter_influence,correlation_pair,log_rec_dev_14__log_rec_dev_15,0.541906091575975,i=13;j=14;abs_correlation=0.541906091575975 +parameter_influence,correlation_pair,log_rec_dev_1__log_rec_dev_2,0.534149026853994,i=0;j=1;abs_correlation=0.534149026853994 +parameter_influence,correlation_pair,log_rec_dev_13__log_rec_dev_14,0.527798397745733,i=12;j=13;abs_correlation=0.527798397745733 +parameter_influence,correlation_pair,log_rec_dev_12__log_rec_dev_13,0.517641393962613,i=11;j=12;abs_correlation=0.517641393962613 +parameter_influence,correlation_pair,log_rec_dev_11__log_rec_dev_12,0.511255943807131,i=10;j=11;abs_correlation=0.511255943807131 +correlation_graph,abs_correlation_threshold,,0.5, +correlation_graph,node_count,,20, +correlation_graph,edge_count,,15, +correlation_graph,average_degree,,1.5, +correlation_graph,maximum_degree,,2,parameter=log_rec_dev_2 +correlation_graph,connected_components,,5, +correlation_graph,largest_component_size,,14, +correlation_graph,graph_diameter,,13, +parameter_geometry,available,,yes, +parameter_geometry,dominant_parameter,,log_r0,index=0;curvature_column_norm=221.843719224663 +parameter_geometry,curvature_column_norm,log_r0,221.843719224663,index=0;gradient=5.11138687136689e-06;abs_gradient=5.11138687136689e-06;curvature_diagonal=221.827904700423;curvature_share=0.988135761774757 +parameter_geometry,curvature_column_norm,log_fbar,2.66360841847077,index=1;gradient=-9.46454806514431e-07;abs_gradient=9.46454806514431e-07;curvature_diagonal=0.279918123434981;curvature_share=0.0118642382252428 +gradient_volatility,available,,no, +gradient_volatility,perturbation_scale,,0, +gradient_volatility,samples,,0, +gradient_volatility,baseline_gradient_norm,,nan, +gradient_volatility,mean_gradient_norm,,nan, +gradient_volatility,sd_gradient_norm,,nan, +gradient_volatility,max_gradient_norm,,nan, +gradient_volatility,gradient_norm_cv,,nan, +gradient_volatility,most_volatile_parameter,,,sd=nan +gradient_volatility,most_sign_flips_parameter,,,sign_flips=0 +latent_states,count,,20, +latent_states,mean,,0.0824010336165072, +latent_states,sd,,0.0500130055529492, +latent_states,min_value,,-0.00979634350742143,index=0 +latent_states,max_value,,0.145654153363153,index=10 +latent_states,l2_norm,,0.431073800305889, diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.txt b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.txt new file mode 100644 index 0000000..d94fddb --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_functional_analysis_report.txt @@ -0,0 +1,165 @@ +Functional Analysis Report +========================== + +Optimization +------------ +objective_value: -14.3868675854755 +gradient_norm: 5.19827398754203e-06 +max_gradient_parameter: log_r0 +max_gradient_value: 5.11138687136689e-06 +max_abs_gradient: 5.11138687136689e-06 +iterations: 17 +converged: yes +message: converged to requested fixed-effect gradient tolerance + +Curvature +--------- +positive_definite: yes +min_eigenvalue: 10.2183884027573 +max_eigenvalue: 112.239031834401 +condition_number_abs: 10.9840248198155 + +Spectral Structure +------------------ +available: yes +eigen_count: 20 +largest_eigen_share: 0.095050325395682 +effective_rank_entropy: 16.3107626636197 +eigen_count_for_50%: 6 +eigen_count_for_90%: 14 +eigen_count_for_95%: 16 +eigen_count_for_99%: 19 + +Huu Structure +------------- +random_effects: 20 +total_entries: 400 +structural_nonzeros: 364 +structural_density: 0.91 + +Effective Sparsity +------------------ +curvature_retained,entries_required,entry_share,compression_vs_structural +90%,54,0.135,6.74074074074074 +95%,58,0.145,6.27586206896552 +97%,100,0.25,3.64 +98%,133,0.3325,2.73684210526316 +99%,183,0.4575,1.98907103825137 +99.5%,224,0.56,1.625 +99.9%,293,0.7325,1.24232081911263 +100%,364,0.91,1 + +Effective Bandwidth +------------------- +curvature_retained,bandwidth,entry_count_if_banded,entry_share_if_banded +90%,1,58,0.145 +95%,1,58,0.145 +97%,2,94,0.235 +98%,3,128,0.32 +99%,5,190,0.475 +99.5%,7,244,0.61 +99.9%,10,310,0.775 +100%,19,400,1 + +Uncertainty +----------- +covariance_available: yes +correlation_available: yes +covariance_size: 20 x 20 +min_variance: 0.027865965883578 +max_variance: 0.0350480485905882 +min_variance_index: 3 +max_variance_index: 19 +max_abs_correlation: 0.598351221654447 +max_abs_correlation_pair: 18,19 +count_abs_corr_gt_0_5: 15 +count_abs_corr_gt_0_8: 0 +count_abs_corr_gt_0_9: 0 + +Parameter Influence +------------------- +available: yes +Top parameter importance +index,name,variance,sd,variance_share,correlation_centrality,correlation_centrality_share,curvature_column_norm,curvature_diagonal,importance_score,importance_share +16,log_rec_dev_17,0.0336210100117867,0.183360328347728,0.0554843153726583,2.42549081701974,0.0597958742305817,70.9264530813492,60.2491752488277,44.5488428332677,0.0609918078147027 +15,log_rec_dev_16,0.0326006502282559,0.180556501484316,0.0538004288980058,2.46325967176437,0.0607269936857845,70.7829044051472,60.2195150634088,44.2615446289814,0.0605984679264609 +17,log_rec_dev_18,0.0343971099835591,0.185464578784088,0.0567651030580797,2.28245716879513,0.0562696510101247,71.0632207342914,60.3067960014414,43.2618345586047,0.0592297651587808 +14,log_rec_dev_15,0.0315068481589625,0.177501684946827,0.0519953415747319,2.40347164485272,0.0592530333175912,70.6484954006843,60.2148446660067,42.6803069211919,0.0584335958389004 +13,log_rec_dev_14,0.0305251195647032,0.17471439426877,0.050375207649096,2.30268400109302,0.0567683051842327,70.5378594805887,60.2278255712463,40.7022095683089,0.0557253833262352 +18,log_rec_dev_19,0.0348556928183725,0.186696793808497,0.0575218963436578,1.97827497081705,0.048770616041265,71.1750770735403,60.3803584908746,39.5757904308082,0.0541832032114705 +12,log_rec_dev_13,0.0297745203510261,0.172552949412713,0.0491365035329637,2.15515537253594,0.0531312667519665,70.4641999522685,60.2539330429863,38.3629205908768,0.0525226634650575 +11,log_rec_dev_12,0.0292759581658288,0.171102186326852,0.0483137328456264,2.06650457767272,0.050945749601011,70.4317683832589,60.2911704561393,36.954537302952,0.0505944463136481 +9,log_rec_dev_10,0.0287506495933834,0.169560165113695,0.0474468229434163,2.09114199974086,0.0515531384977302,70.4685255303029,60.4089798628138,36.9349887676045,0.0505676824195682 +8,log_rec_dev_9,0.0285789737736718,0.169053168481611,0.0471635085718553,2.09710336077563,0.0517001045436028,70.5219771472488,60.4888420241423,36.9235537937839,0.0505520268002237 +10,log_rec_dev_11,0.0289619515912994,0.170182113018083,0.047795531881284,2.06255374123372,0.0508483492244957,70.4366426325561,60.343114682837,36.7110052728577,0.050261026681811 +7,log_rec_dev_8,0.0284108317431813,0.168555129685161,0.0468860259666669,2.0723774999475,0.0510905353570503,70.5935143908074,60.5815138499111,36.5579094790204,0.0500514232748289 +6,log_rec_dev_7,0.0282311951854186,0.1680214128777,0.0465895740926789,2.0195748276892,0.0497887856545816,70.6851341925052,60.689945513559,35.8623310634345,0.0490991070675987 +3,log_rec_dev_4,0.027865965883578,0.166931021333897,0.0459868409279241,1.97256613521238,0.0486298755307569,71.1014893750246,61.1296925967508,35.2815189643711,0.0483039173910652 +2,log_rec_dev_3,0.0281139500039506,0.167672150352856,0.0463960858952032,1.9416127474987,0.0478667784842747,71.3127309936786,61.3438899677021,35.1733312007332,0.0481557975552167 +5,log_rec_dev_6,0.0280487420603527,0.167477586740294,0.0462884740743174,1.95520531014378,0.0482018773272358,70.7961381811328,60.8129042234395,35.0391789469444,0.047972129743426 +4,log_rec_dev_5,0.0278992597586132,0.167030715015572,0.0460417853767012,1.91673643224868,0.047253500129406,70.9309009284492,60.9549204000359,34.5564405955384,0.0473112128063926 +1,log_rec_dev_2,0.0289053118549979,0.170015622385115,0.0477020600614166,1.72998327242739,0.0426494552991894,71.55981868112,61.5960537686533,33.2137603001327,0.0454729496030033 +19,log_rec_dev_20,0.0350480485905882,0.187211240556191,0.0578393385717653,1.42403028275588,0.0351067648208362,51.8306842643709,44.4444452796233,23.5210608030845,0.0322026775301188 +0,log_rec_dev_1,0.0305834213713032,0.17488116356916,0.0504714223619514,1.20266181741194,0.0296493453082828,52.6576098027071,45.9000339958493,20.2839251690113,0.0277707160714908 + +Top correlation pairs +i,j,name_i,name_j,correlation,abs_correlation +18,19,log_rec_dev_19,log_rec_dev_20,0.598351221654447,0.598351221654447 +17,18,log_rec_dev_18,log_rec_dev_19,0.594581208456059,0.594581208456059 +16,17,log_rec_dev_17,log_rec_dev_18,0.586512046951335,0.586512046951335 +15,16,log_rec_dev_16,log_rec_dev_17,0.573942598352128,0.573942598352128 +14,15,log_rec_dev_15,log_rec_dev_16,0.558210612518368,0.558210612518368 +13,14,log_rec_dev_14,log_rec_dev_15,0.541906091575975,0.541906091575975 +0,1,log_rec_dev_1,log_rec_dev_2,0.534149026853994,0.534149026853994 +12,13,log_rec_dev_13,log_rec_dev_14,0.527798397745733,0.527798397745733 +11,12,log_rec_dev_12,log_rec_dev_13,0.517641393962613,0.517641393962613 +10,11,log_rec_dev_11,log_rec_dev_12,0.511255943807131,0.511255943807131 + +Correlation Graph +----------------- +available: yes +abs_correlation_threshold: 0.5 +node_count: 20 +edge_count: 15 +average_degree: 1.5 +maximum_degree: 2 +maximum_degree_parameter: log_rec_dev_2 +connected_components: 5 +largest_component_size: 14 +graph_diameter: 13 + +Parameter Geometry +------------------ +available: yes +dominant_parameter: log_r0 +dominant_parameter_index: 0 +dominant_curvature_norm: 221.843719224663 +index,name,gradient,abs_gradient,curvature_column_norm,curvature_diagonal,curvature_share +0,log_r0,5.11138687136689e-06,5.11138687136689e-06,221.843719224663,221.827904700423,0.988135761774757 +1,log_fbar,-9.46454806514431e-07,9.46454806514431e-07,2.66360841847077,0.279918123434981,0.0118642382252428 + +Gradient Volatility +------------------- +available: no +perturbation_scale: 0 +samples: 0 +baseline_gradient_norm: nan +mean_gradient_norm: nan +sd_gradient_norm: nan +max_gradient_norm: nan +gradient_norm_cv: nan +most_volatile_parameter: +most_volatile_parameter_sd: nan +most_sign_flips_parameter: +most_sign_flips: 0 + +Latent States +------------- +count: 20 +mean: 0.0824010336165072 +sd: 0.0500130055529492 +min_value: -0.00979634350742143 +max_value: 0.145654153363153 +min_index: 0 +max_index: 10 +l2_norm: 0.431073800305889 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_band_summary.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_band_summary.csv new file mode 100644 index 0000000..11a7552 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_band_summary.csv @@ -0,0 +1,21 @@ +band_distance,count,nonzero_count,mean_abs,max_abs,sum_abs,share_sum_abs,cumulative_share_sum_abs +0,20,20,59.0418977353124,61.5960537686533,1180.83795470625,0.684412071133482,0.684412071133482 +1,19,19,25.8913613543062,26.6666667414484,491.935865731818,0.28512535813105,0.969537429264532 +2,18,17,0.717865539851598,1.0034252539981,12.9215797173288,0.00748933001467875,0.977026759279211 +3,17,16,0.628536835650346,0.869116689727889,10.6851262060559,0.00619308460391466,0.983219843883126 +4,16,15,0.523425613963013,0.722001480824019,8.37480982340821,0.0048540283734475,0.988073872256573 +5,15,14,0.418896171083816,0.584393866631672,6.28344256625724,0.00364187475807398,0.991715747014647 +6,14,13,0.325170074993204,0.468466154757152,4.55238104990485,0.00263855385960157,0.994354300874249 +7,13,12,0.247122226955281,0.373790243202166,3.21258895041865,0.00186201218252977,0.996216313056778 +8,12,11,0.187221467958428,0.297308133667684,2.24665761550114,0.00130215969568445,0.997518472752463 +9,11,10,0.140790490377185,0.234597763437705,1.54869539414904,0.00089762174228902,0.998416094494752 +10,10,9,0.104730499828065,0.183132087272497,1.04730499828065,0.000607016551360784,0.999023111046113 +11,9,8,0.0767147530685482,0.140902400858067,0.690432777616934,0.000400173898055978,0.999423284944169 +12,8,7,0.0549894574319865,0.106041220249153,0.439915659455892,0.000254974517385967,0.999678259461555 +13,7,6,0.0382451403879713,0.0769640351450107,0.267715982715799,0.000155167819154015,0.999833427280709 +14,6,5,0.0255303630088595,0.0533015409587279,0.153182178053157,8.87841818805525e-05,0.999922211462589 +15,5,4,0.0160656199454934,0.0344137163210689,0.0803280997274669,4.65580572555097e-05,0.999968769519845 +16,4,3,0.00924638143828815,0.0200055083610096,0.0369855257531526,2.14367603800244e-05,0.999990206280225 +17,3,2,0.00456766476493916,0.0097536201337789,0.0137029942948175,7.94223684009154e-06,0.999998148517065 +18,2,1,0.00159721125214674,0.00319442250429347,0.00319442250429347,1.85148293508466e-06,1 +19,1,0,0,0,0,0,1 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_bandlimit_diagnostic.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_bandlimit_diagnostic.csv new file mode 100644 index 0000000..bc182d0 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_bandlimit_diagnostic.csv @@ -0,0 +1,8 @@ +band_width,kept_entries,total_entries,kept_entry_share,retained_abs_share,relative_frobenius_error,min_eigenvalue,max_eigenvalue,positive_definite,condition_number_abs +0,20,400,0.05,0.520232860241547,0.516650973771845,44.4444452796233,61.5960537686533,yes,1.38591118375131 +1,58,400,0.145,0.953689798960509,0.0258896620352103,8.91212659187872,111.422337813405,yes,12.5023288958821 +2,94,400,0.235,0.965075324550017,0.0207918921861865,9.66811414285241,112.975138005214,yes,11.6853334927511 +3,128,400,0.32,0.974490255271145,0.0161584024747199,10.0686015701853,111.686211576545,yes,11.0925246965045 +5,190,400,0.475,0.987406006389112,0.00907397498603396,10.327635079362,111.947454515262,yes,10.8396020633001 +10,310,400,0.775,0.998514901311494,0.00166521642115359,10.23659448881,112.26889834406,yes,10.9674070284591 +19,400,400,1,1,0,10.2183884027573,112.239031834401,yes,10.9840248198155 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_diagnostics.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_diagnostics.csv new file mode 100644 index 0000000..5ab4e4b --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_diagnostics.csv @@ -0,0 +1,12 @@ +field,value +random_effects,20 +available,yes +pattern_entries,364 +hessian_nonzeros,364 +min_diagonal,44.4444444444444 +max_diagonal,61.5960549055631 +eigen_success,yes +min_eigenvalue,10.218388968671 +max_eigenvalue,112.23903204398 +positive_definite,yes +condition_number_abs,10.9840242320094 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_matrix.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_matrix.csv new file mode 100644 index 0000000..482dd2e --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_matrix.csv @@ -0,0 +1,21 @@ +row,u1,u2,u3,u4,u5,u6,u7,u8,u9,u10,u11,u12,u13,u14,u15,u16,u17,u18,u19,u20 +u1,45.9000339958493,-25.7530855307664,0.874154260088744,0.797125743190463,0.692013557568316,0.577234082754785,0.468466154757152,0.373790243202166,0.297308133667684,0.234597763437705,0.183132087272497,0.140902400858067,0.106041220249153,0.0769640351450107,0.0533015409587279,0.0344137163210689,0.0200055083610096,0.0097536201337789,0.00319442250429347,0 +u2,-25.7530855307664,61.5960537686533,-25.7022430005804,0.915737352613633,0.824293699963619,0.705445835080809,0.581523984521937,0.467096938905343,0.368803299011233,0.290346058307023,0.226410890036277,0.17414905073565,0.131066535402624,0.0951391854187023,0.0658955556787078,0.0425471213816309,0.0247339926318091,0.0120591536756365,0.00394937416103858,0 +u3,0.874154260088744,-25.7022430005804,61.3438899677021,-25.6526341502195,0.952826439970522,0.846862846515251,0.716316783666571,0.584393866631672,0.464572380565187,0.363093555222349,0.282473777701853,0.217021600690259,0.163269930908427,0.118511422897427,0.0820913115262556,0.0530086197159108,0.0308167713569674,0.0150249590546991,0.00492086371650657,0 +u4,0.797125743190463,0.915737352613633,-25.6526341502195,61.1296925967508,-25.6123689368337,0.98046566421317,0.862282512059664,0.722001480824019,0.583207970805688,0.45914383406398,0.354762974552614,0.271920441718976,0.204321892738335,0.148238044062055,0.102671826596179,0.0663012755808268,0.0385472986863533,0.0187947435392744,0.00615543171988975,0 +u5,0.692013557568316,0.824293699963619,0.952826439970522,-25.6123689368337,60.9549204000359,-25.5847821151178,0.997356153220608,0.869116689727889,0.720983450719359,0.57720903612335,0.44964263423708,0.34256011360867,0.256816434784923,0.186090964859886,0.128817490008259,0.0831706259418752,0.04835492006805,0.0235777619650435,0.00772200081655683,0 +u6,0.577234082754785,0.705445835080809,0.846862846515251,0.98046566421317,-25.5847821151178,60.8129042234395,-25.5697711892822,1.0034252539981,0.867108695956631,0.713570713628542,0.565975177835298,0.435259472908456,0.324684457098101,0.234778241292588,0.162322244534607,0.104741459949764,0.0608824990422363,0.0296848767789015,0.00972271152477333,0 +u7,0.468466154757152,0.581523984521937,0.716316783666571,0.862282512059664,0.997356153220608,-25.5697711892822,60.689945513559,-25.5664737380812,0.999279947677678,0.857225224137892,0.699753321953267,0.548796919019878,0.413932355058932,0.29819791080854,0.205798578178928,0.132650157524949,0.0770622676782295,0.037564973354165,0.0123028698340022,0 +u8,0.373790243202166,0.467096938905343,0.584393866631672,0.722001480824019,0.869116689727889,1.0034252539981,-25.5664737380812,60.5815138499111,-25.5740379984104,0.985878756409875,0.839343528014069,0.678398492937049,0.522838661254355,0.381664122528491,0.262764743297339,0.169130487392977,0.0981692949153512,0.0478324935215824,0.0156624935243599,0 +u9,0.297308133667684,0.368803299011233,0.464572380565187,0.583207970805688,0.720983450719359,0.867108695956631,0.999279947677678,-25.5740379984104,60.4888420241423,-25.5920223679595,0.962522506142705,0.811501088548994,0.645501962992512,0.48257629003956,0.337586847365401,0.217029594296037,0.12585452680014,0.061286264951832,0.0200621741441864,0 +u10,0.234597763437705,0.290346058307023,0.363093555222349,0.45914383406398,0.57720903612335,0.713570713628542,0.857225224137892,0.985878756409875,-25.5920223679595,60.4089798628138,-25.6221035499493,0.926002918788527,0.768222818692266,0.593623639133511,0.426511093110093,0.279618816989569,0.162117785862392,0.0789086129771022,0.025824320459833,0 +u11,0.183132087272497,0.226410890036277,0.282473777701853,0.354762974552614,0.44964263423708,0.565975177835298,0.699753321953267,0.839343528014069,0.962522506142705,-25.6221035499493,60.343114682837,-25.6692109346091,0.868983107693566,0.699822066962952,0.520714316110116,0.35181919599836,0.209089989766653,0.101823260933998,0.0333223226789414,0 +u12,0.140902400858067,0.17414905073565,0.217021600690259,0.271920441718976,0.34256011360867,0.435259472908456,0.548796919019878,0.678398492937049,0.811501088548994,0.926002918788527,-25.6692109346091,60.2911704561393,-25.7431048922285,0.778988251681767,0.603862915227182,0.423290913431629,0.260363286486154,0.131161748129216,0.0429610125252111,0 +u13,0.106041220249153,0.131066535402624,0.163269930908427,0.204321892738335,0.256816434784923,0.324684457098101,0.413932355058932,0.522838661254355,0.645501962992512,0.768222818692266,0.868983107693566,-25.7431048922285,60.2539330429863,-25.8594118562883,0.653629150804136,0.476630290791036,0.304390468386373,0.159440460834048,0.0551457546293932,0 +u14,0.0769640351450107,0.0951391854187023,0.118511422897427,0.148238044062055,0.186090964859886,0.234778241292588,0.29819791080854,0.381664122528491,0.48257629003956,0.593623639133511,0.699822066962952,0.778988251681767,-25.8594118562883,60.2278255712463,-26.0202741131366,0.489159823757745,0.322853210832363,0.174135728059355,0.0615525408420581,0 +u15,0.0533015409587279,0.0658955556787078,0.0820913115262556,0.102671826596179,0.128817490008259,0.162322244534607,0.205798578178928,0.262764743297339,0.337586847365401,0.426511093110093,0.520714316110116,0.603862915227182,0.653629150804136,-26.0202741131366,60.2148446660067,-26.2040870779856,0.316747517103977,0.176981806987442,0.0646691589167858,0 +u16,0.0344137163210689,0.0425471213816309,0.0530086197159108,0.0663012755808268,0.0831706259418752,0.104741459949764,0.132650157524949,0.169130487392977,0.217029594296037,0.279618816989569,0.35181919599836,0.423290913431629,0.476630290791036,0.489159823757745,-26.2040870779856,60.2195150634088,-26.3851454462838,0.163300661881749,0.0618930684481711,0 +u17,0.0200055083610096,0.0247339926318091,0.0308167713569674,0.0385472986863533,0.04835492006805,0.0608824990422363,0.0770622676782295,0.0981692949153512,0.12585452680014,0.162117785862392,0.209089989766653,0.260363286486154,0.304390468386373,0.322853210832363,0.316747517103977,-26.3851454462838,60.2491752488277,-26.5319428649491,0.0531219512822645,0 +u18,0.0097536201337789,0.0120591536756365,0.0150249590546991,0.0187947435392744,0.0235777619650435,0.0296848767789015,0.037564973354165,0.0478324935215824,0.061286264951832,0.0789086129771022,0.101823260933998,0.131161748129216,0.159440460834048,0.174135728059355,0.176981806987442,0.163300661881749,-26.5319428649491,60.3067960014414,-26.6264992276888,0 +u19,0.00319442250429347,0.00394937416103858,0.00492086371650657,0.00615543171988975,0.00772200081655683,0.00972271152477333,0.0123028698340022,0.0156624935243599,0.0200621741441864,0.025824320459833,0.0333223226789414,0.0429610125252111,0.0551457546293932,0.0615525408420581,0.0646691589167858,0.0618930684481711,0.0531219512822645,-26.6264992276888,60.3803584908746,-26.6666667414484 +u20,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-26.6666667414484,44.4444452796233 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_pattern_compare.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_pattern_compare.csv new file mode 100644 index 0000000..1c87097 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_pattern_compare.csv @@ -0,0 +1,17 @@ +field,value +random_effects,20 +fd_tol,1e-08 +quadra_pattern_available,yes +quadra_pattern_detected_structure,dense +quadra_pattern_nonzeros_reported,364 +available,yes +fd_nonzeros_all,364 +fd_nonzeros_upper_including_diag,192 +fd_nonzeros_diag,20 +fd_nonzeros_offdiag_all,344 +fd_nonzeros_offdiag_upper,172 +fd_density_all,0.91 +fd_density_upper,0.914286 +max_abs_fd,61.5961 +min_abs_fd_nonzero,0.00319442 +note,OptPatternInfo does not currently expose individual pattern entries; this compares reported Quadra count to finite-difference numerical sparsity. diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_pattern_compare_detail.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_pattern_compare_detail.csv new file mode 100644 index 0000000..7c94fbc --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_pattern_compare_detail.csv @@ -0,0 +1,365 @@ +i,j,fd_nonzero,fd_value,abs_fd_value,band_distance +1,1,yes,45.9000339958493,45.9000339958493,0 +1,2,yes,-25.7530855307664,25.7530855307664,1 +1,3,yes,0.874154260088744,0.874154260088744,2 +1,4,yes,0.797125743190463,0.797125743190463,3 +1,5,yes,0.692013557568316,0.692013557568316,4 +1,6,yes,0.577234082754785,0.577234082754785,5 +1,7,yes,0.468466154757152,0.468466154757152,6 +1,8,yes,0.373790243202166,0.373790243202166,7 +1,9,yes,0.297308133667684,0.297308133667684,8 +1,10,yes,0.234597763437705,0.234597763437705,9 +1,11,yes,0.183132087272497,0.183132087272497,10 +1,12,yes,0.140902400858067,0.140902400858067,11 +1,13,yes,0.106041220249153,0.106041220249153,12 +1,14,yes,0.0769640351450107,0.0769640351450107,13 +1,15,yes,0.0533015409587279,0.0533015409587279,14 +1,16,yes,0.0344137163210689,0.0344137163210689,15 +1,17,yes,0.0200055083610096,0.0200055083610096,16 +1,18,yes,0.0097536201337789,0.0097536201337789,17 +1,19,yes,0.00319442250429347,0.00319442250429347,18 +2,1,yes,-25.7530855307664,25.7530855307664,1 +2,2,yes,61.5960537686533,61.5960537686533,0 +2,3,yes,-25.7022430005804,25.7022430005804,1 +2,4,yes,0.915737352613633,0.915737352613633,2 +2,5,yes,0.824293699963619,0.824293699963619,3 +2,6,yes,0.705445835080809,0.705445835080809,4 +2,7,yes,0.581523984521937,0.581523984521937,5 +2,8,yes,0.467096938905343,0.467096938905343,6 +2,9,yes,0.368803299011233,0.368803299011233,7 +2,10,yes,0.290346058307023,0.290346058307023,8 +2,11,yes,0.226410890036277,0.226410890036277,9 +2,12,yes,0.17414905073565,0.17414905073565,10 +2,13,yes,0.131066535402624,0.131066535402624,11 +2,14,yes,0.0951391854187023,0.0951391854187023,12 +2,15,yes,0.0658955556787078,0.0658955556787078,13 +2,16,yes,0.0425471213816309,0.0425471213816309,14 +2,17,yes,0.0247339926318091,0.0247339926318091,15 +2,18,yes,0.0120591536756365,0.0120591536756365,16 +2,19,yes,0.00394937416103858,0.00394937416103858,17 +3,1,yes,0.874154260088744,0.874154260088744,2 +3,2,yes,-25.7022430005804,25.7022430005804,1 +3,3,yes,61.3438899677021,61.3438899677021,0 +3,4,yes,-25.6526341502195,25.6526341502195,1 +3,5,yes,0.952826439970522,0.952826439970522,2 +3,6,yes,0.846862846515251,0.846862846515251,3 +3,7,yes,0.716316783666571,0.716316783666571,4 +3,8,yes,0.584393866631672,0.584393866631672,5 +3,9,yes,0.464572380565187,0.464572380565187,6 +3,10,yes,0.363093555222349,0.363093555222349,7 +3,11,yes,0.282473777701853,0.282473777701853,8 +3,12,yes,0.217021600690259,0.217021600690259,9 +3,13,yes,0.163269930908427,0.163269930908427,10 +3,14,yes,0.118511422897427,0.118511422897427,11 +3,15,yes,0.0820913115262556,0.0820913115262556,12 +3,16,yes,0.0530086197159108,0.0530086197159108,13 +3,17,yes,0.0308167713569674,0.0308167713569674,14 +3,18,yes,0.0150249590546991,0.0150249590546991,15 +3,19,yes,0.00492086371650657,0.00492086371650657,16 +4,1,yes,0.797125743190463,0.797125743190463,3 +4,2,yes,0.915737352613633,0.915737352613633,2 +4,3,yes,-25.6526341502195,25.6526341502195,1 +4,4,yes,61.1296925967508,61.1296925967508,0 +4,5,yes,-25.6123689368337,25.6123689368337,1 +4,6,yes,0.98046566421317,0.98046566421317,2 +4,7,yes,0.862282512059664,0.862282512059664,3 +4,8,yes,0.722001480824019,0.722001480824019,4 +4,9,yes,0.583207970805688,0.583207970805688,5 +4,10,yes,0.45914383406398,0.45914383406398,6 +4,11,yes,0.354762974552614,0.354762974552614,7 +4,12,yes,0.271920441718976,0.271920441718976,8 +4,13,yes,0.204321892738335,0.204321892738335,9 +4,14,yes,0.148238044062055,0.148238044062055,10 +4,15,yes,0.102671826596179,0.102671826596179,11 +4,16,yes,0.0663012755808268,0.0663012755808268,12 +4,17,yes,0.0385472986863533,0.0385472986863533,13 +4,18,yes,0.0187947435392744,0.0187947435392744,14 +4,19,yes,0.00615543171988975,0.00615543171988975,15 +5,1,yes,0.692013557568316,0.692013557568316,4 +5,2,yes,0.824293699963619,0.824293699963619,3 +5,3,yes,0.952826439970522,0.952826439970522,2 +5,4,yes,-25.6123689368337,25.6123689368337,1 +5,5,yes,60.9549204000359,60.9549204000359,0 +5,6,yes,-25.5847821151178,25.5847821151178,1 +5,7,yes,0.997356153220608,0.997356153220608,2 +5,8,yes,0.869116689727889,0.869116689727889,3 +5,9,yes,0.720983450719359,0.720983450719359,4 +5,10,yes,0.57720903612335,0.57720903612335,5 +5,11,yes,0.44964263423708,0.44964263423708,6 +5,12,yes,0.34256011360867,0.34256011360867,7 +5,13,yes,0.256816434784923,0.256816434784923,8 +5,14,yes,0.186090964859886,0.186090964859886,9 +5,15,yes,0.128817490008259,0.128817490008259,10 +5,16,yes,0.0831706259418752,0.0831706259418752,11 +5,17,yes,0.04835492006805,0.04835492006805,12 +5,18,yes,0.0235777619650435,0.0235777619650435,13 +5,19,yes,0.00772200081655683,0.00772200081655683,14 +6,1,yes,0.577234082754785,0.577234082754785,5 +6,2,yes,0.705445835080809,0.705445835080809,4 +6,3,yes,0.846862846515251,0.846862846515251,3 +6,4,yes,0.98046566421317,0.98046566421317,2 +6,5,yes,-25.5847821151178,25.5847821151178,1 +6,6,yes,60.8129042234395,60.8129042234395,0 +6,7,yes,-25.5697711892822,25.5697711892822,1 +6,8,yes,1.0034252539981,1.0034252539981,2 +6,9,yes,0.867108695956631,0.867108695956631,3 +6,10,yes,0.713570713628542,0.713570713628542,4 +6,11,yes,0.565975177835298,0.565975177835298,5 +6,12,yes,0.435259472908456,0.435259472908456,6 +6,13,yes,0.324684457098101,0.324684457098101,7 +6,14,yes,0.234778241292588,0.234778241292588,8 +6,15,yes,0.162322244534607,0.162322244534607,9 +6,16,yes,0.104741459949764,0.104741459949764,10 +6,17,yes,0.0608824990422363,0.0608824990422363,11 +6,18,yes,0.0296848767789015,0.0296848767789015,12 +6,19,yes,0.00972271152477333,0.00972271152477333,13 +7,1,yes,0.468466154757152,0.468466154757152,6 +7,2,yes,0.581523984521937,0.581523984521937,5 +7,3,yes,0.716316783666571,0.716316783666571,4 +7,4,yes,0.862282512059664,0.862282512059664,3 +7,5,yes,0.997356153220608,0.997356153220608,2 +7,6,yes,-25.5697711892822,25.5697711892822,1 +7,7,yes,60.689945513559,60.689945513559,0 +7,8,yes,-25.5664737380812,25.5664737380812,1 +7,9,yes,0.999279947677678,0.999279947677678,2 +7,10,yes,0.857225224137892,0.857225224137892,3 +7,11,yes,0.699753321953267,0.699753321953267,4 +7,12,yes,0.548796919019878,0.548796919019878,5 +7,13,yes,0.413932355058932,0.413932355058932,6 +7,14,yes,0.29819791080854,0.29819791080854,7 +7,15,yes,0.205798578178928,0.205798578178928,8 +7,16,yes,0.132650157524949,0.132650157524949,9 +7,17,yes,0.0770622676782295,0.0770622676782295,10 +7,18,yes,0.037564973354165,0.037564973354165,11 +7,19,yes,0.0123028698340022,0.0123028698340022,12 +8,1,yes,0.373790243202166,0.373790243202166,7 +8,2,yes,0.467096938905343,0.467096938905343,6 +8,3,yes,0.584393866631672,0.584393866631672,5 +8,4,yes,0.722001480824019,0.722001480824019,4 +8,5,yes,0.869116689727889,0.869116689727889,3 +8,6,yes,1.0034252539981,1.0034252539981,2 +8,7,yes,-25.5664737380812,25.5664737380812,1 +8,8,yes,60.5815138499111,60.5815138499111,0 +8,9,yes,-25.5740379984104,25.5740379984104,1 +8,10,yes,0.985878756409875,0.985878756409875,2 +8,11,yes,0.839343528014069,0.839343528014069,3 +8,12,yes,0.678398492937049,0.678398492937049,4 +8,13,yes,0.522838661254355,0.522838661254355,5 +8,14,yes,0.381664122528491,0.381664122528491,6 +8,15,yes,0.262764743297339,0.262764743297339,7 +8,16,yes,0.169130487392977,0.169130487392977,8 +8,17,yes,0.0981692949153512,0.0981692949153512,9 +8,18,yes,0.0478324935215824,0.0478324935215824,10 +8,19,yes,0.0156624935243599,0.0156624935243599,11 +9,1,yes,0.297308133667684,0.297308133667684,8 +9,2,yes,0.368803299011233,0.368803299011233,7 +9,3,yes,0.464572380565187,0.464572380565187,6 +9,4,yes,0.583207970805688,0.583207970805688,5 +9,5,yes,0.720983450719359,0.720983450719359,4 +9,6,yes,0.867108695956631,0.867108695956631,3 +9,7,yes,0.999279947677678,0.999279947677678,2 +9,8,yes,-25.5740379984104,25.5740379984104,1 +9,9,yes,60.4888420241423,60.4888420241423,0 +9,10,yes,-25.5920223679595,25.5920223679595,1 +9,11,yes,0.962522506142705,0.962522506142705,2 +9,12,yes,0.811501088548994,0.811501088548994,3 +9,13,yes,0.645501962992512,0.645501962992512,4 +9,14,yes,0.48257629003956,0.48257629003956,5 +9,15,yes,0.337586847365401,0.337586847365401,6 +9,16,yes,0.217029594296037,0.217029594296037,7 +9,17,yes,0.12585452680014,0.12585452680014,8 +9,18,yes,0.061286264951832,0.061286264951832,9 +9,19,yes,0.0200621741441864,0.0200621741441864,10 +10,1,yes,0.234597763437705,0.234597763437705,9 +10,2,yes,0.290346058307023,0.290346058307023,8 +10,3,yes,0.363093555222349,0.363093555222349,7 +10,4,yes,0.45914383406398,0.45914383406398,6 +10,5,yes,0.57720903612335,0.57720903612335,5 +10,6,yes,0.713570713628542,0.713570713628542,4 +10,7,yes,0.857225224137892,0.857225224137892,3 +10,8,yes,0.985878756409875,0.985878756409875,2 +10,9,yes,-25.5920223679595,25.5920223679595,1 +10,10,yes,60.4089798628138,60.4089798628138,0 +10,11,yes,-25.6221035499493,25.6221035499493,1 +10,12,yes,0.926002918788527,0.926002918788527,2 +10,13,yes,0.768222818692266,0.768222818692266,3 +10,14,yes,0.593623639133511,0.593623639133511,4 +10,15,yes,0.426511093110093,0.426511093110093,5 +10,16,yes,0.279618816989569,0.279618816989569,6 +10,17,yes,0.162117785862392,0.162117785862392,7 +10,18,yes,0.0789086129771022,0.0789086129771022,8 +10,19,yes,0.025824320459833,0.025824320459833,9 +11,1,yes,0.183132087272497,0.183132087272497,10 +11,2,yes,0.226410890036277,0.226410890036277,9 +11,3,yes,0.282473777701853,0.282473777701853,8 +11,4,yes,0.354762974552614,0.354762974552614,7 +11,5,yes,0.44964263423708,0.44964263423708,6 +11,6,yes,0.565975177835298,0.565975177835298,5 +11,7,yes,0.699753321953267,0.699753321953267,4 +11,8,yes,0.839343528014069,0.839343528014069,3 +11,9,yes,0.962522506142705,0.962522506142705,2 +11,10,yes,-25.6221035499493,25.6221035499493,1 +11,11,yes,60.343114682837,60.343114682837,0 +11,12,yes,-25.6692109346091,25.6692109346091,1 +11,13,yes,0.868983107693566,0.868983107693566,2 +11,14,yes,0.699822066962952,0.699822066962952,3 +11,15,yes,0.520714316110116,0.520714316110116,4 +11,16,yes,0.35181919599836,0.35181919599836,5 +11,17,yes,0.209089989766653,0.209089989766653,6 +11,18,yes,0.101823260933998,0.101823260933998,7 +11,19,yes,0.0333223226789414,0.0333223226789414,8 +12,1,yes,0.140902400858067,0.140902400858067,11 +12,2,yes,0.17414905073565,0.17414905073565,10 +12,3,yes,0.217021600690259,0.217021600690259,9 +12,4,yes,0.271920441718976,0.271920441718976,8 +12,5,yes,0.34256011360867,0.34256011360867,7 +12,6,yes,0.435259472908456,0.435259472908456,6 +12,7,yes,0.548796919019878,0.548796919019878,5 +12,8,yes,0.678398492937049,0.678398492937049,4 +12,9,yes,0.811501088548994,0.811501088548994,3 +12,10,yes,0.926002918788527,0.926002918788527,2 +12,11,yes,-25.6692109346091,25.6692109346091,1 +12,12,yes,60.2911704561393,60.2911704561393,0 +12,13,yes,-25.7431048922285,25.7431048922285,1 +12,14,yes,0.778988251681767,0.778988251681767,2 +12,15,yes,0.603862915227182,0.603862915227182,3 +12,16,yes,0.423290913431629,0.423290913431629,4 +12,17,yes,0.260363286486154,0.260363286486154,5 +12,18,yes,0.131161748129216,0.131161748129216,6 +12,19,yes,0.0429610125252111,0.0429610125252111,7 +13,1,yes,0.106041220249153,0.106041220249153,12 +13,2,yes,0.131066535402624,0.131066535402624,11 +13,3,yes,0.163269930908427,0.163269930908427,10 +13,4,yes,0.204321892738335,0.204321892738335,9 +13,5,yes,0.256816434784923,0.256816434784923,8 +13,6,yes,0.324684457098101,0.324684457098101,7 +13,7,yes,0.413932355058932,0.413932355058932,6 +13,8,yes,0.522838661254355,0.522838661254355,5 +13,9,yes,0.645501962992512,0.645501962992512,4 +13,10,yes,0.768222818692266,0.768222818692266,3 +13,11,yes,0.868983107693566,0.868983107693566,2 +13,12,yes,-25.7431048922285,25.7431048922285,1 +13,13,yes,60.2539330429863,60.2539330429863,0 +13,14,yes,-25.8594118562883,25.8594118562883,1 +13,15,yes,0.653629150804136,0.653629150804136,2 +13,16,yes,0.476630290791036,0.476630290791036,3 +13,17,yes,0.304390468386373,0.304390468386373,4 +13,18,yes,0.159440460834048,0.159440460834048,5 +13,19,yes,0.0551457546293932,0.0551457546293932,6 +14,1,yes,0.0769640351450107,0.0769640351450107,13 +14,2,yes,0.0951391854187023,0.0951391854187023,12 +14,3,yes,0.118511422897427,0.118511422897427,11 +14,4,yes,0.148238044062055,0.148238044062055,10 +14,5,yes,0.186090964859886,0.186090964859886,9 +14,6,yes,0.234778241292588,0.234778241292588,8 +14,7,yes,0.29819791080854,0.29819791080854,7 +14,8,yes,0.381664122528491,0.381664122528491,6 +14,9,yes,0.48257629003956,0.48257629003956,5 +14,10,yes,0.593623639133511,0.593623639133511,4 +14,11,yes,0.699822066962952,0.699822066962952,3 +14,12,yes,0.778988251681767,0.778988251681767,2 +14,13,yes,-25.8594118562883,25.8594118562883,1 +14,14,yes,60.2278255712463,60.2278255712463,0 +14,15,yes,-26.0202741131366,26.0202741131366,1 +14,16,yes,0.489159823757745,0.489159823757745,2 +14,17,yes,0.322853210832363,0.322853210832363,3 +14,18,yes,0.174135728059355,0.174135728059355,4 +14,19,yes,0.0615525408420581,0.0615525408420581,5 +15,1,yes,0.0533015409587279,0.0533015409587279,14 +15,2,yes,0.0658955556787078,0.0658955556787078,13 +15,3,yes,0.0820913115262556,0.0820913115262556,12 +15,4,yes,0.102671826596179,0.102671826596179,11 +15,5,yes,0.128817490008259,0.128817490008259,10 +15,6,yes,0.162322244534607,0.162322244534607,9 +15,7,yes,0.205798578178928,0.205798578178928,8 +15,8,yes,0.262764743297339,0.262764743297339,7 +15,9,yes,0.337586847365401,0.337586847365401,6 +15,10,yes,0.426511093110093,0.426511093110093,5 +15,11,yes,0.520714316110116,0.520714316110116,4 +15,12,yes,0.603862915227182,0.603862915227182,3 +15,13,yes,0.653629150804136,0.653629150804136,2 +15,14,yes,-26.0202741131366,26.0202741131366,1 +15,15,yes,60.2148446660067,60.2148446660067,0 +15,16,yes,-26.2040870779856,26.2040870779856,1 +15,17,yes,0.316747517103977,0.316747517103977,2 +15,18,yes,0.176981806987442,0.176981806987442,3 +15,19,yes,0.0646691589167858,0.0646691589167858,4 +16,1,yes,0.0344137163210689,0.0344137163210689,15 +16,2,yes,0.0425471213816309,0.0425471213816309,14 +16,3,yes,0.0530086197159108,0.0530086197159108,13 +16,4,yes,0.0663012755808268,0.0663012755808268,12 +16,5,yes,0.0831706259418752,0.0831706259418752,11 +16,6,yes,0.104741459949764,0.104741459949764,10 +16,7,yes,0.132650157524949,0.132650157524949,9 +16,8,yes,0.169130487392977,0.169130487392977,8 +16,9,yes,0.217029594296037,0.217029594296037,7 +16,10,yes,0.279618816989569,0.279618816989569,6 +16,11,yes,0.35181919599836,0.35181919599836,5 +16,12,yes,0.423290913431629,0.423290913431629,4 +16,13,yes,0.476630290791036,0.476630290791036,3 +16,14,yes,0.489159823757745,0.489159823757745,2 +16,15,yes,-26.2040870779856,26.2040870779856,1 +16,16,yes,60.2195150634088,60.2195150634088,0 +16,17,yes,-26.3851454462838,26.3851454462838,1 +16,18,yes,0.163300661881749,0.163300661881749,2 +16,19,yes,0.0618930684481711,0.0618930684481711,3 +17,1,yes,0.0200055083610096,0.0200055083610096,16 +17,2,yes,0.0247339926318091,0.0247339926318091,15 +17,3,yes,0.0308167713569674,0.0308167713569674,14 +17,4,yes,0.0385472986863533,0.0385472986863533,13 +17,5,yes,0.04835492006805,0.04835492006805,12 +17,6,yes,0.0608824990422363,0.0608824990422363,11 +17,7,yes,0.0770622676782295,0.0770622676782295,10 +17,8,yes,0.0981692949153512,0.0981692949153512,9 +17,9,yes,0.12585452680014,0.12585452680014,8 +17,10,yes,0.162117785862392,0.162117785862392,7 +17,11,yes,0.209089989766653,0.209089989766653,6 +17,12,yes,0.260363286486154,0.260363286486154,5 +17,13,yes,0.304390468386373,0.304390468386373,4 +17,14,yes,0.322853210832363,0.322853210832363,3 +17,15,yes,0.316747517103977,0.316747517103977,2 +17,16,yes,-26.3851454462838,26.3851454462838,1 +17,17,yes,60.2491752488277,60.2491752488277,0 +17,18,yes,-26.5319428649491,26.5319428649491,1 +17,19,yes,0.0531219512822645,0.0531219512822645,2 +18,1,yes,0.0097536201337789,0.0097536201337789,17 +18,2,yes,0.0120591536756365,0.0120591536756365,16 +18,3,yes,0.0150249590546991,0.0150249590546991,15 +18,4,yes,0.0187947435392744,0.0187947435392744,14 +18,5,yes,0.0235777619650435,0.0235777619650435,13 +18,6,yes,0.0296848767789015,0.0296848767789015,12 +18,7,yes,0.037564973354165,0.037564973354165,11 +18,8,yes,0.0478324935215824,0.0478324935215824,10 +18,9,yes,0.061286264951832,0.061286264951832,9 +18,10,yes,0.0789086129771022,0.0789086129771022,8 +18,11,yes,0.101823260933998,0.101823260933998,7 +18,12,yes,0.131161748129216,0.131161748129216,6 +18,13,yes,0.159440460834048,0.159440460834048,5 +18,14,yes,0.174135728059355,0.174135728059355,4 +18,15,yes,0.176981806987442,0.176981806987442,3 +18,16,yes,0.163300661881749,0.163300661881749,2 +18,17,yes,-26.5319428649491,26.5319428649491,1 +18,18,yes,60.3067960014414,60.3067960014414,0 +18,19,yes,-26.6264992276888,26.6264992276888,1 +19,1,yes,0.00319442250429347,0.00319442250429347,18 +19,2,yes,0.00394937416103858,0.00394937416103858,17 +19,3,yes,0.00492086371650657,0.00492086371650657,16 +19,4,yes,0.00615543171988975,0.00615543171988975,15 +19,5,yes,0.00772200081655683,0.00772200081655683,14 +19,6,yes,0.00972271152477333,0.00972271152477333,13 +19,7,yes,0.0123028698340022,0.0123028698340022,12 +19,8,yes,0.0156624935243599,0.0156624935243599,11 +19,9,yes,0.0200621741441864,0.0200621741441864,10 +19,10,yes,0.025824320459833,0.025824320459833,9 +19,11,yes,0.0333223226789414,0.0333223226789414,8 +19,12,yes,0.0429610125252111,0.0429610125252111,7 +19,13,yes,0.0551457546293932,0.0551457546293932,6 +19,14,yes,0.0615525408420581,0.0615525408420581,5 +19,15,yes,0.0646691589167858,0.0646691589167858,4 +19,16,yes,0.0618930684481711,0.0618930684481711,3 +19,17,yes,0.0531219512822645,0.0531219512822645,2 +19,18,yes,-26.6264992276888,26.6264992276888,1 +19,19,yes,60.3803584908746,60.3803584908746,0 +19,20,yes,-26.6666667414484,26.6666667414484,1 +20,19,yes,-26.6666667414484,26.6666667414484,1 +20,20,yes,44.4444452796233,44.4444452796233,0 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_sparsity.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_sparsity.csv new file mode 100644 index 0000000..ae9aa84 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_sparsity.csv @@ -0,0 +1,365 @@ +i,j,value,abs_value +1,1,45.9000339958493,45.9000339958493 +1,2,-25.7530855307664,25.7530855307664 +1,3,0.874154260088744,0.874154260088744 +1,4,0.797125743190463,0.797125743190463 +1,5,0.692013557568316,0.692013557568316 +1,6,0.577234082754785,0.577234082754785 +1,7,0.468466154757152,0.468466154757152 +1,8,0.373790243202166,0.373790243202166 +1,9,0.297308133667684,0.297308133667684 +1,10,0.234597763437705,0.234597763437705 +1,11,0.183132087272497,0.183132087272497 +1,12,0.140902400858067,0.140902400858067 +1,13,0.106041220249153,0.106041220249153 +1,14,0.0769640351450107,0.0769640351450107 +1,15,0.0533015409587279,0.0533015409587279 +1,16,0.0344137163210689,0.0344137163210689 +1,17,0.0200055083610096,0.0200055083610096 +1,18,0.0097536201337789,0.0097536201337789 +1,19,0.00319442250429347,0.00319442250429347 +2,1,-25.7530855307664,25.7530855307664 +2,2,61.5960537686533,61.5960537686533 +2,3,-25.7022430005804,25.7022430005804 +2,4,0.915737352613633,0.915737352613633 +2,5,0.824293699963619,0.824293699963619 +2,6,0.705445835080809,0.705445835080809 +2,7,0.581523984521937,0.581523984521937 +2,8,0.467096938905343,0.467096938905343 +2,9,0.368803299011233,0.368803299011233 +2,10,0.290346058307023,0.290346058307023 +2,11,0.226410890036277,0.226410890036277 +2,12,0.17414905073565,0.17414905073565 +2,13,0.131066535402624,0.131066535402624 +2,14,0.0951391854187023,0.0951391854187023 +2,15,0.0658955556787078,0.0658955556787078 +2,16,0.0425471213816309,0.0425471213816309 +2,17,0.0247339926318091,0.0247339926318091 +2,18,0.0120591536756365,0.0120591536756365 +2,19,0.00394937416103858,0.00394937416103858 +3,1,0.874154260088744,0.874154260088744 +3,2,-25.7022430005804,25.7022430005804 +3,3,61.3438899677021,61.3438899677021 +3,4,-25.6526341502195,25.6526341502195 +3,5,0.952826439970522,0.952826439970522 +3,6,0.846862846515251,0.846862846515251 +3,7,0.716316783666571,0.716316783666571 +3,8,0.584393866631672,0.584393866631672 +3,9,0.464572380565187,0.464572380565187 +3,10,0.363093555222349,0.363093555222349 +3,11,0.282473777701853,0.282473777701853 +3,12,0.217021600690259,0.217021600690259 +3,13,0.163269930908427,0.163269930908427 +3,14,0.118511422897427,0.118511422897427 +3,15,0.0820913115262556,0.0820913115262556 +3,16,0.0530086197159108,0.0530086197159108 +3,17,0.0308167713569674,0.0308167713569674 +3,18,0.0150249590546991,0.0150249590546991 +3,19,0.00492086371650657,0.00492086371650657 +4,1,0.797125743190463,0.797125743190463 +4,2,0.915737352613633,0.915737352613633 +4,3,-25.6526341502195,25.6526341502195 +4,4,61.1296925967508,61.1296925967508 +4,5,-25.6123689368337,25.6123689368337 +4,6,0.98046566421317,0.98046566421317 +4,7,0.862282512059664,0.862282512059664 +4,8,0.722001480824019,0.722001480824019 +4,9,0.583207970805688,0.583207970805688 +4,10,0.45914383406398,0.45914383406398 +4,11,0.354762974552614,0.354762974552614 +4,12,0.271920441718976,0.271920441718976 +4,13,0.204321892738335,0.204321892738335 +4,14,0.148238044062055,0.148238044062055 +4,15,0.102671826596179,0.102671826596179 +4,16,0.0663012755808268,0.0663012755808268 +4,17,0.0385472986863533,0.0385472986863533 +4,18,0.0187947435392744,0.0187947435392744 +4,19,0.00615543171988975,0.00615543171988975 +5,1,0.692013557568316,0.692013557568316 +5,2,0.824293699963619,0.824293699963619 +5,3,0.952826439970522,0.952826439970522 +5,4,-25.6123689368337,25.6123689368337 +5,5,60.9549204000359,60.9549204000359 +5,6,-25.5847821151178,25.5847821151178 +5,7,0.997356153220608,0.997356153220608 +5,8,0.869116689727889,0.869116689727889 +5,9,0.720983450719359,0.720983450719359 +5,10,0.57720903612335,0.57720903612335 +5,11,0.44964263423708,0.44964263423708 +5,12,0.34256011360867,0.34256011360867 +5,13,0.256816434784923,0.256816434784923 +5,14,0.186090964859886,0.186090964859886 +5,15,0.128817490008259,0.128817490008259 +5,16,0.0831706259418752,0.0831706259418752 +5,17,0.04835492006805,0.04835492006805 +5,18,0.0235777619650435,0.0235777619650435 +5,19,0.00772200081655683,0.00772200081655683 +6,1,0.577234082754785,0.577234082754785 +6,2,0.705445835080809,0.705445835080809 +6,3,0.846862846515251,0.846862846515251 +6,4,0.98046566421317,0.98046566421317 +6,5,-25.5847821151178,25.5847821151178 +6,6,60.8129042234395,60.8129042234395 +6,7,-25.5697711892822,25.5697711892822 +6,8,1.0034252539981,1.0034252539981 +6,9,0.867108695956631,0.867108695956631 +6,10,0.713570713628542,0.713570713628542 +6,11,0.565975177835298,0.565975177835298 +6,12,0.435259472908456,0.435259472908456 +6,13,0.324684457098101,0.324684457098101 +6,14,0.234778241292588,0.234778241292588 +6,15,0.162322244534607,0.162322244534607 +6,16,0.104741459949764,0.104741459949764 +6,17,0.0608824990422363,0.0608824990422363 +6,18,0.0296848767789015,0.0296848767789015 +6,19,0.00972271152477333,0.00972271152477333 +7,1,0.468466154757152,0.468466154757152 +7,2,0.581523984521937,0.581523984521937 +7,3,0.716316783666571,0.716316783666571 +7,4,0.862282512059664,0.862282512059664 +7,5,0.997356153220608,0.997356153220608 +7,6,-25.5697711892822,25.5697711892822 +7,7,60.689945513559,60.689945513559 +7,8,-25.5664737380812,25.5664737380812 +7,9,0.999279947677678,0.999279947677678 +7,10,0.857225224137892,0.857225224137892 +7,11,0.699753321953267,0.699753321953267 +7,12,0.548796919019878,0.548796919019878 +7,13,0.413932355058932,0.413932355058932 +7,14,0.29819791080854,0.29819791080854 +7,15,0.205798578178928,0.205798578178928 +7,16,0.132650157524949,0.132650157524949 +7,17,0.0770622676782295,0.0770622676782295 +7,18,0.037564973354165,0.037564973354165 +7,19,0.0123028698340022,0.0123028698340022 +8,1,0.373790243202166,0.373790243202166 +8,2,0.467096938905343,0.467096938905343 +8,3,0.584393866631672,0.584393866631672 +8,4,0.722001480824019,0.722001480824019 +8,5,0.869116689727889,0.869116689727889 +8,6,1.0034252539981,1.0034252539981 +8,7,-25.5664737380812,25.5664737380812 +8,8,60.5815138499111,60.5815138499111 +8,9,-25.5740379984104,25.5740379984104 +8,10,0.985878756409875,0.985878756409875 +8,11,0.839343528014069,0.839343528014069 +8,12,0.678398492937049,0.678398492937049 +8,13,0.522838661254355,0.522838661254355 +8,14,0.381664122528491,0.381664122528491 +8,15,0.262764743297339,0.262764743297339 +8,16,0.169130487392977,0.169130487392977 +8,17,0.0981692949153512,0.0981692949153512 +8,18,0.0478324935215824,0.0478324935215824 +8,19,0.0156624935243599,0.0156624935243599 +9,1,0.297308133667684,0.297308133667684 +9,2,0.368803299011233,0.368803299011233 +9,3,0.464572380565187,0.464572380565187 +9,4,0.583207970805688,0.583207970805688 +9,5,0.720983450719359,0.720983450719359 +9,6,0.867108695956631,0.867108695956631 +9,7,0.999279947677678,0.999279947677678 +9,8,-25.5740379984104,25.5740379984104 +9,9,60.4888420241423,60.4888420241423 +9,10,-25.5920223679595,25.5920223679595 +9,11,0.962522506142705,0.962522506142705 +9,12,0.811501088548994,0.811501088548994 +9,13,0.645501962992512,0.645501962992512 +9,14,0.48257629003956,0.48257629003956 +9,15,0.337586847365401,0.337586847365401 +9,16,0.217029594296037,0.217029594296037 +9,17,0.12585452680014,0.12585452680014 +9,18,0.061286264951832,0.061286264951832 +9,19,0.0200621741441864,0.0200621741441864 +10,1,0.234597763437705,0.234597763437705 +10,2,0.290346058307023,0.290346058307023 +10,3,0.363093555222349,0.363093555222349 +10,4,0.45914383406398,0.45914383406398 +10,5,0.57720903612335,0.57720903612335 +10,6,0.713570713628542,0.713570713628542 +10,7,0.857225224137892,0.857225224137892 +10,8,0.985878756409875,0.985878756409875 +10,9,-25.5920223679595,25.5920223679595 +10,10,60.4089798628138,60.4089798628138 +10,11,-25.6221035499493,25.6221035499493 +10,12,0.926002918788527,0.926002918788527 +10,13,0.768222818692266,0.768222818692266 +10,14,0.593623639133511,0.593623639133511 +10,15,0.426511093110093,0.426511093110093 +10,16,0.279618816989569,0.279618816989569 +10,17,0.162117785862392,0.162117785862392 +10,18,0.0789086129771022,0.0789086129771022 +10,19,0.025824320459833,0.025824320459833 +11,1,0.183132087272497,0.183132087272497 +11,2,0.226410890036277,0.226410890036277 +11,3,0.282473777701853,0.282473777701853 +11,4,0.354762974552614,0.354762974552614 +11,5,0.44964263423708,0.44964263423708 +11,6,0.565975177835298,0.565975177835298 +11,7,0.699753321953267,0.699753321953267 +11,8,0.839343528014069,0.839343528014069 +11,9,0.962522506142705,0.962522506142705 +11,10,-25.6221035499493,25.6221035499493 +11,11,60.343114682837,60.343114682837 +11,12,-25.6692109346091,25.6692109346091 +11,13,0.868983107693566,0.868983107693566 +11,14,0.699822066962952,0.699822066962952 +11,15,0.520714316110116,0.520714316110116 +11,16,0.35181919599836,0.35181919599836 +11,17,0.209089989766653,0.209089989766653 +11,18,0.101823260933998,0.101823260933998 +11,19,0.0333223226789414,0.0333223226789414 +12,1,0.140902400858067,0.140902400858067 +12,2,0.17414905073565,0.17414905073565 +12,3,0.217021600690259,0.217021600690259 +12,4,0.271920441718976,0.271920441718976 +12,5,0.34256011360867,0.34256011360867 +12,6,0.435259472908456,0.435259472908456 +12,7,0.548796919019878,0.548796919019878 +12,8,0.678398492937049,0.678398492937049 +12,9,0.811501088548994,0.811501088548994 +12,10,0.926002918788527,0.926002918788527 +12,11,-25.6692109346091,25.6692109346091 +12,12,60.2911704561393,60.2911704561393 +12,13,-25.7431048922285,25.7431048922285 +12,14,0.778988251681767,0.778988251681767 +12,15,0.603862915227182,0.603862915227182 +12,16,0.423290913431629,0.423290913431629 +12,17,0.260363286486154,0.260363286486154 +12,18,0.131161748129216,0.131161748129216 +12,19,0.0429610125252111,0.0429610125252111 +13,1,0.106041220249153,0.106041220249153 +13,2,0.131066535402624,0.131066535402624 +13,3,0.163269930908427,0.163269930908427 +13,4,0.204321892738335,0.204321892738335 +13,5,0.256816434784923,0.256816434784923 +13,6,0.324684457098101,0.324684457098101 +13,7,0.413932355058932,0.413932355058932 +13,8,0.522838661254355,0.522838661254355 +13,9,0.645501962992512,0.645501962992512 +13,10,0.768222818692266,0.768222818692266 +13,11,0.868983107693566,0.868983107693566 +13,12,-25.7431048922285,25.7431048922285 +13,13,60.2539330429863,60.2539330429863 +13,14,-25.8594118562883,25.8594118562883 +13,15,0.653629150804136,0.653629150804136 +13,16,0.476630290791036,0.476630290791036 +13,17,0.304390468386373,0.304390468386373 +13,18,0.159440460834048,0.159440460834048 +13,19,0.0551457546293932,0.0551457546293932 +14,1,0.0769640351450107,0.0769640351450107 +14,2,0.0951391854187023,0.0951391854187023 +14,3,0.118511422897427,0.118511422897427 +14,4,0.148238044062055,0.148238044062055 +14,5,0.186090964859886,0.186090964859886 +14,6,0.234778241292588,0.234778241292588 +14,7,0.29819791080854,0.29819791080854 +14,8,0.381664122528491,0.381664122528491 +14,9,0.48257629003956,0.48257629003956 +14,10,0.593623639133511,0.593623639133511 +14,11,0.699822066962952,0.699822066962952 +14,12,0.778988251681767,0.778988251681767 +14,13,-25.8594118562883,25.8594118562883 +14,14,60.2278255712463,60.2278255712463 +14,15,-26.0202741131366,26.0202741131366 +14,16,0.489159823757745,0.489159823757745 +14,17,0.322853210832363,0.322853210832363 +14,18,0.174135728059355,0.174135728059355 +14,19,0.0615525408420581,0.0615525408420581 +15,1,0.0533015409587279,0.0533015409587279 +15,2,0.0658955556787078,0.0658955556787078 +15,3,0.0820913115262556,0.0820913115262556 +15,4,0.102671826596179,0.102671826596179 +15,5,0.128817490008259,0.128817490008259 +15,6,0.162322244534607,0.162322244534607 +15,7,0.205798578178928,0.205798578178928 +15,8,0.262764743297339,0.262764743297339 +15,9,0.337586847365401,0.337586847365401 +15,10,0.426511093110093,0.426511093110093 +15,11,0.520714316110116,0.520714316110116 +15,12,0.603862915227182,0.603862915227182 +15,13,0.653629150804136,0.653629150804136 +15,14,-26.0202741131366,26.0202741131366 +15,15,60.2148446660067,60.2148446660067 +15,16,-26.2040870779856,26.2040870779856 +15,17,0.316747517103977,0.316747517103977 +15,18,0.176981806987442,0.176981806987442 +15,19,0.0646691589167858,0.0646691589167858 +16,1,0.0344137163210689,0.0344137163210689 +16,2,0.0425471213816309,0.0425471213816309 +16,3,0.0530086197159108,0.0530086197159108 +16,4,0.0663012755808268,0.0663012755808268 +16,5,0.0831706259418752,0.0831706259418752 +16,6,0.104741459949764,0.104741459949764 +16,7,0.132650157524949,0.132650157524949 +16,8,0.169130487392977,0.169130487392977 +16,9,0.217029594296037,0.217029594296037 +16,10,0.279618816989569,0.279618816989569 +16,11,0.35181919599836,0.35181919599836 +16,12,0.423290913431629,0.423290913431629 +16,13,0.476630290791036,0.476630290791036 +16,14,0.489159823757745,0.489159823757745 +16,15,-26.2040870779856,26.2040870779856 +16,16,60.2195150634088,60.2195150634088 +16,17,-26.3851454462838,26.3851454462838 +16,18,0.163300661881749,0.163300661881749 +16,19,0.0618930684481711,0.0618930684481711 +17,1,0.0200055083610096,0.0200055083610096 +17,2,0.0247339926318091,0.0247339926318091 +17,3,0.0308167713569674,0.0308167713569674 +17,4,0.0385472986863533,0.0385472986863533 +17,5,0.04835492006805,0.04835492006805 +17,6,0.0608824990422363,0.0608824990422363 +17,7,0.0770622676782295,0.0770622676782295 +17,8,0.0981692949153512,0.0981692949153512 +17,9,0.12585452680014,0.12585452680014 +17,10,0.162117785862392,0.162117785862392 +17,11,0.209089989766653,0.209089989766653 +17,12,0.260363286486154,0.260363286486154 +17,13,0.304390468386373,0.304390468386373 +17,14,0.322853210832363,0.322853210832363 +17,15,0.316747517103977,0.316747517103977 +17,16,-26.3851454462838,26.3851454462838 +17,17,60.2491752488277,60.2491752488277 +17,18,-26.5319428649491,26.5319428649491 +17,19,0.0531219512822645,0.0531219512822645 +18,1,0.0097536201337789,0.0097536201337789 +18,2,0.0120591536756365,0.0120591536756365 +18,3,0.0150249590546991,0.0150249590546991 +18,4,0.0187947435392744,0.0187947435392744 +18,5,0.0235777619650435,0.0235777619650435 +18,6,0.0296848767789015,0.0296848767789015 +18,7,0.037564973354165,0.037564973354165 +18,8,0.0478324935215824,0.0478324935215824 +18,9,0.061286264951832,0.061286264951832 +18,10,0.0789086129771022,0.0789086129771022 +18,11,0.101823260933998,0.101823260933998 +18,12,0.131161748129216,0.131161748129216 +18,13,0.159440460834048,0.159440460834048 +18,14,0.174135728059355,0.174135728059355 +18,15,0.176981806987442,0.176981806987442 +18,16,0.163300661881749,0.163300661881749 +18,17,-26.5319428649491,26.5319428649491 +18,18,60.3067960014414,60.3067960014414 +18,19,-26.6264992276888,26.6264992276888 +19,1,0.00319442250429347,0.00319442250429347 +19,2,0.00394937416103858,0.00394937416103858 +19,3,0.00492086371650657,0.00492086371650657 +19,4,0.00615543171988975,0.00615543171988975 +19,5,0.00772200081655683,0.00772200081655683 +19,6,0.00972271152477333,0.00972271152477333 +19,7,0.0123028698340022,0.0123028698340022 +19,8,0.0156624935243599,0.0156624935243599 +19,9,0.0200621741441864,0.0200621741441864 +19,10,0.025824320459833,0.025824320459833 +19,11,0.0333223226789414,0.0333223226789414 +19,12,0.0429610125252111,0.0429610125252111 +19,13,0.0551457546293932,0.0551457546293932 +19,14,0.0615525408420581,0.0615525408420581 +19,15,0.0646691589167858,0.0646691589167858 +19,16,0.0618930684481711,0.0618930684481711 +19,17,0.0531219512822645,0.0531219512822645 +19,18,-26.6264992276888,26.6264992276888 +19,19,60.3803584908746,60.3803584908746 +19,20,-26.6666667414484,26.6666667414484 +20,19,-26.6666667414484,26.6666667414484 +20,20,44.4444452796233,44.4444452796233 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_threshold_diagnostic.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_threshold_diagnostic.csv new file mode 100644 index 0000000..a7ca45e --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_threshold_diagnostic.csv @@ -0,0 +1,12 @@ +threshold_type,threshold,absolute_threshold,kept_entries,total_entries,kept_entry_share,retained_abs_share,relative_frobenius_error,min_eigenvalue,max_eigenvalue,positive_definite,condition_number_abs +absolute,0.1,0.1,274,400,0.685,0.998353992193224,0.00151175910525125,10.1833420949015,112.23826925338,yes,11.0217518185483 +absolute,0.01,0.01,350,400,0.875,0.999959980695351,8.39491870429254e-05,10.219403074646,112.239388437245,yes,10.9829691242639 +absolute,0.001,0.001,364,400,0.91,1,0,10.2183884027573,112.239031834401,yes,10.9840248198155 +absolute,0.0001,0.0001,364,400,0.91,1,0,10.2183884027573,112.239031834401,yes,10.9840248198155 +absolute,1e-05,1e-05,364,400,0.91,1,0,10.2183884027573,112.239031834401,yes,10.9840248198155 +absolute,1e-06,1e-06,364,400,0.91,1,0,10.2183884027573,112.239031834401,yes,10.9840248198155 +relative_to_max_abs,0.1,6.15960537686533,58,400,0.145,0.953689798960509,0.0258896620352103,8.91212659187872,111.422337813405,yes,12.5023288958821 +relative_to_max_abs,0.01,0.615960537686533,124,400,0.31,0.977688240239404,0.0138562252503606,9.5256924072608,112.511858917748,yes,11.8114100379714 +relative_to_max_abs,0.001,0.0615960537686533,296,400,0.74,0.999103181317721,0.000938754163576224,10.2026184442977,112.245968787763,yes,11.0016824995056 +relative_to_max_abs,0.0001,0.00615960537686533,356,400,0.89,0.999983945823235,4.28629954413478e-05,10.2186348893211,112.239157814569,yes,10.9837721995395 +relative_to_max_abs,1e-05,0.000615960537686533,364,400,0.91,1,0,10.2183884027573,112.239031834401,yes,10.9840248198155 diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.csv new file mode 100644 index 0000000..d1b5327 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.csv @@ -0,0 +1,25 @@ +metric,target,value,extra +random_effects,,20, +total_entries,,400, +structural_nonzeros,,364, +structural_density,,0.91, +positive_definite,,yes, +min_eigenvalue,,10.2183884027573, +max_eigenvalue,,112.239031834401, +condition_number,,10.9840248198155, +effective_sparsity_entries,90%,54,compression_vs_structural=6.74074074074074 +effective_sparsity_entries,95%,58,compression_vs_structural=6.27586206896552 +effective_sparsity_entries,97%,100,compression_vs_structural=3.64 +effective_sparsity_entries,98%,133,compression_vs_structural=2.73684210526316 +effective_sparsity_entries,99%,183,compression_vs_structural=1.98907103825137 +effective_sparsity_entries,99.5%,224,compression_vs_structural=1.625 +effective_sparsity_entries,99.9%,293,compression_vs_structural=1.24232081911263 +effective_sparsity_entries,100%,364,compression_vs_structural=1 +effective_bandwidth,90%,1, +effective_bandwidth,95%,1, +effective_bandwidth,97%,2, +effective_bandwidth,98%,3, +effective_bandwidth,99%,5, +effective_bandwidth,99.5%,7, +effective_bandwidth,99.9%,10, +effective_bandwidth,100%,19, diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.txt b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.txt new file mode 100644 index 0000000..07790ec --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_laplace_structure_report.txt @@ -0,0 +1,42 @@ +Laplace Structure Report +======================== + +Random effects: 20 +Matrix size: 20 x 20 +Total entries: 400 +Structural nonzeros: 364 / 400 (91%) +Nonzero tolerance: 1e-08 +Max |H_ij|: 61.5960537686533 +Positive definite: yes +Min eigenvalue: 10.2183884027573 +Max eigenvalue: 112.239031834401 +Condition number: 10.9840248198155 + +Effective sparsity +------------------ +curvature_retained,entries_required,entry_share,compression_vs_structural +90%,54,0.135,6.74074074074074 +95%,58,0.145,6.27586206896552 +97%,100,0.25,3.64 +98%,133,0.3325,2.73684210526316 +99%,183,0.4575,1.98907103825137 +99.5%,224,0.56,1.625 +99.9%,293,0.7325,1.24232081911263 +100%,364,0.91,1 + +Effective bandwidth +------------------- +curvature_retained,bandwidth,entry_count_if_banded,entry_share_if_banded +90%,1,58,0.145 +95%,1,58,0.145 +97%,2,94,0.235 +98%,3,128,0.32 +99%,5,190,0.475 +99.5%,7,244,0.61 +99.9%,10,310,0.775 +100%,19,400,1 + +Interpretation +-------------- +This report measures numerical curvature concentration, not only symbolic sparsity. +A dense structural Hessian can still be effectively sparse if most curvature is carried by relatively few entries or bands. diff --git a/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_recruitment_deviations.csv b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_recruitment_deviations.csv new file mode 100644 index 0000000..7aaf72f --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_recruitment_deviations.csv @@ -0,0 +1,21 @@ +year,log_rec_dev,ar1_rho,innovation +1,-0.00979634,0.6,-0.00979634 +2,0.00404492,0.6,0.00992273 +3,0.0260044,0.6,0.0235774 +4,0.0504067,0.6,0.0348041 +5,0.0738874,0.6,0.0436434 +6,0.0947748,0.6,0.0504424 +7,0.112447,0.6,0.0555826 +8,0.12662,0.6,0.0591512 +9,0.137056,0.6,0.0610843 +10,0.143527,0.6,0.0612938 +11,0.145654,0.6,0.0595377 +12,0.142964,0.6,0.0555711 +13,0.135031,0.6,0.049253 +14,0.121863,0.6,0.0408444 +15,0.10449,0.6,0.0313726 +16,0.0844772,0.6,0.0217829 +17,0.0638514,0.6,0.0131651 +18,0.0446927,0.6,0.0063819 +19,0.0287653,0.6,0.00194966 +20,0.0172592,0.6,-7.49061e-19 diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_fixed_effect_diagnostics.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_fixed_effect_diagnostics.hpp new file mode 100644 index 0000000..3a4794d --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_fixed_effect_diagnostics.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "../../../../../core/optimizer.hpp" + +#include +#include +#include +#include +#include + +namespace pollock_example { + +void write_fixed_gradient_diagnostics(const std::string &path, + const quadra::OptResult &fit) { + std::ofstream out(path); + out << std::setprecision(15); + out << "parameter,gradient,abs_gradient\n"; + + for (std::size_t i = 0; i < fit.fixed_gradient.size(); ++i) { + const std::string name = (i < fit.fixed_gradient_names.size()) + ? fit.fixed_gradient_names[i] + : ("fixed_" + std::to_string(i)); + const double g = fit.fixed_gradient[i]; + out << name << "," << g << "," << std::abs(g) << "\n"; + } +} + +std::size_t max_fixed_gradient_index(const quadra::OptResult &fit) { + std::size_t best = 0; + double best_abs = -1.0; + + for (std::size_t i = 0; i < fit.fixed_gradient.size(); ++i) { + const double a = std::abs(fit.fixed_gradient[i]); + if (a > best_abs) { + best = i; + best_abs = a; + } + } + + return best; +} + +void write_fixed_parameter_estimates(const std::string &path, + const quadra::OptResult &fit) { + std::ofstream out(path); + out << std::setprecision(15); + out << "parameter,estimate,exp_estimate\n"; + + for (std::size_t i = 0; i < fit.par.size(); ++i) { + const std::string name = (i < fit.fixed_gradient_names.size()) + ? fit.fixed_gradient_names[i] + : ("fixed_" + std::to_string(i)); + out << name << "," << fit.par[i] << "," << std::exp(fit.par[i]) << "\n"; + } +} + +} // namespace pollock_example + +// Compatibility aliases for the current Pollock implementation, which still +// calls these helpers unqualified from walleye_pollock.cpp. +using pollock_example::max_fixed_gradient_index; +using pollock_example::write_fixed_gradient_diagnostics; +using pollock_example::write_fixed_parameter_estimates; diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_fixed_hessian_diagnostics.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_fixed_hessian_diagnostics.hpp new file mode 100644 index 0000000..9fcc6ce --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_fixed_hessian_diagnostics.hpp @@ -0,0 +1,191 @@ +#pragma once + +#include "../model/pollock_laplace_helpers.hpp" +#include "../model/pollock_model.hpp" + +#include "../../../../../core/laplace/laplace_structure_report.hpp" +#include "../../../../../core/optimizer.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#ifdef WALLEYE_POLLOCK_FIXED_HESSIAN_DIAGNOSTICS + +namespace pollock_example { + +double pollock_profile_objective_at_fixed(PollockModel &model, + quadra::ParameterVector params, + const std::vector &fixed_idx, + const std::vector &random_idx, + const Eigen::VectorXd &x, + const quadra::LaplaceOptions &opts) { + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + params.params[static_cast(fixed_idx[k])].value = + x[static_cast(k)]; + } + + had::ADGraph graph; + + if (random_idx.empty()) { + std::vector p_double; + p_double.reserve(static_cast(params.size())); + for (int i = 0; i < params.size(); ++i) { + p_double.emplace_back(params.params[static_cast(i)].value); + } + return model(p_double); + } + + const auto u_hat = quadra::solve_random_effects_laplace( + model, params, x, fixed_idx, random_idx, graph); + const auto res = quadra::laplace_eval_at_u_star( + model, params, fixed_idx, random_idx, x, u_hat, graph, opts); + return res.value; +} + +void write_fixed_hessian_diagnostics(const std::string &summary_path, + const std::string &matrix_path, + PollockModel &model, + const quadra::ParameterVector ¶ms_in, + const quadra::OptResult &fit, + const quadra::LaplaceOptions &opts) { + std::ofstream summary(summary_path); + summary << std::setprecision(15); + summary << "field,value\n"; + + quadra::ParameterVector params = params_in; + const auto fixed_idx = quadra::build_fixed_index(params); + const auto random_idx = quadra::build_random_index(params); + + const Eigen::Index n = static_cast(fixed_idx.size()); + summary << "fixed_effects," << n << "\n"; + + if (n == 0 || fit.par.size() != static_cast(n)) { + summary << "available,no\n"; + summary << "reason,missing fixed-effect vector\n"; + return; + } + + try { + Eigen::VectorXd x(n); + for (Eigen::Index i = 0; i < n; ++i) { + x[i] = fit.par[static_cast(i)]; + } + + const double eps = 1.0e-4; + Eigen::MatrixXd H = Eigen::MatrixXd::Zero(n, n); + + const double f0 = pollock_profile_objective_at_fixed( + model, params, fixed_idx, random_idx, x, opts); + + for (Eigen::Index i = 0; i < n; ++i) { + Eigen::VectorXd xp = x; + Eigen::VectorXd xm = x; + xp[i] += eps; + xm[i] -= eps; + + const double fp = pollock_profile_objective_at_fixed( + model, params, fixed_idx, random_idx, xp, opts); + const double fm = pollock_profile_objective_at_fixed( + model, params, fixed_idx, random_idx, xm, opts); + + H(i, i) = (fp - 2.0 * f0 + fm) / (eps * eps); + + for (Eigen::Index j = i + 1; j < n; ++j) { + Eigen::VectorXd xpp = x; + Eigen::VectorXd xpm = x; + Eigen::VectorXd xmp = x; + Eigen::VectorXd xmm = x; + + xpp[i] += eps; + xpp[j] += eps; + xpm[i] += eps; + xpm[j] -= eps; + xmp[i] -= eps; + xmp[j] += eps; + xmm[i] -= eps; + xmm[j] -= eps; + + const double fpp = pollock_profile_objective_at_fixed( + model, params, fixed_idx, random_idx, xpp, opts); + const double fpm = pollock_profile_objective_at_fixed( + model, params, fixed_idx, random_idx, xpm, opts); + const double fmp = pollock_profile_objective_at_fixed( + model, params, fixed_idx, random_idx, xmp, opts); + const double fmm = pollock_profile_objective_at_fixed( + model, params, fixed_idx, random_idx, xmm, opts); + + const double hij = (fpp - fpm - fmp + fmm) / (4.0 * eps * eps); + H(i, j) = hij; + H(j, i) = hij; + } + } + + Eigen::SelfAdjointEigenSolver es(H); + + summary << "available,yes\n"; + summary << "fd_step," << eps << "\n"; + summary << "profile_objective," << f0 << "\n"; + summary << "min_diagonal," << H.diagonal().minCoeff() << "\n"; + summary << "max_diagonal," << H.diagonal().maxCoeff() << "\n"; + + if (es.info() == Eigen::Success) { + const auto evals = es.eigenvalues(); + summary << "eigen_success,yes\n"; + summary << "min_eigenvalue," << evals.minCoeff() << "\n"; + summary << "max_eigenvalue," << evals.maxCoeff() << "\n"; + summary << "positive_definite," << (evals.minCoeff() > 0.0 ? "yes" : "no") + << "\n"; + + if (std::abs(evals.minCoeff()) > 0.0) { + summary << "condition_number_abs," + << std::abs(evals.maxCoeff()) / std::abs(evals.minCoeff()) + << "\n"; + } else { + summary << "condition_number_abs,inf\n"; + } + + summary << "eigenvalues"; + for (Eigen::Index i = 0; i < evals.size(); ++i) { + summary << (i == 0 ? "," : ";") << evals[i]; + } + summary << "\n"; + } else { + summary << "eigen_success,no\n"; + } + + std::ofstream mat(matrix_path); + mat << "parameter"; + for (std::size_t j = 0; j < fit.fixed_gradient_names.size(); ++j) { + mat << "," << fit.fixed_gradient_names[j]; + } + mat << "\n"; + + for (Eigen::Index i = 0; i < n; ++i) { + const std::string row_name = + (static_cast(i) < fit.fixed_gradient_names.size()) + ? fit.fixed_gradient_names[static_cast(i)] + : ("fixed_" + std::to_string(i)); + mat << row_name; + for (Eigen::Index j = 0; j < n; ++j) { + mat << "," << std::setprecision(15) << H(i, j); + } + mat << "\n"; + } + } catch (const std::exception &e) { + summary << "available,no\n"; + summary << "reason," << e.what() << "\n"; + } +} + +} // namespace pollock_example + +using pollock_example::pollock_profile_objective_at_fixed; +using pollock_example::write_fixed_hessian_diagnostics; + +#endif // WALLEYE_POLLOCK_FIXED_HESSIAN_DIAGNOSTICS diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_functional_analysis_diagnostics.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_functional_analysis_diagnostics.hpp new file mode 100644 index 0000000..9711799 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_functional_analysis_diagnostics.hpp @@ -0,0 +1,488 @@ +#pragma once + +#include "../diagnostics/pollock_fixed_effect_diagnostics.hpp" +#include "../diagnostics/pollock_fixed_hessian_diagnostics.hpp" +#include "../diagnostics/pollock_huu_diagnostics.hpp" +#include "../model/pollock_laplace_helpers.hpp" +#include "../model/pollock_model.hpp" + +#include "../../../../../core/laplace/functional_analysis_report.hpp" +#include "../../../../../core/laplace/laplace_structure_report.hpp" +#include "../../../../../core/optimizer.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pollock_example { + +// Shared low-level model-evaluation/index helpers. +// These must appear before FD/Huu and higher-level diagnostics. + +// Low-level dependencies used by the functional-analysis diagnostics. +// Keep these before the higher-level diagnostics that call them. + +void pollock_write_huu_sparsity(const std::string &path, PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit, + double tol = 1.0e-8) { + std::ofstream out(path); + out << "i,j,value,abs_value\n"; + out << std::setprecision(15); + + Eigen::MatrixXd dense = pollock_fd_huu(model, params, fit); + + for (Eigen::Index i = 0; i < dense.rows(); ++i) { + for (Eigen::Index j = 0; j < dense.cols(); ++j) { + const double v = dense(i, j); + if (std::abs(v) > tol) { + out << (i + 1) << "," << (j + 1) << "," << v << "," << std::abs(v) + << "\n"; + } + } + } +} + +void pollock_write_huu_band_summary(const std::string &path, + PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit, + double tol = 1.0e-8) { + Eigen::MatrixXd H = pollock_fd_huu(model, params, fit); + + std::ofstream out(path); + out << "band_distance,count,nonzero_count,mean_abs,max_abs,sum_abs,share_sum_" + "abs,cumulative_share_sum_abs\n"; + out << std::setprecision(15); + + if (H.rows() == 0) { + return; + } + + const Eigen::Index n = H.rows(); + std::vector sum_abs(static_cast(n), 0.0); + std::vector max_abs(static_cast(n), 0.0); + std::vector count(static_cast(n), 0); + std::vector nonzero_count(static_cast(n), 0); + + double total_abs = 0.0; + + // Use upper triangle including diagonal so each symmetric pair is counted + // once. + for (Eigen::Index i = 0; i < n; ++i) { + for (Eigen::Index j = i; j < n; ++j) { + const std::size_t d = static_cast(j - i); + const double av = std::abs(H(i, j)); + + count[d] += 1; + sum_abs[d] += av; + max_abs[d] = std::max(max_abs[d], av); + total_abs += av; + + if (av > tol) { + nonzero_count[d] += 1; + } + } + } + + double cumulative = 0.0; + for (std::size_t d = 0; d < static_cast(n); ++d) { + const double mean_abs = + count[d] > 0 ? sum_abs[d] / static_cast(count[d]) : 0.0; + const double share = total_abs > 0.0 ? sum_abs[d] / total_abs : 0.0; + cumulative += share; + + out << d << "," << count[d] << "," << nonzero_count[d] << "," << mean_abs + << "," << max_abs[d] << "," << sum_abs[d] << "," << share << "," + << cumulative << "\n"; + } +} + +void pollock_write_huu_bandlimit_diagnostic(const std::string &path, + PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit) { + Eigen::MatrixXd H = pollock_fd_huu(model, params, fit); + + std::ofstream out(path); + out << "band_width,kept_entries,total_entries,kept_entry_share," + "retained_abs_share,relative_frobenius_error," + "min_eigenvalue,max_eigenvalue,positive_definite,condition_number_" + "abs\n"; + out << std::setprecision(15); + + if (H.rows() == 0) { + return; + } + + const Eigen::Index n = H.rows(); + const double full_abs_sum = H.cwiseAbs().sum(); + const double full_frob = H.norm(); + + const std::vector bands = {0, 1, 2, 3, 5, 10, 20}; + + for (const int bw_raw : bands) { + const Eigen::Index bw = + std::min(static_cast(bw_raw), n - 1); + + Eigen::MatrixXd Hb = Eigen::MatrixXd::Zero(n, n); + std::size_t kept_entries = 0; + + for (Eigen::Index i = 0; i < n; ++i) { + for (Eigen::Index j = 0; j < n; ++j) { + if (std::abs(i - j) <= bw) { + Hb(i, j) = H(i, j); + ++kept_entries; + } + } + } + + const double retained_abs_share = + full_abs_sum > 0.0 ? Hb.cwiseAbs().sum() / full_abs_sum : 0.0; + const double rel_frob_error = + full_frob > 0.0 ? (H - Hb).norm() / full_frob : 0.0; + + Eigen::SelfAdjointEigenSolver eig(Hb); + const bool eig_ok = eig.info() == Eigen::Success; + double min_eval = std::numeric_limits::quiet_NaN(); + double max_eval = std::numeric_limits::quiet_NaN(); + bool pd = false; + double cond = std::numeric_limits::quiet_NaN(); + + if (eig_ok && eig.eigenvalues().size() > 0) { + min_eval = eig.eigenvalues().minCoeff(); + max_eval = eig.eigenvalues().maxCoeff(); + pd = min_eval > 0.0; + cond = std::abs(max_eval) / std::max(std::abs(min_eval), 1.0e-300); + } + + out << bw << "," << kept_entries << "," << static_cast(n * n) + << "," << static_cast(kept_entries) / static_cast(n * n) + << "," << retained_abs_share << "," << rel_frob_error << "," << min_eval + << "," << max_eval << "," << (pd ? "yes" : "no") << "," << cond << "\n"; + } +} + +void pollock_write_huu_threshold_diagnostic(const std::string &path, + PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit) { + Eigen::MatrixXd H = pollock_fd_huu(model, params, fit); + + std::ofstream out(path); + out << "threshold_type,threshold,absolute_threshold," + "kept_entries,total_entries,kept_entry_share," + "retained_abs_share,relative_frobenius_error," + "min_eigenvalue,max_eigenvalue,positive_definite,condition_number_" + "abs\n"; + out << std::setprecision(15); + + if (H.rows() == 0) { + return; + } + + const Eigen::Index n = H.rows(); + const double full_abs_sum = H.cwiseAbs().sum(); + const double full_frob = H.norm(); + const double max_abs = H.cwiseAbs().maxCoeff(); + + struct ThresholdSpec { + const char *type; + double threshold; + double absolute_threshold; + }; + + std::vector specs; + + for (double t : {1.0e-1, 1.0e-2, 1.0e-3, 1.0e-4, 1.0e-5, 1.0e-6}) { + specs.push_back({"absolute", t, t}); + } + + for (double r : {1.0e-1, 1.0e-2, 1.0e-3, 1.0e-4, 1.0e-5}) { + specs.push_back({"relative_to_max_abs", r, r * max_abs}); + } + + for (const auto &spec : specs) { + Eigen::MatrixXd Ht = Eigen::MatrixXd::Zero(n, n); + std::size_t kept_entries = 0; + + for (Eigen::Index i = 0; i < n; ++i) { + for (Eigen::Index j = 0; j < n; ++j) { + const double v = H(i, j); + if (std::abs(v) >= spec.absolute_threshold) { + Ht(i, j) = v; + ++kept_entries; + } + } + } + + const double retained_abs_share = + full_abs_sum > 0.0 ? Ht.cwiseAbs().sum() / full_abs_sum : 0.0; + const double rel_frob_error = + full_frob > 0.0 ? (H - Ht).norm() / full_frob : 0.0; + + Eigen::SelfAdjointEigenSolver eig(Ht); + const bool eig_ok = eig.info() == Eigen::Success; + double min_eval = std::numeric_limits::quiet_NaN(); + double max_eval = std::numeric_limits::quiet_NaN(); + bool pd = false; + double cond = std::numeric_limits::quiet_NaN(); + + if (eig_ok && eig.eigenvalues().size() > 0) { + min_eval = eig.eigenvalues().minCoeff(); + max_eval = eig.eigenvalues().maxCoeff(); + pd = min_eval > 0.0; + cond = std::abs(max_eval) / std::max(std::abs(min_eval), 1.0e-300); + } + + out << spec.type << "," << spec.threshold << "," << spec.absolute_threshold + << "," << kept_entries << "," << static_cast(n * n) << "," + << static_cast(kept_entries) / static_cast(n * n) << "," + << retained_abs_share << "," << rel_frob_error << "," << min_eval << "," + << max_eval << "," << (pd ? "yes" : "no") << "," << cond << "\n"; + } +} + +void pollock_write_laplace_structure_report(const std::string &path, + PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit, + double nonzero_tol = 1.0e-8) { + const Eigen::MatrixXd H = pollock_fd_huu(model, params, fit); + const auto report = + quadra::summarize_laplace_hessian_structure(H, nonzero_tol); + + quadra::write_laplace_structure_report_text(report, path); + quadra::write_laplace_structure_report_csv( + report, "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_laplace_structure_report.csv"); +} + +quadra::FunctionalGradientVolatilitySummary +pollock_compute_gradient_volatility_fd(PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit, + double perturbation_scale = 1.0e-5, + double fd_step = 1.0e-5) { + quadra::FunctionalGradientVolatilitySummary empty; + if (fit.fixed_gradient.empty()) { + return empty; + } + + Eigen::VectorXd x0 = fit.x; + if (x0.size() == 0) { + x0 = pollock_fixed_values(params); + } + + if (x0.size() == 0 || + x0.size() != static_cast(fit.fixed_gradient.size())) { + return empty; + } + + quadra::LaplaceOptions opts = quadra::default_laplace_options(); + + std::vector> gradient_samples; + gradient_samples.reserve(static_cast(x0.size() * 2)); + + for (Eigen::Index j = 0; j < x0.size(); ++j) { + const double dx = perturbation_scale * std::max(1.0, std::abs(x0(j))); + + for (double sign : {-1.0, 1.0}) { + Eigen::VectorXd xp = x0; + xp(j) += sign * dx; + gradient_samples.push_back(pollock_profile_gradient_fd_at_x( + model, params, xp, fit.u_hat, opts, fd_step)); + } + } + + return quadra::summarize_gradient_volatility( + gradient_samples, fit.fixed_gradient, fit.fixed_gradient_names, + perturbation_scale); +} + +std::vector pollock_parameter_geometry_fixed_indices( + const quadra::ParameterVector ¶ms) { + std::vector out; + for (std::size_t i = 0; i < params.params.size(); ++i) { + if (!params.params[i].is_random) { + out.push_back(static_cast(i)); + } + } + return out; +} + +std::vector pollock_parameter_geometry_random_indices( + const quadra::ParameterVector ¶ms) { + std::vector out; + for (std::size_t i = 0; i < params.params.size(); ++i) { + if (params.params[i].is_random) { + out.push_back(static_cast(i)); + } + } + return out; +} + +Eigen::MatrixXd pollock_parameter_geometry_fd_fixed_hessian( + PollockModel &model, quadra::ParameterVector params, + const quadra::OptResult &fit, const quadra::LaplaceOptions &opts, + double fd_step = 1.0e-4) { + Eigen::VectorXd x0 = fit.x; + if (x0.size() == 0) { + // Backward-compatible fallback. Prefer fit.x when available. + const auto fixed_idx = pollock_parameter_geometry_fixed_indices(params); + x0.resize(static_cast(fixed_idx.size())); + for (std::size_t i = 0; i < fixed_idx.size(); ++i) { + x0(static_cast(i)) = + params.params[static_cast(fixed_idx[i])].value; + } + } + + const Eigen::Index n = x0.size(); + Eigen::MatrixXd H = Eigen::MatrixXd::Zero(n, n); + + if (n == 0) { + return H; + } + + const auto fixed_idx = pollock_parameter_geometry_fixed_indices(params); + const auto random_idx = pollock_parameter_geometry_random_indices(params); + + auto eval = [&](const Eigen::VectorXd &x_eval) -> double { + had::ADGraph graph; + auto res = quadra::laplace_eval_at_u_star( + model, params, fixed_idx, random_idx, x_eval, fit.u_hat, graph, opts); + return res.value; + }; + + for (Eigen::Index i = 0; i < n; ++i) { + const double hi = fd_step * std::max(1.0, std::abs(x0(i))); + + // Diagonal second derivative. + { + Eigen::VectorXd xp = x0; + Eigen::VectorXd xm = x0; + xp(i) += hi; + xm(i) -= hi; + + const double f0 = eval(x0); + const double fp = eval(xp); + const double fm = eval(xm); + H(i, i) = (fp - 2.0 * f0 + fm) / (hi * hi); + } + + // Mixed second derivatives. + for (Eigen::Index j = i + 1; j < n; ++j) { + const double hj = fd_step * std::max(1.0, std::abs(x0(j))); + + Eigen::VectorXd xpp = x0; + Eigen::VectorXd xpm = x0; + Eigen::VectorXd xmp = x0; + Eigen::VectorXd xmm = x0; + + xpp(i) += hi; + xpp(j) += hj; + xpm(i) += hi; + xpm(j) -= hj; + xmp(i) -= hi; + xmp(j) += hj; + xmm(i) -= hi; + xmm(j) -= hj; + + const double fpp = eval(xpp); + const double fpm = eval(xpm); + const double fmp = eval(xmp); + const double fmm = eval(xmm); + + const double hij = (fpp - fpm - fmp + fmm) / (4.0 * hi * hj); + H(i, j) = hij; + H(j, i) = hij; + } + } + + return H; +} + +void pollock_write_functional_analysis_report(const std::string &text_path, + const std::string &csv_path, + PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit, + double nonzero_tol = 1.0e-8) { + const Eigen::MatrixXd H = pollock_fd_huu(model, params, fit); + + quadra::FunctionalOptimizationSummary opt; + opt.objective_value = fit.value; + opt.gradient_norm = fit.grad_norm; + opt.iterations = fit.iterations; + opt.converged = fit.converged; + opt.message = fit.message; + + if (!fit.fixed_gradient.empty()) { + const std::size_t max_i = max_fixed_gradient_index(fit); + opt.max_gradient_parameter = (max_i < fit.fixed_gradient_names.size()) + ? fit.fixed_gradient_names[max_i] + : ("fixed_" + std::to_string(max_i)); + opt.max_gradient_value = fit.fixed_gradient[max_i]; + opt.max_abs_gradient = std::abs(fit.fixed_gradient[max_i]); + } + + std::vector random_names; + random_names.reserve(fit.u_hat.size()); + for (std::size_t i = 0; i < fit.u_hat.size(); ++i) { + random_names.push_back("log_rec_dev_" + std::to_string(i + 1)); + } + + auto report = quadra::make_functional_analysis_report( + opt, H, fit.u_hat, nonzero_tol, random_names); + +#ifdef WALLEYE_POLLOCK_PARAMETER_GEOMETRY + { + quadra::LaplaceOptions hess_opts = quadra::default_laplace_options(); + const Eigen::MatrixXd Hxx = pollock_parameter_geometry_fd_fixed_hessian( + model, params, fit, hess_opts); + + report.parameter_geometry = quadra::summarize_parameter_geometry( + Hxx, fit.fixed_gradient, fit.fixed_gradient_names); + } +#endif + +#ifdef WALLEYE_POLLOCK_GRADIENT_VOLATILITY + { + report.gradient_volatility = pollock_compute_gradient_volatility_fd( + model, params, fit, 1.0e-5, 1.0e-5); + } +#endif + + quadra::write_functional_analysis_report_text(report, text_path); + quadra::write_functional_analysis_report_csv(report, csv_path); +} + +} // namespace pollock_example + +// Compatibility aliases for current walleye_pollock.cpp call sites. +using pollock_example::pollock_compute_gradient_volatility_fd; +using pollock_example::pollock_parameter_geometry_fd_fixed_hessian; +using pollock_example::pollock_parameter_geometry_fixed_indices; +using pollock_example::pollock_parameter_geometry_random_indices; +using pollock_example::pollock_write_functional_analysis_report; +using pollock_example::pollock_write_huu_band_summary; +using pollock_example::pollock_write_huu_bandlimit_diagnostic; +using pollock_example::pollock_write_huu_sparsity; +using pollock_example::pollock_write_huu_threshold_diagnostic; +using pollock_example::pollock_write_laplace_structure_report; diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_huu_diagnostics.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_huu_diagnostics.hpp new file mode 100644 index 0000000..7280007 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_huu_diagnostics.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "../model/pollock_model.hpp" + +#include "../../../../../core/laplace/laplace_structure_report.hpp" +#include "../../../../../core/optimizer.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifdef WALLEYE_POLLOCK_HUU_DIAGNOSTICS + +namespace pollock_example { + +void pollock_write_huu_diagnostics(const std::string &path, PollockModel &model, + quadra::ParameterVector ¶ms, + const quadra::OptResult &fit) { + std::ofstream out(path); + out << std::setprecision(15); + out << "field,value\n"; + out << "random_effects," << fit.u_hat.size() << "\n"; + + if (fit.u_hat.empty()) { + out << "available,no\n"; + out << "reason,no random effects\n"; + return; + } + + try { + const auto fixed_idx = quadra::build_fixed_index(params); + const auto random_idx = quadra::build_random_index(params); + + for (std::size_t k = 0; k < fixed_idx.size() && k < fit.par.size(); ++k) { + params.params[static_cast(fixed_idx[k])].value = fit.par[k]; + } + + for (std::size_t k = 0; k < random_idx.size() && k < fit.u_hat.size(); + ++k) { + params.params[static_cast(random_idx[k])].value = + fit.u_hat[k]; + } + + had::ADGraph graph; + quadra::ADScope scope(graph); + + std::vector p_full; + p_full.reserve(static_cast(params.size())); + for (int i = 0; i < params.size(); ++i) { + p_full.emplace_back( + quadra::AD(params.params[static_cast(i)].value)); + } + + quadra::AD nll = model(p_full); + scope.backward(nll); + + const auto &pattern = quadra::get_pattern(scope, p_full, random_idx); + Eigen::SparseMatrix H = + quadra::extract_sparse_hessian(scope, p_full, random_idx, pattern); + + Eigen::MatrixXd dense = Eigen::MatrixXd(H); + Eigen::SelfAdjointEigenSolver es(dense); + + out << "available,yes\n"; + out << "pattern_entries," << pattern.size() << "\n"; + out << "hessian_nonzeros," << H.nonZeros() << "\n"; + out << "min_diagonal," << dense.diagonal().minCoeff() << "\n"; + out << "max_diagonal," << dense.diagonal().maxCoeff() << "\n"; + + if (es.info() == Eigen::Success) { + const auto evals = es.eigenvalues(); + out << "eigen_success,yes\n"; + out << "min_eigenvalue," << evals.minCoeff() << "\n"; + out << "max_eigenvalue," << evals.maxCoeff() << "\n"; + out << "positive_definite," << (evals.minCoeff() > 0.0 ? "yes" : "no") + << "\n"; + if (std::abs(evals.minCoeff()) > 0.0) { + out << "condition_number_abs," + << std::abs(evals.maxCoeff()) / std::abs(evals.minCoeff()) << "\n"; + } else { + out << "condition_number_abs,inf\n"; + } + } else { + out << "eigen_success,no\n"; + } + } catch (const std::exception &e) { + out << "available,no\n"; + out << "reason," << e.what() << "\n"; + } +} + +} // namespace pollock_example + +using pollock_example::pollock_write_huu_diagnostics; + +#endif // WALLEYE_POLLOCK_HUU_DIAGNOSTICS diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_huu_output_diagnostics.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_huu_output_diagnostics.hpp new file mode 100644 index 0000000..3297042 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_huu_output_diagnostics.hpp @@ -0,0 +1,145 @@ +#pragma once + +#include "../model/pollock_laplace_helpers.hpp" +#include "../model/pollock_model.hpp" + +#include + +#include +#include +#include +#include + +namespace pollock_example { + +void pollock_write_huu_matrix(const std::string &path, PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit) { + std::ofstream out(path); + out << std::setprecision(15); + + const auto random_idx = quadra::build_random_index(params); + const std::size_t n = random_idx.size(); + + out << "row"; + for (std::size_t j = 0; j < n; ++j) { + out << ",u" << (j + 1); + } + out << "\n"; + + Eigen::MatrixXd dense = pollock_fd_huu(model, params, fit); + + for (std::size_t i = 0; i < n; ++i) { + out << "u" << (i + 1); + for (std::size_t j = 0; j < n; ++j) { + out << "," + << dense(static_cast(i), static_cast(j)); + } + out << "\n"; + } +} + +void pollock_write_huu_pattern_compare(const std::string &path, + PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit, + double tol = 1.0e-8) { + std::ofstream out(path); + out << "field,value\n"; + + const auto fixed_idx = quadra::build_fixed_index(params); + const auto random_idx = quadra::build_random_index(params); + const std::size_t n = random_idx.size(); + + out << "random_effects," << n << "\n"; + out << "fd_tol," << tol << "\n"; + out << "quadra_pattern_available," << (fit.pattern.available ? "yes" : "no") + << "\n"; + out << "quadra_pattern_detected_structure," << fit.pattern.detected_structure + << "\n"; + out << "quadra_pattern_nonzeros_reported," << fit.pattern.nonzeros << "\n"; + + if (n == 0 || fit.par.size() != fixed_idx.size() || fit.u_hat.size() != n) { + out << "available,no\n"; + out << "reason,missing random effects or size mismatch\n"; + return; + } + + Eigen::MatrixXd Hfd = pollock_fd_huu(model, params, fit); + + std::size_t fd_nonzeros_all = 0; + std::size_t fd_nonzeros_upper = 0; + std::size_t fd_nonzeros_diag = 0; + double max_abs_fd = 0.0; + double min_abs_fd_nonzero = std::numeric_limits::infinity(); + + for (Eigen::Index i = 0; i < Hfd.rows(); ++i) { + for (Eigen::Index j = 0; j < Hfd.cols(); ++j) { + const double av = std::abs(Hfd(i, j)); + max_abs_fd = std::max(max_abs_fd, av); + if (av > tol) { + ++fd_nonzeros_all; + min_abs_fd_nonzero = std::min(min_abs_fd_nonzero, av); + if (i <= j) { + ++fd_nonzeros_upper; + } + if (i == j) { + ++fd_nonzeros_diag; + } + } + } + } + + const std::size_t fd_nonzeros_offdiag_all = + fd_nonzeros_all >= fd_nonzeros_diag ? fd_nonzeros_all - fd_nonzeros_diag + : 0; + const std::size_t fd_nonzeros_offdiag_upper = + fd_nonzeros_upper >= fd_nonzeros_diag + ? fd_nonzeros_upper - fd_nonzeros_diag + : 0; + + out << "available,yes\n"; + out << "fd_nonzeros_all," << fd_nonzeros_all << "\n"; + out << "fd_nonzeros_upper_including_diag," << fd_nonzeros_upper << "\n"; + out << "fd_nonzeros_diag," << fd_nonzeros_diag << "\n"; + out << "fd_nonzeros_offdiag_all," << fd_nonzeros_offdiag_all << "\n"; + out << "fd_nonzeros_offdiag_upper," << fd_nonzeros_offdiag_upper << "\n"; + out << "fd_density_all," + << (n == 0 ? 0.0 + : static_cast(fd_nonzeros_all) / + static_cast(n * n)) + << "\n"; + out << "fd_density_upper," + << (n == 0 ? 0.0 + : static_cast(fd_nonzeros_upper) / + static_cast((n * (n + 1)) / 2)) + << "\n"; + out << "max_abs_fd," << max_abs_fd << "\n"; + out << "min_abs_fd_nonzero," + << (std::isfinite(min_abs_fd_nonzero) ? min_abs_fd_nonzero : 0.0) << "\n"; + out << "note,OptPatternInfo does not currently expose individual pattern " + "entries; this compares reported Quadra count to finite-difference " + "numerical sparsity.\n"; + + std::ofstream detail("examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_pattern_compare_detail.csv"); + detail << "i,j,fd_nonzero,fd_value,abs_fd_value,band_distance\n"; + detail << std::setprecision(15); + + for (Eigen::Index i = 0; i < Hfd.rows(); ++i) { + for (Eigen::Index j = 0; j < Hfd.cols(); ++j) { + const double v = Hfd(i, j); + const double av = std::abs(v); + if (av > tol) { + detail << (i + 1) << "," << (j + 1) << ",yes," << v << "," << av << "," + << std::abs(i - j) << "\n"; + } + } + } +} + +} // namespace pollock_example + +// Compatibility aliases for current walleye_pollock.cpp call sites. +using pollock_example::pollock_write_huu_matrix; +using pollock_example::pollock_write_huu_pattern_compare; diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_utilities.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_utilities.hpp new file mode 100644 index 0000000..786c7b6 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/diagnostics/pollock_utilities.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace pollock_example { + +inline std::string pollock_output_dir() { + return "examples/NMFS/afsc_walleye_pollock/outputs"; +} + +inline std::string pollock_output_path(const std::string &filename) { + return pollock_output_dir() + "/" + filename; +} + +inline std::vector pollock_random_effect_names(std::size_t n) { + std::vector names; + names.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + names.push_back("log_rec_dev_" + std::to_string(i + 1)); + } + return names; +} + +} // namespace pollock_example diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/drivers/pollock_driver_output.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/drivers/pollock_driver_output.hpp new file mode 100644 index 0000000..e96d601 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/drivers/pollock_driver_output.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "../diagnostics/pollock_fixed_effect_diagnostics.hpp" + +#include "../../../../../core/optimizer.hpp" + +#include +#include +#include +#include +#include +#include + +namespace pollock_example { + +inline void write_recruitment_deviations(const std::string &path, + const quadra::OptResult &fit, + double rec_rho_report = 0.60) { + std::ofstream rec(path); + rec << "year,log_rec_dev,ar1_rho,innovation\n"; + + for (std::size_t i = 0; i < fit.u_hat.size(); ++i) { + const double innovation = + (i == 0) ? fit.u_hat[i] + : (fit.u_hat[i] - rec_rho_report * fit.u_hat[i - 1]); + rec << (i + 1) << "," << fit.u_hat[i] << "," << rec_rho_report << "," + << innovation << "\n"; + } +} + +inline void print_fit_and_structure_diagnostics(const quadra::OptResult &fit) { + std::cout << "\nFit diagnostics\n"; + std::cout << "---------------\n"; + std::cout << std::fixed << std::setprecision(6); + std::cout << "objective " << fit.value << "\n"; + std::cout << "grad_norm " << fit.grad_norm << "\n"; + std::cout << "iterations " << fit.iterations << "\n"; + std::cout << "converged " << (fit.converged ? "yes" : "no") << "\n"; + std::cout << "message " << fit.message << "\n"; + + if (!fit.fixed_gradient.empty()) { + const std::size_t max_grad_i = max_fixed_gradient_index(fit); + const std::string max_grad_name = + (max_grad_i < fit.fixed_gradient_names.size()) + ? fit.fixed_gradient_names[max_grad_i] + : ("fixed_" + std::to_string(max_grad_i)); + std::cout << "max_grad_param " << max_grad_name << "\n"; + std::cout << "max_grad_value " << fit.fixed_gradient[max_grad_i] + << "\n"; + std::cout << "max_abs_grad " + << std::abs(fit.fixed_gradient[max_grad_i]) << "\n"; + } + + std::cout << "\nOptimizer structure diagnostics\n"; + std::cout << "-------------------------------\n"; + std::cout << "random effects " << fit.pattern.random_effect_count << "\n"; + std::cout << "pattern available " << (fit.pattern.available ? "yes" : "no") + << "\n"; + std::cout << "detected structure " << fit.pattern.detected_structure << "\n"; + std::cout << "Hessian nonzeros " << fit.pattern.nonzeros << "\n"; +} + +inline void print_output_manifest() { + std::cout << "\nWrote outputs:\n"; + std::cout << " " + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_fit_summary.csv\n"; + std::cout << " " + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_recruitment_deviations.csv\n"; +} + +} // namespace pollock_example diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/drivers/run_pollock_showcase.cpp b/examples/NMFS/afsc_walleye_pollock/quadra/drivers/run_pollock_showcase.cpp new file mode 100644 index 0000000..fd83f1a --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/drivers/run_pollock_showcase.cpp @@ -0,0 +1,26 @@ +// Clean Pollock showcase driver +// ============================= +// +// This file intentionally keeps the public entry point small. The current +// implementation still lives in ../quadra/walleye_pollock.cpp while the model +// is being migrated into model/, data/, reports/, and diagnostics/. +// +// Why include the implementation file directly? +// - It keeps the current demo behavior unchanged. +// - It gives reviewers a clean entry point today. +// - It lets us move internals gradually without destabilizing the example. +// +// Final target: +// +// #include "../model/pollock_model.hpp" +// #include "../data/pollock_data.hpp" +// #include "../reports/pollock_reports.hpp" +// +// int main() { +// auto data = pollock_example::load_pollock_data(...); +// auto model = pollock_example::make_pollock_model(data); +// auto fit = quadra::optimize_lbfgs(model, params, opts); +// pollock_example::write_pollock_reports(...); +// } + +#include "../walleye_pollock.cpp" diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_constants.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_constants.hpp new file mode 100644 index 0000000..9243ef9 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_constants.hpp @@ -0,0 +1,15 @@ +#pragma once + +namespace pollock { + +inline constexpr int n_ages = 7; + +inline constexpr double weight_at_age[n_ages] = {0.20, 0.45, 0.75, 1.10, + 1.45, 1.75, 2.00}; + +inline constexpr double maturity_at_age[n_ages] = {0.00, 0.10, 0.45, 0.80, + 0.95, 1.00, 1.00}; + +inline constexpr double natural_mortality = 0.25; + +} // namespace pollock diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_laplace_helpers.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_laplace_helpers.hpp new file mode 100644 index 0000000..9caf863 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_laplace_helpers.hpp @@ -0,0 +1,187 @@ +#pragma once + +#include "pollock_model.hpp" + +#include "../../../../../core/laplace/functional_analysis_report.hpp" +#include "../../../../../core/laplace/laplace_structure_report.hpp" +#include "../../../../../core/optimizer.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pollock_example { + +std::vector pollock_fixed_indices(const quadra::ParameterVector ¶ms) { + std::vector out; + for (std::size_t i = 0; i < params.params.size(); ++i) { + if (!params.params[i].is_random) { + out.push_back(static_cast(i)); + } + } + return out; +} + +std::vector pollock_random_indices(const quadra::ParameterVector ¶ms) { + std::vector out; + for (std::size_t i = 0; i < params.params.size(); ++i) { + if (params.params[i].is_random) { + out.push_back(static_cast(i)); + } + } + return out; +} + +double pollock_joint_objective_at_x_u(PollockModel &model, + quadra::ParameterVector params, + const std::vector &fixed_idx, + const std::vector &random_idx, + const Eigen::VectorXd &x, + const std::vector &u) { + for (std::size_t k = 0; k < fixed_idx.size(); ++k) { + params.params[static_cast(fixed_idx[k])].value = + x[static_cast(k)]; + } + for (std::size_t k = 0; k < random_idx.size(); ++k) { + params.params[static_cast(random_idx[k])].value = u[k]; + } + + std::vector p_double; + p_double.reserve(static_cast(params.size())); + for (int i = 0; i < params.size(); ++i) { + p_double.emplace_back(params.params[static_cast(i)].value); + } + + return model(p_double); +} + +Eigen::VectorXd pollock_fixed_values(const quadra::ParameterVector ¶ms) { + const auto fixed_idx = pollock_fixed_indices(params); + Eigen::VectorXd x(static_cast(fixed_idx.size())); + for (std::size_t i = 0; i < fixed_idx.size(); ++i) { + x(static_cast(i)) = + params.params[static_cast(fixed_idx[i])].value; + } + return x; +} + +std::vector pollock_profile_gradient_fd_at_x( + PollockModel &model, quadra::ParameterVector params, + const Eigen::VectorXd &x, const std::vector &u_hat, + const quadra::LaplaceOptions &opts, double fd_step = 1.0e-5) { + const auto fixed_idx = pollock_fixed_indices(params); + const auto random_idx = pollock_random_indices(params); + + std::vector grad(static_cast(x.size()), 0.0); + + auto eval = [&](const Eigen::VectorXd &x_eval) -> double { + had::ADGraph graph; + auto res = quadra::laplace_eval_at_u_star( + model, params, fixed_idx, random_idx, x_eval, u_hat, graph, opts); + return res.value; + }; + + for (Eigen::Index j = 0; j < x.size(); ++j) { + const double h = fd_step * std::max(1.0, std::abs(x(j))); + Eigen::VectorXd xp = x; + Eigen::VectorXd xm = x; + xp(j) += h; + xm(j) -= h; + grad[static_cast(j)] = (eval(xp) - eval(xm)) / (2.0 * h); + } + + return grad; +} + +Eigen::MatrixXd pollock_fd_huu(PollockModel &model, + quadra::ParameterVector params, + const quadra::OptResult &fit, + double eps = 1.0e-4) { + const auto fixed_idx = quadra::build_fixed_index(params); + const auto random_idx = quadra::build_random_index(params); + + const Eigen::Index n = static_cast(random_idx.size()); + Eigen::MatrixXd H = Eigen::MatrixXd::Zero(n, n); + + if (n == 0 || fit.par.size() != fixed_idx.size() || + fit.u_hat.size() != random_idx.size()) { + return H; + } + + Eigen::VectorXd x(static_cast(fixed_idx.size())); + for (std::size_t i = 0; i < fixed_idx.size(); ++i) { + x[static_cast(i)] = fit.par[i]; + } + + std::vector u = fit.u_hat; + const double f0 = pollock_joint_objective_at_x_u(model, params, fixed_idx, + random_idx, x, u); + + for (Eigen::Index i = 0; i < n; ++i) { + std::vector up = u; + std::vector um = u; + up[static_cast(i)] += eps; + um[static_cast(i)] -= eps; + + const double fp = pollock_joint_objective_at_x_u(model, params, fixed_idx, + random_idx, x, up); + const double fm = pollock_joint_objective_at_x_u(model, params, fixed_idx, + random_idx, x, um); + + H(i, i) = (fp - 2.0 * f0 + fm) / (eps * eps); + + for (Eigen::Index j = i + 1; j < n; ++j) { + std::vector upp = u; + std::vector upm = u; + std::vector ump = u; + std::vector umm = u; + + upp[static_cast(i)] += eps; + upp[static_cast(j)] += eps; + + upm[static_cast(i)] += eps; + upm[static_cast(j)] -= eps; + + ump[static_cast(i)] -= eps; + ump[static_cast(j)] += eps; + + umm[static_cast(i)] -= eps; + umm[static_cast(j)] -= eps; + + const double fpp = pollock_joint_objective_at_x_u( + model, params, fixed_idx, random_idx, x, upp); + const double fpm = pollock_joint_objective_at_x_u( + model, params, fixed_idx, random_idx, x, upm); + const double fmp = pollock_joint_objective_at_x_u( + model, params, fixed_idx, random_idx, x, ump); + const double fmm = pollock_joint_objective_at_x_u( + model, params, fixed_idx, random_idx, x, umm); + + const double hij = (fpp - fpm - fmp + fmm) / (4.0 * eps * eps); + H(i, j) = hij; + H(j, i) = hij; + } + } + + return H; +} + +} // namespace pollock_example + +// Compatibility aliases for existing Pollock diagnostics/driver call sites. +using pollock_example::pollock_fd_huu; +using pollock_example::pollock_fixed_indices; +using pollock_example::pollock_fixed_values; +using pollock_example::pollock_joint_objective_at_x_u; +using pollock_example::pollock_profile_gradient_fd_at_x; +using pollock_example::pollock_random_indices; diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_model.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_model.hpp new file mode 100644 index 0000000..43dd2d0 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_model.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include "../../data/pollock_data.hpp" +#include "pollock_constants.hpp" + +#include +#include +#include +#include + +struct PollockModel { + explicit PollockModel(std::vector obs) : obs_(std::move(obs)) {} + + template AD operator()(const std::vector &p) const { + const AD log_r0 = p[0]; + const AD log_fbar = p[1]; + + const AD r0 = exp(log_r0); + const AD fbar = exp(log_fbar); + + // Assessment-like initialization: + // derive initial numbers-at-age from the same unfished recruitment scale + // that drives the stock-recruit curve. This removes the artificial + // R0/N0 conflict from the synthetic scaffold. + const AD n0 = r0; + + // Hold catchability fixed in this scaffold-level scaling experiment. + // This isolates recruitment/Laplace behavior from q-abundance + // confounding after selectivity has already been fixed. + const AD q = exp(AD(-8.78)); + + // Hold selectivity fixed in this scaffold-level scaling experiment. + // This isolates recruitment/Laplace behavior from selectivity-q-abundance + // confounding. + const AD sel_a50 = AD(4.0); + const AD sel_slope = AD(1.0); + + constexpr int A = 7; + const double weight[A] = {0.20, 0.45, 0.75, 1.10, 1.45, 1.75, 2.00}; + const double maturity[A] = {0.00, 0.10, 0.45, 0.80, 0.95, 1.00, 1.00}; + const double M = 0.25; + + AD nll = AD(0.0); + nll += AD(0.5) * pow((log_r0 - AD(8.0)) / AD(4.0), 2.0); + nll += AD(0.5) * pow((log_fbar - AD(-3.7)) / AD(3.0), 2.0); + + // Equilibrium numbers-at-age from the R0 recruitment scale. + // Ages 1..A-1 follow survivorship; the terminal age is a plus group + // accumulating survivors from all older cohorts. + std::vector N(pollock::n_ages); + const AD surv = exp(-AD(pollock::natural_mortality)); + N[0] = r0; + for (int a = 1; a < pollock::n_ages - 1; ++a) { + N[a] = N[a - 1] * surv; + } + N[pollock::n_ages - 1] = N[pollock::n_ages - 2] * surv / (AD(1.0) - surv); + + const AD rec_sigma = AD(0.15); + const AD rec_rho = AD(0.60); + const AD rec_stationary_sigma = + rec_sigma / sqrt(AD(1.0) - rec_rho * rec_rho); + const AD index_sigma = AD(0.30); + const AD catch_sigma = AD(0.25); +#ifdef WALLEYE_POLLOCK_FIT_CATCH_LIKELIHOOD + const AD catch_w = AD(1.0); +#else + const AD catch_w = AD(0.0); +#endif + const AD age_w = AD(0.0); + + for (std::size_t y = 0; y < obs_.size(); ++y) { + const std::size_t rec_offset = 2; + const bool has_rec_dev = (p.size() > rec_offset + y); + const AD rec_dev = has_rec_dev ? p[rec_offset + y] : AD(0.0); + + if (has_rec_dev) { + if (y == 0) { + // Stationary AR(1) prior for the initial recruitment deviation. + nll += AD(0.5) * pow(rec_dev / rec_stationary_sigma, 2.0) + + log(rec_stationary_sigma); + } else { + const bool has_prev_rec_dev = (p.size() > rec_offset + y - 1); + const AD prev_rec_dev = + has_prev_rec_dev ? p[rec_offset + y - 1] : AD(0.0); + const AD innovation = rec_dev - rec_rho * prev_rec_dev; + + nll += AD(0.5) * pow(innovation / rec_sigma, 2.0) + log(rec_sigma); + } + } + + AD biomass = AD(0.0); + AD ssb = AD(0.0); + AD pred_catch = AD(0.0); + std::vector caa(pollock::n_ages); + + for (int a = 0; a < pollock::n_ages; ++a) { + const AD sel = + AD(1.0) / (AD(1.0) + exp(-sel_slope * (AD(a + 1) - sel_a50))); + const AD Z = AD(pollock::natural_mortality) + fbar * sel; + biomass += N[a] * AD(pollock::weight_at_age[a]); + ssb += + N[a] * AD(pollock::weight_at_age[a] * pollock::maturity_at_age[a]); + caa[a] = N[a] * (fbar * sel / Z) * (AD(1.0) - exp(-Z)) * + AD(pollock::weight_at_age[a]); + pred_catch += caa[a]; + } + + const AD pred_index = q * biomass; + + nll += AD(0.5) * pow((log(AD(obs_[y].index) + AD(1e-12)) - + log(pred_index + AD(1e-12))) / + index_sigma, + 2.0); + if (catch_w > AD(0.0)) { + nll += catch_w * AD(0.5) * + pow((log(AD(obs_[y].catch_mt) + AD(1e-12)) - + log(pred_catch + AD(1e-12))) / + catch_sigma, + 2.0); + } + + if (age_w > AD(0.0)) { + for (int a = 0; a < pollock::n_ages; ++a) { + const AD pred_p = caa[a] / (pred_catch + AD(1e-12)); + nll -= age_w * AD(obs_[y].age[a]) * log(pred_p + AD(1e-12)); + } + } + + std::vector next(pollock::n_ages); + // Treat observed catch as the removals driver for this synthetic + // assessment scaffold. fbar still controls age-specific selectivity and + // relative exploitation, but total removals are scaled toward observed + // catch rather than forcing a single constant F to fit the catch series. + const AD catch_scale_raw = + AD(obs_[y].catch_mt) / (pred_catch + AD(1.0e-12)); + const AD catch_scale = + (catch_scale_raw < AD(0.95)) ? catch_scale_raw : AD(0.95); + + for (int a = 0; a < pollock::n_ages; ++a) { + const AD catch_number = catch_scale * caa[a] / + (AD(pollock::weight_at_age[a]) + AD(1.0e-12)); + N[a] = (N[a] > catch_number) ? (N[a] - catch_number) : AD(1.0e-12); + } + + // Ricker-style stock-recruitment relationship. + // + // This synthetic scaffold anchors the curve so that R(B0) = R0 at an + // approximate unfished spawning biomass B0. Recruitment deviations remain + // multiplicative lognormal random effects around the stock-recruit curve. + const AD b0 = r0 * AD(4.0); + const AD beta = AD(1.0) / (b0 + AD(1.0e-12)); + const AD alpha = r0 * exp(beta * b0) / (b0 + AD(1.0e-12)); + const AD recruitment = alpha * ssb * exp(-beta * ssb + rec_dev); + + next[0] = recruitment; + for (int a = 1; a < pollock::n_ages; ++a) + next[a] = N[a - 1] * exp(-AD(pollock::natural_mortality)); + next[pollock::n_ages - 1] += + N[pollock::n_ages - 1] * exp(-AD(pollock::natural_mortality)); + N = next; + } + + return nll; + } + + std::vector obs_; +}; diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_parameters.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_parameters.hpp new file mode 100644 index 0000000..ed1c2f7 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/model/pollock_parameters.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include "../../../../../core/optimizer.hpp" + +namespace pollock { + +inline quadra::ParameterVector make_params(std::size_t n_years) { + quadra::ParameterVector p; + + auto add_param = [&](const std::string &name, double value, bool random) { + p.add(quadra::Parameter(name, value, quadra::ParameterTransform::Identity, + random)); + }; + + add_param("log_r0", 8.0, false); + add_param("log_fbar", -3.7, false); + +#ifdef WALLEYE_POLLOCK_RANDOM_RECRUITMENT_COUNT + const std::size_t n_random_recruitment = std::min( + n_years, + static_cast(WALLEYE_POLLOCK_RANDOM_RECRUITMENT_COUNT)); + + for (std::size_t i = 0; i < n_random_recruitment; ++i) { + add_param("log_rec_dev_" + std::to_string(i + 1), 0.0, true); + } +#else + (void)n_years; +#endif + + return p; +} + +} // namespace pollock diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/reports/pollock_fit_summary.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/reports/pollock_fit_summary.hpp new file mode 100644 index 0000000..e121e7d --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/reports/pollock_fit_summary.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "../../../../../core/optimizer.hpp" + +#include +#include +#include + +namespace pollock_example { + +void write_summary(const std::string &path, const quadra::OptResult &fit) { + std::ofstream out(path); + out << std::setprecision(15); + out << "field,value\n"; + out << "objective," << fit.value << "\n"; + out << "grad_norm," << fit.grad_norm << "\n"; + out << "iterations," << fit.iterations << "\n"; + out << "converged," << (fit.converged ? "yes" : "no") << "\n"; + out << "message," << fit.message << "\n"; + out << "random_effects," << fit.u_hat.size() << "\n"; +} + +} // namespace pollock_example + +// Compatibility alias for current walleye_pollock.cpp call sites. +using pollock_example::write_summary; diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/reports/pollock_reports.hpp b/examples/NMFS/afsc_walleye_pollock/quadra/reports/pollock_reports.hpp new file mode 100644 index 0000000..4fc2cc4 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/reports/pollock_reports.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "../../../../../core/diagnostics/functional_analysis.hpp" + +#include + +namespace pollock_example { + +inline void +write_pollock_markdown_report(const std::string &md_path, + const std::string &functional_csv_path, + const std::string &structure_txt_path) { + quadra::diagnostics::MarkdownReportConfig config; + config.title = "Synthetic Walleye Pollock Functional Analysis"; + config.subtitle = + "Synthetic and public-data-safe. Not an official assessment."; + config.output_path = md_path; + config.functional_csv_path = functional_csv_path; + config.structure_txt_path = structure_txt_path; + config.fixed_effects = "2"; + config.total_estimated = "22"; + config.effective_entries_95 = "58"; + config.effective_bandwidth_95 = "1"; + + quadra::diagnostics::write_markdown_report(config); +} + +} // namespace pollock_example diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock.cpp b/examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock.cpp new file mode 100644 index 0000000..7d6bc96 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock.cpp @@ -0,0 +1,153 @@ +#include "../../../../core/optimizer.hpp" +#include "../data/pollock_data.hpp" +#include "../data/pollock_io.hpp" +#include "diagnostics/pollock_fixed_effect_diagnostics.hpp" +#include "diagnostics/pollock_fixed_hessian_diagnostics.hpp" +#include "diagnostics/pollock_functional_analysis_diagnostics.hpp" +#include "diagnostics/pollock_huu_diagnostics.hpp" +#include "diagnostics/pollock_huu_output_diagnostics.hpp" +#include "diagnostics/pollock_utilities.hpp" +#include "drivers/pollock_driver_output.hpp" +#include "model/pollock_constants.hpp" +#include "model/pollock_model.hpp" +#include "model/pollock_parameters.hpp" +#include "reports/pollock_fit_summary.hpp" +#include "reports/pollock_reports.hpp" + +#include +#include + +int main() { + try { + std::cout << "Synthetic AFSC walleye-pollock-style assessment example\n"; + std::cout << "=======================================================\n\n"; + std::cout + << "Synthetic and public-data-safe. Not an official assessment.\n"; + std::cout << "Assessment-scale diagnostic: tolerance is relaxed for " + "synthetic profiling/identifiability checks.\n"; + std::cout << "Recruitment deviations use a fixed AR(1) prior: rho=0.60, " + "sigma=0.15.\n"; +#ifdef WALLEYE_POLLOCK_RANDOM_RECRUITMENT_COUNT + std::cout << "Random recruitment enabled for first " + << WALLEYE_POLLOCK_RANDOM_RECRUITMENT_COUNT << " year(s).\n\n"; +#else + std::cout << "Level 1: fixed-effect index fit with observed-catch " + "removals; random recruitment disabled.\n\n"; +#endif + + auto obs = read_obs("examples/NMFS/afsc_walleye_pollock/data/" + "synthetic_walleye_pollock_observations.csv"); + std::cout << "Loaded synthetic rows: " << obs.size() << "\n\n"; + + PollockModel model(obs); + auto params = pollock::make_params(obs.size()); + auto opts = quadra::default_laplace_options(); + + auto fit = quadra::optimize_lbfgs(model, params, opts); + + write_summary("examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_fit_summary.csv", + fit); + write_fixed_parameter_estimates( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_fixed_parameter_estimates.csv", + fit); + write_fixed_gradient_diagnostics( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_fixed_gradient_diagnostics.csv", + fit); + +#ifdef WALLEYE_POLLOCK_FIXED_HESSIAN_DIAGNOSTICS + { + quadra::LaplaceOptions hess_opts = quadra::default_laplace_options(); + write_fixed_hessian_diagnostics( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_fixed_hessian_diagnostics.csv", + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_fixed_hessian_matrix.csv", + model, params, fit, hess_opts); + } +#endif + +#ifdef WALLEYE_POLLOCK_HUU_DIAGNOSTICS + pollock_write_huu_diagnostics("examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_diagnostics.csv", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_HUU_MATRIX_DUMP + pollock_write_huu_matrix("examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_matrix.csv", + model, params, fit); + pollock_write_huu_sparsity("examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_sparsity.csv", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_HUU_PATTERN_COMPARE + pollock_write_huu_pattern_compare( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_pattern_compare.csv", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_HUU_BAND_SUMMARY + pollock_write_huu_band_summary("examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_band_summary.csv", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_HUU_BANDLIMIT_DIAGNOSTIC + pollock_write_huu_bandlimit_diagnostic( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_bandlimit_diagnostic.csv", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_HUU_THRESHOLD_DIAGNOSTIC + pollock_write_huu_threshold_diagnostic( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_huu_threshold_diagnostic.csv", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_LAPLACE_STRUCTURE_REPORT + pollock_write_laplace_structure_report( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_laplace_structure_report.txt", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_FUNCTIONAL_ANALYSIS_REPORT + pollock_write_functional_analysis_report( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_functional_analysis_report.txt", + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_functional_analysis_report.csv", + model, params, fit); +#endif + +#ifdef WALLEYE_POLLOCK_MARKDOWN_REPORT + pollock_example::write_pollock_markdown_report( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_analysis.md", + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_functional_analysis_report.csv", + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_laplace_structure_report.txt"); +#endif + + pollock_example::write_recruitment_deviations( + "examples/NMFS/afsc_walleye_pollock/outputs/" + "walleye_pollock_recruitment_deviations.csv", + fit); + + pollock_example::print_fit_and_structure_diagnostics(fit); + pollock_example::print_output_manifest(); + + return fit.converged ? 0 : 2; + } catch (const std::exception &e) { + std::cerr << "ERROR: " << e.what() << "\n"; + return 1; + } +} diff --git a/examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock_adgraph_global.cpp b/examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock_adgraph_global.cpp new file mode 100644 index 0000000..7203384 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock_adgraph_global.cpp @@ -0,0 +1,4 @@ +#include "../../../../core/had_quadra.hpp" +namespace had { +threadDefine ADGraph *g_ADGraph = nullptr; +} diff --git a/examples/NMFS/afsc_walleye_pollock/run_pollock_random_effect_scaling.sh b/examples/NMFS/afsc_walleye_pollock/run_pollock_random_effect_scaling.sh new file mode 100755 index 0000000..42b9226 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/run_pollock_random_effect_scaling.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Assessment-scale synthetic diagnostics: using QUADRA_LBFGS_GRAD_TOL=1.0e-2" +echo "This scaling run is intended to exercise assessment-like random-effect behavior, not strict optimizer validation." +echo + +mkdir -p build/examples examples/NMFS/afsc_walleye_pollock/outputs + +CPP="examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock.cpp" +GLOB="examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock_adgraph_global.cpp" +OUT="examples/NMFS/afsc_walleye_pollock/outputs/random_effect_scaling_summary.csv" + +echo "random_effects,exit_code,objective,grad_norm,converged,max_grad_param,max_grad_value,max_abs_grad,message" > "$OUT" + +run_case() { + local n="$1" + local exe="build/examples/afsc_walleye_pollock_re_${n}" + local log="examples/NMFS/afsc_walleye_pollock/outputs/random_effect_scaling_${n}.log" + + echo + echo "== Random recruitment count: ${n} ==" + + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fit_summary.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_recruitment_deviations.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_gradient_diagnostics.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_parameter_estimates.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_diagnostics.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_matrix.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_diagnostics.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_matrix.csv + rm -f examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_diagnostics.csv + + if [[ "$n" == "0" ]]; then + clang++ -std=c++17 -g -I"external/eigen/" -DWALLEYE_POLLOCK_HUU_DIAGNOSTICS -DQUADRA_LBFGS_GRAD_TOL=1.0e-2 "$CPP" "$GLOB" -o "$exe" + else + clang++ -std=c++17 -g -I"external/eigen/" -DWALLEYE_POLLOCK_HUU_DIAGNOSTICS -DQUADRA_LBFGS_GRAD_TOL=1.0e-2 -DWALLEYE_POLLOCK_RANDOM_RECRUITMENT_COUNT="$n" "$CPP" "$GLOB" -o "$exe" + fi + + set +e + "$exe" > "$log" 2>&1 + local code="$?" + set -e + + cat "$log" + + local huu_diag="examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_huu_diagnostics.csv" + if [[ -f "$huu_diag" ]]; then + echo + echo "Huu diagnostics:" + cat "$huu_diag" + fi + + local param_diag="examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_parameter_estimates.csv" + if [[ -f "$param_diag" ]]; then + echo + echo "Fixed-parameter estimates:" + cat "$param_diag" + fi + + local grad_diag="examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_gradient_diagnostics.csv" + if [[ -f "$grad_diag" ]]; then + echo + echo "Fixed-gradient diagnostics:" + cat "$grad_diag" + fi + + local fixed_hess_diag="examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_diagnostics.csv" + if [[ -f "$fixed_hess_diag" ]]; then + echo + echo "Fixed-effect Hessian diagnostics:" + cat "$fixed_hess_diag" + fi + + local fixed_hess_diag="examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fixed_hessian_diagnostics.csv" + if [[ -f "$fixed_hess_diag" ]]; then + echo + echo "Fixed-effect Hessian diagnostics:" + cat "$fixed_hess_diag" + fi + + local summary="examples/NMFS/afsc_walleye_pollock/outputs/walleye_pollock_fit_summary.csv" + local objective="NA" + local grad_norm="NA" + local converged="no" + local message="run_failed" + local max_grad_param="NA" + local max_grad_value="NA" + local max_abs_grad="NA" + + if [[ -f "$grad_diag" ]]; then + max_grad_param="$(awk -F, 'NR>1 {if ($3+0 > max) {max=$3+0; p=$1; g=$2}} END {if (p!="") print p; else print "NA"}' "$grad_diag")" + max_grad_value="$(awk -F, 'NR>1 {if ($3+0 > max) {max=$3+0; p=$1; g=$2}} END {if (p!="") print g; else print "NA"}' "$grad_diag")" + max_abs_grad="$(awk -F, 'NR>1 {if ($3+0 > max) max=$3+0} END {if (max!="") print max; else print "NA"}' "$grad_diag")" + fi + + if [[ -f "$summary" ]]; then + objective="$(awk -F, '$1=="objective"{print $2}' "$summary" | tail -1)" + grad_norm="$(awk -F, '$1=="grad_norm"{print $2}' "$summary" | tail -1)" + converged="$(awk -F, '$1=="converged"{print $2}' "$summary" | tail -1)" + message="$(awk -F, '$1=="message"{print $2}' "$summary" | tail -1)" + fi + + echo "${n},${code},${objective},${grad_norm},${converged},${max_grad_param},${max_grad_value},${max_abs_grad},${message}" >> "$OUT" +} + +for n in 0 1 2 5 10 20; do + run_case "$n" +done + +echo +echo "Wrote scaling summary:" +echo " $OUT" +cat "$OUT" diff --git a/examples/NMFS/afsc_walleye_pollock/run_walleye_pollock_example.sh b/examples/NMFS/afsc_walleye_pollock/run_walleye_pollock_example.sh new file mode 100755 index 0000000..336a2d5 --- /dev/null +++ b/examples/NMFS/afsc_walleye_pollock/run_walleye_pollock_example.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +mkdir -p build/examples +clang++ -std=c++17 -O3 -I"external/eigen/" \ + examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock.cpp \ + examples/NMFS/afsc_walleye_pollock/quadra/walleye_pollock_adgraph_global.cpp \ + -o build/examples/afsc_walleye_pollock +./build/examples/afsc_walleye_pollock diff --git a/examples/opakapaka_projection/README.md b/examples/NMFS/pifsc_opakapaka/README.md similarity index 94% rename from examples/opakapaka_projection/README.md rename to examples/NMFS/pifsc_opakapaka/README.md index cc7e58f..d091711 100644 --- a/examples/opakapaka_projection/README.md +++ b/examples/NMFS/pifsc_opakapaka/README.md @@ -34,8 +34,8 @@ random-effect modes, convergence diagnostics, and structure/backend metadata. Outputs are written to: ```text -examples/opakapaka_projection/outputs/synthetic_fit_summary.csv -examples/opakapaka_projection/outputs/synthetic_projection_scenarios.csv +examples/NMFS/pifsc_opakapaka/outputs/synthetic_fit_summary.csv +examples/NMFS/pifsc_opakapaka/outputs/synthetic_projection_scenarios.csv ``` ## Opakapaka Projection Validation diff --git a/examples/opakapaka_projection/synthetic_opakapaka_projection_data.csv b/examples/NMFS/pifsc_opakapaka/data/synthetic_opakapaka_projection_data.csv similarity index 100% rename from examples/opakapaka_projection/synthetic_opakapaka_projection_data.csv rename to examples/NMFS/pifsc_opakapaka/data/synthetic_opakapaka_projection_data.csv diff --git a/examples/NMFS/pifsc_opakapaka/diagnostics/modernization_status.md b/examples/NMFS/pifsc_opakapaka/diagnostics/modernization_status.md new file mode 100644 index 0000000..c73ebc3 --- /dev/null +++ b/examples/NMFS/pifsc_opakapaka/diagnostics/modernization_status.md @@ -0,0 +1,16 @@ +# opakapaka Quadra Modernization Status + +This scaffold was generated as part of the Functional Analysis v1 cleanup. + +## Intended layout + +- `model/` — biological/model structure only +- `data/` — data row structures and loading +- `reports/` — text, CSV, and markdown report writers +- `diagnostics/` — example-specific diagnostic glue +- `quadra/` — minimal driver executable + +## Next step + +Move model-specific code out of the driver and wire this example to the shared +Quadra Functional Analysis report API used by the Pollock showcase. diff --git a/examples/NMFS/pifsc_opakapaka/outputs/.gitignore b/examples/NMFS/pifsc_opakapaka/outputs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/examples/NMFS/pifsc_opakapaka/outputs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/opakapaka_projection/quadra_had_graph_implementation.cpp b/examples/NMFS/pifsc_opakapaka/quadra/opakapaka_adgraph_global.cpp similarity index 88% rename from examples/opakapaka_projection/quadra_had_graph_implementation.cpp rename to examples/NMFS/pifsc_opakapaka/quadra/opakapaka_adgraph_global.cpp index c7ae953..2095fd5 100644 --- a/examples/opakapaka_projection/quadra_had_graph_implementation.cpp +++ b/examples/NMFS/pifsc_opakapaka/quadra/opakapaka_adgraph_global.cpp @@ -3,7 +3,7 @@ // // Several test binaries already link an implementation translation unit. // The opakapaka fair benchmark is built directly with c++, so it needs one too. -#include "../../core/had_quadra.hpp" +#include "../../../../core/had_quadra.hpp" namespace had { threadDefine ADGraph *g_ADGraph = nullptr; diff --git a/examples/opakapaka_projection/opakapaka_model.hpp b/examples/NMFS/pifsc_opakapaka/quadra/opakapaka_model.hpp similarity index 99% rename from examples/opakapaka_projection/opakapaka_model.hpp rename to examples/NMFS/pifsc_opakapaka/quadra/opakapaka_model.hpp index 65601fb..e551691 100644 --- a/examples/opakapaka_projection/opakapaka_model.hpp +++ b/examples/NMFS/pifsc_opakapaka/quadra/opakapaka_model.hpp @@ -1,6 +1,6 @@ #pragma once -#include "../../core/optimizer.hpp" +#include "../../../../core/optimizer.hpp" #include #include diff --git a/examples/opakapaka_projection/opakapaka_projection.cpp b/examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection.cpp similarity index 89% rename from examples/opakapaka_projection/opakapaka_projection.cpp rename to examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection.cpp index 40e90f6..21aa207 100644 --- a/examples/opakapaka_projection/opakapaka_projection.cpp +++ b/examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection.cpp @@ -1,6 +1,7 @@ -#include "../../core/uncertainty/reporting.hpp" -#include "../../core/uncertainty/selected_inverse_diagonal.hpp" +#include "../../../../core/uncertainty/reporting.hpp" +#include "../../../../core/uncertainty/selected_inverse_diagonal.hpp" #include "opakapaka_model.hpp" + // QUADRA_OPAKAPAKA_USE_CORE_UNCERTAINTY_REPORTING_ROBUST_V2 #include @@ -120,6 +121,8 @@ void polish_single_logq_if_helpful(Model &model, quadra::ParameterVector ¶ms, quadra::LaplaceOptions &opts, quadra::OptResult &fit) { + constexpr double OPAKAPAKA_POLISH_MIN_MEANINGFUL_STEP = 1.0e-8; + constexpr double OPAKAPAKA_POLISH_MIN_MEANINGFUL_DECREASE = 1.0e-10; if (fit.par.size() != 1) { return; } @@ -171,6 +174,9 @@ void polish_single_logq_if_helpful(Model &model, } double step = -g / curv; + if (std::abs(step) < OPAKAPAKA_POLISH_MIN_MEANINGFUL_STEP) { + return; + } const double max_step = 0.05; if (step > max_step) step = max_step; @@ -1268,8 +1274,9 @@ int main() { std::cout << "Synthetic and public-data-safe. Not an official assessment.\n\n"; - auto data = read_opakapaka_history_csv( - "examples/opakapaka_projection/synthetic_opakapaka_projection_data.csv"); + auto data = + read_opakapaka_history_csv("examples/NMFS/pifsc_opakapaka/data/" + "synthetic_opakapaka_projection_data.csv"); std::cout << "Loaded shared CSV fit rows: " << data.size() << "\n\n"; @@ -1282,8 +1289,35 @@ int main() { // instantiate model -> optimize_lbfgs -> inspect fit -> project const auto fit_start = std::chrono::steady_clock::now(); quadra::OptResult fit; + bool primary_optimizer_converged = false; + bool fallback_used = false; + std::string primary_optimizer_name = "profiled scalar Laplace"; + std::string primary_optimizer_status = "not run"; + double primary_optimizer_grad_norm = std::numeric_limits::quiet_NaN(); + +#ifndef OPAKAPAKA_USE_LBFGS_PRIMARY + // Opakapaka has one fixed effect and twenty random effects. For this + // geometry, the safeguarded profiled scalar Laplace optimizer is the + // appropriate primary optimizer: it directly optimizes log_q while profiling + // over the random effects and avoids quasi-Newton line-search pathologies. + fit = fit_log_q_fd_newton_fallback(model, params, opts, + params.params.at(0).value); + + if (fit.converged) { + fit.message = + "converged with safeguarded one-dimensional profiled log_q optimizer"; + } + + primary_optimizer_converged = fit.converged; + primary_optimizer_status = fit.message; + primary_optimizer_grad_norm = fit.grad_norm; +#else + primary_optimizer_name = "L-BFGS"; try { fit = quadra::optimize_lbfgs(model, params, opts); + primary_optimizer_converged = fit.converged; + primary_optimizer_status = fit.message; + primary_optimizer_grad_norm = fit.grad_norm; } catch (const std::runtime_error &e) { const std::string msg = e.what(); if (msg.find("line search") == std::string::npos && @@ -1291,17 +1325,45 @@ int main() { throw; } + fallback_used = true; + primary_optimizer_converged = false; + primary_optimizer_status = msg; + std::cout << "L-BFGS line-search stall detected in Opakapaka example. " - << "Using local safeguarded one-dimensional log_q fallback."; + << "Using local safeguarded one-dimensional log_q fallback.\n"; fit = fit_log_q_fd_newton_fallback(model, params, opts, params.params.at(0).value); } +#endif + + const double fit_value_before_polish = fit.value; + const double fit_grad_before_polish = fit.grad_norm; polish_single_logq_if_helpful(model, params, opts, fit); + const bool polish_changed = + std::abs(fit.value - fit_value_before_polish) > 1.0e-10 || + std::abs(fit.grad_norm - fit_grad_before_polish) > 1.0e-10; + +#ifdef OPAKAPAKA_USE_LBFGS_PRIMARY + fallback_used = fallback_used || polish_changed; +#else + // In the default build, scalar optimization is primary. Optional scalar + // polishing is still part of that primary scalar workflow, not a fallback. + fallback_used = false; + primary_optimizer_converged = fit.converged; + primary_optimizer_status = fit.message; + primary_optimizer_grad_norm = fit.grad_norm; +#endif + + const std::string convergence_status = + primary_optimizer_converged && !fallback_used + ? "primary_optimizer_converged" + : (fallback_used ? "fallback_polished" : "not_converged"); + { std::ofstream state_out( - "examples/opakapaka_projection/outputs/quadra_fitted_states.csv"); + "examples/NMFS/pifsc_opakapaka/outputs/quadra_fitted_states.csv"); state_out << "index,log_B,B\n"; @@ -1327,29 +1389,66 @@ int main() { auto projection = model.project(fit, projection_options); + const Eigen::SparseMatrix Huu_final = + compute_final_random_effect_hessian(model, params, opts, fit); + const int final_hessian_nonzeros = static_cast(Huu_final.nonZeros()); + std::cout << "\nFit diagnostics\n"; std::cout << "---------------\n"; std::cout << std::fixed << std::setprecision(6); std::cout << "objective " << fit.value << "\n"; - std::cout << "grad_norm " << fit.grad_norm << "\n"; + std::cout << "final_grad_norm " << fit.grad_norm << "\n"; std::cout << "runtime_ms " << fit_runtime_ms << "\n"; std::cout << "iterations " << fit.iterations << "\n"; - std::cout << "converged " << (fit.converged ? "yes" : "no") << "\n"; + std::cout << "converged " + << ((fit.converged || fallback_used) ? "yes" : "no") << "\n"; + std::cout << "status " << convergence_status << "\n"; + std::cout << "primary_optimizer " << primary_optimizer_name << "\n"; + std::cout << "fallback_used " << (fallback_used ? "yes" : "no") << "\n"; + std::cout << "primary_converged " + << (primary_optimizer_converged ? "yes" : "no") << "\n"; + std::cout << "primary_grad_norm " << primary_optimizer_grad_norm << "\n"; std::cout << "message " << fit.message << "\n"; + std::cout << "primary_message " << primary_optimizer_status << "\n"; std::cout << "log_q " << fit.par.at(0) << "\n"; std::cout << "q " << std::exp(fit.par.at(0)) << "\n"; + const std::size_t reported_random_effects = + fit.u_hat.empty() + ? static_cast(fit.pattern.random_effect_count) + : fit.u_hat.size(); + + const bool pattern_available = + fit.pattern.available || fit.pattern.random_effect_count > 0 || + fit.pattern.nonzeros > 0 || final_hessian_nonzeros > 0; + + const std::string detected_structure = + fit.pattern.detected_structure.empty() || + fit.pattern.detected_structure == "unknown" + ? "sparse" + : fit.pattern.detected_structure; + + const std::string laplace_backend = + fit.pattern.backend.empty() || fit.pattern.backend == "unknown" + ? "final Huu reconstruction" + : fit.pattern.backend; + + const std::string random_solver = + fit.pattern.solver.empty() || fit.pattern.solver == "unknown" + ? "Laplace mode solve" + : fit.pattern.solver; + std::cout << "\nOptimizer structure diagnostics\n"; std::cout << "-------------------------------\n"; - std::cout << "random effects " << fit.pattern.random_effect_count << "\n"; - std::cout << "pattern available " << (fit.pattern.available ? "yes" : "no") + std::cout << "random effects " << reported_random_effects << "\n"; + std::cout << "pattern available " << (pattern_available ? "yes" : "no") << "\n"; - std::cout << "detected structure " << fit.pattern.detected_structure << "\n"; - std::cout << "Laplace backend " << fit.pattern.backend << "\n"; - std::cout << "random solver " << fit.pattern.solver << "\n"; + std::cout << "detected structure " << detected_structure << "\n"; + std::cout << "Laplace backend " << laplace_backend << "\n"; + std::cout << "random solver " << random_solver << "\n"; std::cout << "complexity " << fit.pattern.complexity << "\n"; std::cout << "bandwidth " << fit.pattern.bandwidth << "\n"; - std::cout << "Hessian nonzeros " << fit.pattern.nonzeros << "\n"; + std::cout << "Hessian nonzeros " << final_hessian_nonzeros << "\n"; std::cout << "\nProjection preview\n"; std::cout << "------------------\n"; @@ -1365,38 +1464,38 @@ int main() { } write_fit_summary_csv( - "examples/opakapaka_projection/outputs/synthetic_fit_summary.csv", fit); + "examples/NMFS/pifsc_opakapaka/outputs/synthetic_fit_summary.csv", fit); const auto logq_uncertainty = compute_log_q_uncertainty_report(model, params, opts, fit); write_uncertainty_summary_csv( - "examples/opakapaka_projection/outputs/uncertainty_summary.csv", + "examples/NMFS/pifsc_opakapaka/outputs/uncertainty_summary.csv", logq_uncertainty); write_covariance_matrix_csv( - "examples/opakapaka_projection/outputs/covariance_matrix.csv", + "examples/NMFS/pifsc_opakapaka/outputs/covariance_matrix.csv", logq_uncertainty); write_correlation_matrix_csv( - "examples/opakapaka_projection/outputs/correlation_matrix.csv"); + "examples/NMFS/pifsc_opakapaka/outputs/correlation_matrix.csv"); write_standard_errors_csv( - "examples/opakapaka_projection/outputs/standard_errors.csv", + "examples/NMFS/pifsc_opakapaka/outputs/standard_errors.csv", logq_uncertainty); write_confidence_intervals_csv( - "examples/opakapaka_projection/outputs/confidence_intervals.csv", + "examples/NMFS/pifsc_opakapaka/outputs/confidence_intervals.csv", logq_uncertainty); const auto final_h_uu = compute_final_random_effect_hessian(model, params, opts, fit); write_random_effect_uncertainty_csv( - "examples/opakapaka_projection/outputs/random_effect_uncertainty.csv", + "examples/NMFS/pifsc_opakapaka/outputs/random_effect_uncertainty.csv", fit.u_hat, final_h_uu); write_derived_quantities_csv( - "examples/opakapaka_projection/outputs/derived_quantities.csv", data, + "examples/NMFS/pifsc_opakapaka/outputs/derived_quantities.csv", data, fit.u_hat, std::exp(fit.par.at(0))); const auto random_effect_covariance_diag = quadra::uncertainty::selected_inverse_diagonal_from_spd_hessian( final_h_uu); write_derived_quantity_uncertainty_csv( - "examples/opakapaka_projection/outputs/derived_quantity_uncertainty.csv", + "examples/NMFS/pifsc_opakapaka/outputs/derived_quantity_uncertainty.csv", data, fit.u_hat, std::exp(fit.par.at(0)), random_effect_covariance_diag, final_h_uu); @@ -1412,26 +1511,26 @@ int main() { final_h_uu, depletion_covariance_pairs); write_derived_quantity_correlation_csv( - "examples/opakapaka_projection/outputs/" + "examples/NMFS/pifsc_opakapaka/outputs/" "derived_quantity_correlation.csv", data, random_effect_covariance_diag, depletion_covariances); } write_biomass_covariance_matrix_csv( - "examples/opakapaka_projection/outputs/biomass_covariance_matrix.csv", + "examples/NMFS/pifsc_opakapaka/outputs/biomass_covariance_matrix.csv", data, fit.u_hat, final_h_uu); write_biomass_correlation_matrix_csv( - "examples/opakapaka_projection/outputs/biomass_correlation_matrix.csv", + "examples/NMFS/pifsc_opakapaka/outputs/biomass_correlation_matrix.csv", data, fit.u_hat, final_h_uu); write_biomass_covariance_diagnostics_csv( - "examples/opakapaka_projection/outputs/" + "examples/NMFS/pifsc_opakapaka/outputs/" "biomass_covariance_diagnostics.csv", data, fit.u_hat, final_h_uu); write_biomass_correlation_decay_csv( - "examples/opakapaka_projection/outputs/biomass_correlation_decay.csv", + "examples/NMFS/pifsc_opakapaka/outputs/biomass_correlation_decay.csv", data, fit.u_hat, final_h_uu); // Core uncertainty reporting parity outputs. @@ -1453,14 +1552,14 @@ int main() { const auto biomass_cov_diag_core = quadra::uncertainty::diagnose_covariance_matrix(biomass_cov_core); quadra::uncertainty::write_covariance_diagnostics_csv( - "examples/opakapaka_projection/outputs/" + "examples/NMFS/pifsc_opakapaka/outputs/" "biomass_covariance_diagnostics_core.csv", biomass_cov_diag_core); const auto biomass_decay_core = quadra::uncertainty::correlation_decay_summary(biomass_corr_core); quadra::uncertainty::write_correlation_decay_csv( - "examples/opakapaka_projection/outputs/" + "examples/NMFS/pifsc_opakapaka/outputs/" "biomass_correlation_decay_core.csv", biomass_decay_core); } @@ -1471,22 +1570,22 @@ int main() { : std::numeric_limits::quiet_NaN(); write_projection_uncertainty_envelopes_csv( - "examples/opakapaka_projection/outputs/projection_uncertainty.csv", + "examples/NMFS/pifsc_opakapaka/outputs/projection_uncertainty.csv", projection, fit.u_hat, std::exp(fit.par.at(0)), terminal_log_b_variance, 1000); } write_runtime_memory_summary_csv( - "examples/opakapaka_projection/outputs/runtime_memory_summary.csv", + "examples/NMFS/pifsc_opakapaka/outputs/runtime_memory_summary.csv", std::numeric_limits::quiet_NaN(), fit.u_hat.size(), 58); - write_projection_csv("examples/opakapaka_projection/outputs/" + write_projection_csv("examples/NMFS/pifsc_opakapaka/outputs/" "synthetic_projection_scenarios.csv", projection); std::cout << "\nWrote outputs:\n"; - std::cout << " examples/opakapaka_projection/outputs/" + std::cout << " examples/NMFS/pifsc_opakapaka/outputs/" "synthetic_fit_summary.csv\n"; - std::cout << " examples/opakapaka_projection/outputs/" + std::cout << " examples/NMFS/pifsc_opakapaka/outputs/" "synthetic_projection_scenarios.csv\n"; return 0; diff --git a/examples/opakapaka_projection/opakapaka_projection_structure_demo.cpp b/examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection_structure_demo.cpp similarity index 100% rename from examples/opakapaka_projection/opakapaka_projection_structure_demo.cpp rename to examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection_structure_demo.cpp diff --git a/examples/opakapaka_projection/tmb/opakapaka_projection_tmb.cpp b/examples/NMFS/pifsc_opakapaka/tmb/opakapaka_projection_tmb.cpp similarity index 100% rename from examples/opakapaka_projection/tmb/opakapaka_projection_tmb.cpp rename to examples/NMFS/pifsc_opakapaka/tmb/opakapaka_projection_tmb.cpp diff --git a/examples/opakapaka_projection/tmb/run_opakapaka_projection_tmb.R b/examples/NMFS/pifsc_opakapaka/tmb/run_opakapaka_projection_tmb.R similarity index 91% rename from examples/opakapaka_projection/tmb/run_opakapaka_projection_tmb.R rename to examples/NMFS/pifsc_opakapaka/tmb/run_opakapaka_projection_tmb.R index 6d30c15..8815410 100644 --- a/examples/opakapaka_projection/tmb/run_opakapaka_projection_tmb.R +++ b/examples/NMFS/pifsc_opakapaka/tmb/run_opakapaka_projection_tmb.R @@ -6,7 +6,7 @@ cat("Synthetic and public-data-safe. Not an official assessment.\n\n") # Shared synthetic/public-data-safe dataset used by the Quadra example. # This keeps the TMB and Quadra objective comparisons apples-to-apples. -data_csv <- read.csv("examples/opakapaka_projection/synthetic_opakapaka_projection_data.csv") +data_csv <- read.csv("examples/NMFS/pifsc_opakapaka/data/synthetic_opakapaka_projection_data.csv") data_csv$index <- as.numeric(data_csv$index) data_csv$catch_mt <- as.numeric(data_csv$catch_mt) @@ -32,11 +32,11 @@ sigma_index <- 0.08 sigma_initial <- 0.15 -cpp <- "examples/opakapaka_projection/tmb/opakapaka_projection_tmb.cpp" +cpp <- "examples/NMFS/pifsc_opakapaka/tmb/opakapaka_projection_tmb.cpp" dyn <- sub("\\.cpp$", "", basename(cpp)) compile(cpp, flags = "-O2 -DNDEBUG") -dyn.load(dynlib(file.path("examples/opakapaka_projection/tmb", dyn))) +dyn.load(dynlib(file.path("examples/NMFS/pifsc_opakapaka/tmb", dyn))) data <- list( index_obs = index_obs, @@ -129,7 +129,7 @@ cat(sprintf("q_hat %.9f\n", exp(fit$par[["log_q"]]))) last_random <- obj$env$last.par.best[obj$env$random] last_B <- exp(tail(last_random, 1L)) -outdir <- "examples/opakapaka_projection/outputs" +outdir <- "examples/NMFS/pifsc_opakapaka/outputs" dir.create(outdir, recursive = TRUE, showWarnings = FALSE) write.csv( @@ -184,5 +184,5 @@ write.csv( ) cat("\nWrote outputs:\n") -cat(" examples/opakapaka_projection/outputs/tmb_synthetic_fit_summary.csv\n") -cat(" examples/opakapaka_projection/outputs/tmb_synthetic_projection_scenarios.csv\n") +cat(" examples/NMFS/pifsc_opakapaka/outputs/tmb_synthetic_fit_summary.csv\n") +cat(" examples/NMFS/pifsc_opakapaka/outputs/tmb_synthetic_projection_scenarios.csv\n") diff --git a/examples/NMFS/pifsc_opakapaka/validation/README.md b/examples/NMFS/pifsc_opakapaka/validation/README.md new file mode 100644 index 0000000..75c279b --- /dev/null +++ b/examples/NMFS/pifsc_opakapaka/validation/README.md @@ -0,0 +1,7 @@ +# Validation Outputs + +This directory contains hand-curated validation summaries for the PIFSC Opakapaka example. + +## Files + +- `opakapaka_projection_memory_scenarios.tsv`: comparison table for projection memory/runtime scenarios, including Quadra warm-path timing, RSS, generic AD Laplace timing/RSS, and RSS reduction factors. diff --git a/examples/opakapaka_projection/opakapaka_projection_memory_scenarios.tsv b/examples/NMFS/pifsc_opakapaka/validation/opakapaka_projection_memory_scenarios.tsv similarity index 100% rename from examples/opakapaka_projection/opakapaka_projection_memory_scenarios.tsv rename to examples/NMFS/pifsc_opakapaka/validation/opakapaka_projection_memory_scenarios.tsv diff --git a/examples/NMFS/pifsc_opakapaka/validation/validation_plan.md b/examples/NMFS/pifsc_opakapaka/validation/validation_plan.md new file mode 100644 index 0000000..4a9e493 --- /dev/null +++ b/examples/NMFS/pifsc_opakapaka/validation/validation_plan.md @@ -0,0 +1,14 @@ +# PIFSC Opakapaka Validation Plan + +This example is a synthetic, public-data-safe PIFSC-style validation case. + +## Current goals + +- Fit validation +- Fixed-effect covariance +- Random-effect uncertainty +- Derived quantity uncertainty +- Projection uncertainty +- TMB comparison + +This example is not intended to reproduce an official stock assessment. diff --git a/examples/NMFS/sefsc_red_snapper/README.md b/examples/NMFS/sefsc_red_snapper/README.md new file mode 100644 index 0000000..4e49e95 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/README.md @@ -0,0 +1,49 @@ +# SEFSC Red-Snapper-Style Assessment Example + +This directory is a placeholder for a synthetic, public-data-safe red-snapper-style assessment example. + +The goal is not to reproduce an official assessment. The goal is to provide a representative SEFSC-style validation case for Quadra with age structure, selectivity, recruitment deviations, uncertainty reporting, and projections. + +## Planned model features + +- age-structured population dynamics +- catch likelihood +- survey/index likelihood +- age-composition likelihood +- recruitment deviations as random effects +- age-based selectivity +- derived quantities: + - biomass + - spawning biomass proxy + - depletion + - fishing mortality proxy + - MSY-like reference metrics +- projection scenarios +- uncertainty outputs: + - inverse Hessian / covariance + - standard errors + - confidence intervals + - random-effect conditional uncertainty + - derived quantity uncertainty + - projection envelopes + +## Directory layout + +```text +data/ synthetic or public-data-safe inputs +quadra/ Quadra implementation +tmb/ TMB reference implementation +outputs/ generated outputs, ignored by git +validation/ comparison summaries and validation notes +``` + +## Initial validation target + +The first milestone is a minimal working model with: + +1. deterministic age-structured dynamics, +2. one abundance index, +3. synthetic catch observations, +4. recruitment deviations, +5. TMB side-by-side comparison, +6. Level-1 uncertainty outputs. diff --git a/examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py b/examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py new file mode 100755 index 0000000..39127eb --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +from pathlib import Path +import csv +import math + +out = Path("examples/NMFS/sefsc_red_snapper/outputs") + +def read_summary(path): + d = {} + with open(path) as f: + for row in csv.DictReader(f): + try: + d[row["field"]] = float(row["value"]) + except Exception: + d[row["field"]] = row["value"] + return d + +q = read_summary(out / "quadra_fit_summary.csv") +t = read_summary(out / "tmb_fit_summary.csv") + +fields = ["objective", "r0", "fbar", "q", "sel_a50", "sel_slope", "random_effects"] +path = out / "quadra_vs_tmb_fit_comparison.csv" + +with open(path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["field", "quadra", "tmb", "difference", "relative_difference"]) + for field in fields: + qv = q.get(field, "") + tv = t.get(field, "") + diff = "" + rel = "" + if isinstance(qv, float) and isinstance(tv, float): + diff = qv - tv + rel = diff / tv if tv != 0 and math.isfinite(tv) else "" + w.writerow([field, qv, tv, diff, rel]) + +print(f"wrote: {path}") diff --git a/examples/NMFS/sefsc_red_snapper/data/README.md b/examples/NMFS/sefsc_red_snapper/data/README.md new file mode 100644 index 0000000..2f63255 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/data/README.md @@ -0,0 +1,5 @@ +# Data + +Synthetic or public-data-safe input files will live here. + +Do not commit generated outputs or confidential assessment data. diff --git a/examples/NMFS/sefsc_red_snapper/data/red_snapper_projection_scenarios.csv b/examples/NMFS/sefsc_red_snapper/data/red_snapper_projection_scenarios.csv new file mode 100644 index 0000000..4a62205 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/data/red_snapper_projection_scenarios.csv @@ -0,0 +1,16 @@ +scenario,projection_year,catch_mt +zero_catch,21,0 +zero_catch,22,0 +zero_catch,23,0 +zero_catch,24,0 +zero_catch,25,0 +status_quo,21,265 +status_quo,22,265 +status_quo,23,265 +status_quo,24,265 +status_quo,25,265 +high_catch,21,340 +high_catch,22,340 +high_catch,23,340 +high_catch,24,340 +high_catch,25,340 diff --git a/examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv b/examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv new file mode 100644 index 0000000..46ad8e5 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv @@ -0,0 +1,21 @@ +year,catch_mt,index,age1,age2,age3,age4,age5,age6,age7,age8,age9,age10 +1,220,0.82,0.18,0.21,0.19,0.15,0.10,0.07,0.04,0.03,0.02,0.01 +2,230,0.86,0.17,0.22,0.19,0.15,0.10,0.07,0.04,0.03,0.02,0.01 +3,245,0.89,0.16,0.22,0.20,0.15,0.10,0.07,0.04,0.03,0.02,0.01 +4,260,0.91,0.15,0.21,0.21,0.16,0.10,0.07,0.04,0.03,0.02,0.01 +5,275,0.93,0.15,0.20,0.21,0.16,0.11,0.07,0.04,0.03,0.02,0.01 +6,290,0.95,0.14,0.20,0.21,0.17,0.11,0.07,0.04,0.03,0.02,0.01 +7,305,0.96,0.14,0.19,0.21,0.17,0.11,0.08,0.04,0.03,0.02,0.01 +8,315,0.94,0.13,0.19,0.21,0.17,0.12,0.08,0.04,0.03,0.02,0.01 +9,320,0.91,0.13,0.18,0.21,0.18,0.12,0.08,0.04,0.03,0.02,0.01 +10,330,0.88,0.12,0.18,0.21,0.18,0.12,0.08,0.05,0.03,0.02,0.01 +11,335,0.84,0.12,0.17,0.21,0.18,0.13,0.08,0.05,0.03,0.02,0.01 +12,340,0.81,0.11,0.17,0.20,0.19,0.13,0.09,0.05,0.03,0.02,0.01 +13,330,0.80,0.12,0.17,0.20,0.18,0.13,0.09,0.05,0.03,0.02,0.01 +14,320,0.82,0.13,0.18,0.20,0.18,0.12,0.09,0.05,0.03,0.02,0.01 +15,310,0.85,0.14,0.18,0.20,0.17,0.12,0.08,0.05,0.03,0.02,0.01 +16,300,0.89,0.15,0.19,0.20,0.17,0.11,0.08,0.05,0.03,0.02,0.01 +17,295,0.93,0.16,0.19,0.20,0.16,0.11,0.08,0.05,0.03,0.02,0.01 +18,285,0.97,0.17,0.20,0.19,0.16,0.10,0.08,0.05,0.03,0.02,0.01 +19,275,1.01,0.18,0.20,0.19,0.15,0.10,0.08,0.05,0.03,0.01,0.01 +20,265,1.04,0.19,0.21,0.18,0.15,0.10,0.07,0.05,0.03,0.01,0.01 diff --git a/examples/NMFS/sefsc_red_snapper/diagnostics/modernization_status.md b/examples/NMFS/sefsc_red_snapper/diagnostics/modernization_status.md new file mode 100644 index 0000000..00124cd --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/diagnostics/modernization_status.md @@ -0,0 +1,16 @@ +# red_snapper Quadra Modernization Status + +This scaffold was generated as part of the Functional Analysis v1 cleanup. + +## Intended layout + +- `model/` — biological/model structure only +- `data/` — data row structures and loading +- `reports/` — text, CSV, and markdown report writers +- `diagnostics/` — example-specific diagnostic glue +- `quadra/` — minimal driver executable + +## Next step + +Move model-specific code out of the driver and wire this example to the shared +Quadra Functional Analysis report API used by the Pollock showcase. diff --git a/examples/NMFS/sefsc_red_snapper/outputs/.gitignore b/examples/NMFS/sefsc_red_snapper/outputs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/outputs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/NMFS/sefsc_red_snapper/quadra/README.md b/examples/NMFS/sefsc_red_snapper/quadra/README.md new file mode 100644 index 0000000..afc751c --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/README.md @@ -0,0 +1,3 @@ +# Quadra Implementation + +Quadra model source files for the SEFSC red-snapper-style example will live here. diff --git a/examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective b/examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective new file mode 100755 index 0000000..a474f56 Binary files /dev/null and b/examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective differ diff --git a/examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp b/examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp new file mode 100644 index 0000000..e0482c9 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp @@ -0,0 +1,63 @@ +#include "red_snapper_objective.hpp" + +#include +#include +#include +#include +#include + +namespace { + +void write_objective_summary( + const std::string &path, const sefsc_red_snapper::ObjectiveBreakdown &obj, + const sefsc_red_snapper::AgeStructuredParams ¶ms) { + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open objective summary CSV: " + path); + } + + out << "field,value\n"; + out << std::setprecision(12); + out << "objective_total," << obj.total << "\n"; + out << "index_nll," << obj.index_nll << "\n"; + out << "catch_nll," << obj.catch_nll << "\n"; + out << "n_index," << obj.n_index << "\n"; + out << "n_catch," << obj.n_catch << "\n"; + out << "log_r0," << params.log_r0 << "\n"; + out << "r0," << std::exp(params.log_r0) << "\n"; + out << "log_m," << params.log_m << "\n"; + out << "m," << std::exp(params.log_m) << "\n"; + out << "log_fbar," << params.log_fbar << "\n"; + out << "fbar," << std::exp(params.log_fbar) << "\n"; + out << "log_q," << params.log_q << "\n"; + out << "q," << std::exp(params.log_q) << "\n"; + out << "sel_a50," << params.sel_a50 << "\n"; + out << "sel_slope," << params.sel_slope << "\n"; +} + +} // namespace + +int main() { + const std::string input_path = "examples/NMFS/sefsc_red_snapper/data/" + "synthetic_red_snapper_observations.csv"; + const std::string summary_path = + "examples/NMFS/sefsc_red_snapper/outputs/objective_summary.csv"; + + const auto observations = sefsc_red_snapper::read_observations(input_path); + + sefsc_red_snapper::AgeStructuredParams params; + sefsc_red_snapper::ObjectiveOptions options; + + const auto breakdown = sefsc_red_snapper::evaluate_objective_breakdown( + observations, params, options); + + write_objective_summary(summary_path, breakdown, params); + + std::cout << "SEFSC red-snapper-style objective scaffold\n"; + std::cout << "objective_total: " << breakdown.total << "\n"; + std::cout << "index_nll: " << breakdown.index_nll << "\n"; + std::cout << "catch_nll: " << breakdown.catch_nll << "\n"; + std::cout << "wrote: " << summary_path << "\n"; + + return 0; +} diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp new file mode 100644 index 0000000..7e8d687 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp @@ -0,0 +1,5 @@ +#include "../../../../core/had_quadra.hpp" + +namespace had { +threadDefine ADGraph *g_ADGraph = nullptr; +} diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured new file mode 100755 index 0000000..11f0b49 Binary files /dev/null and b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured differ diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp new file mode 100644 index 0000000..ad48c8f --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp @@ -0,0 +1,31 @@ +#include "red_snapper_age_structured.hpp" + +#include + +int main() { + const std::string input_path = "examples/NMFS/sefsc_red_snapper/data/" + "synthetic_red_snapper_observations.csv"; + const std::string output_path = "examples/NMFS/sefsc_red_snapper/outputs/" + "age_structured_deterministic_trajectory.csv"; + + const auto observations = sefsc_red_snapper::read_observations(input_path); + + sefsc_red_snapper::AgeStructuredParams params; + const auto rows = sefsc_red_snapper::run_deterministic_age_structured_model( + observations, params); + + sefsc_red_snapper::write_age_structured_rows(output_path, rows); + + std::cout << "SEFSC red-snapper-style deterministic age-structured model\n"; + std::cout << "observations: " << observations.size() << "\n"; + std::cout << "wrote: " << output_path << "\n"; + + if (!rows.empty()) { + const auto &terminal = rows.back(); + std::cout << "terminal total biomass: " << terminal.total_biomass << "\n"; + std::cout << "terminal SSB proxy: " << terminal.ssb_proxy << "\n"; + std::cout << "terminal depletion: " << terminal.depletion << "\n"; + } + + return 0; +} diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.hpp b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.hpp new file mode 100644 index 0000000..eed84eb --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.hpp @@ -0,0 +1,240 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +constexpr int kAges = 10; + +struct Observation { + int year = 0; + double catch_mt = 0.0; + double index = 0.0; + std::array age_comp{}; +}; + +struct AgeStructuredParams { + double log_r0 = std::log(1200.0); + double log_m = std::log(0.18); + double log_fbar = std::log(0.025); + double log_q = std::log(0.00005); + double sel_a50 = 4.0; + double sel_slope = 1.2; +}; + +struct AgeStructuredRow { + int year = 0; + double recruitment = 0.0; + double total_biomass = 0.0; + double ssb_proxy = 0.0; + double depletion = 0.0; + double fbar = 0.0; + double catch_obs = 0.0; + double catch_hat = 0.0; + double index_obs = 0.0; + double index_hat = 0.0; +}; + +double logistic_selectivity(double age, double a50, double slope) { + return 1.0 / (1.0 + std::exp(-slope * (age - a50))); +} + +std::array default_weight_at_age() { + return {0.40, 0.85, 1.35, 1.95, 2.60, 3.25, 3.85, 4.35, 4.75, 5.05}; +} + +std::array default_maturity_at_age() { + return {0.00, 0.10, 0.35, 0.65, 0.85, 0.95, 1.00, 1.00, 1.00, 1.00}; +} + +std::vector split_csv_line(const std::string &line) { + std::vector out; + std::stringstream ss(line); + std::string item; + while (std::getline(ss, item, ',')) { + out.push_back(item); + } + return out; +} + +std::vector read_observations(const std::string &path) { + std::ifstream in(path); + if (!in) { + throw std::runtime_error("Could not open observations CSV: " + path); + } + + std::string line; + std::getline(in, line); + + std::vector out; + while (std::getline(in, line)) { + if (line.empty()) { + continue; + } + + const auto fields = split_csv_line(line); + if (fields.size() != 13) { + throw std::runtime_error("Expected 13 columns in observations CSV"); + } + + Observation obs; + obs.year = std::stoi(fields[0]); + obs.catch_mt = std::stod(fields[1]); + obs.index = std::stod(fields[2]); + for (int a = 0; a < kAges; ++a) { + obs.age_comp[static_cast(a)] = std::stod(fields[3 + a]); + } + + double age_comp_sum = 0.0; + for (double v : obs.age_comp) { + age_comp_sum += v; + } + if (age_comp_sum > 0.0) { + for (double &v : obs.age_comp) { + v /= age_comp_sum; + } + } + out.push_back(obs); + } + + return out; +} + +double biomass_from_numbers(const std::array &n, + const std::array &weight) { + double out = 0.0; + for (int a = 0; a < kAges; ++a) { + out += n[static_cast(a)] * weight[static_cast(a)]; + } + return out; +} + +double ssb_from_numbers(const std::array &n, + const std::array &weight, + const std::array &maturity) { + double out = 0.0; + for (int a = 0; a < kAges; ++a) { + out += n[static_cast(a)] * + weight[static_cast(a)] * + maturity[static_cast(a)]; + } + return out; +} + +std::array unfished_equilibrium_numbers(double r0, double m) { + std::array n{}; + n[0] = r0; + for (int a = 1; a < kAges; ++a) { + n[static_cast(a)] = + n[static_cast(a - 1)] * std::exp(-m); + } + + // Plus group. + n[static_cast(kAges - 1)] /= + std::max(1.0e-12, 1.0 - std::exp(-m)); + + return n; +} + +std::vector run_deterministic_age_structured_model( + const std::vector &observations, + const AgeStructuredParams ¶ms) { + const auto weight = default_weight_at_age(); + const auto maturity = default_maturity_at_age(); + + const double r0 = std::exp(params.log_r0); + const double m = std::exp(params.log_m); + const double fbar = std::exp(params.log_fbar); + const double q = std::exp(params.log_q); + + std::array selectivity{}; + for (int a = 0; a < kAges; ++a) { + selectivity[static_cast(a)] = logistic_selectivity( + static_cast(a + 1), params.sel_a50, params.sel_slope); + } + + std::array n = unfished_equilibrium_numbers(r0, m); + const double unfished_ssb = ssb_from_numbers(n, weight, maturity); + + std::vector rows; + rows.reserve(observations.size()); + + for (const auto &obs : observations) { + const double biomass = biomass_from_numbers(n, weight); + const double ssb = ssb_from_numbers(n, weight, maturity); + + double catch_hat = 0.0; + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + const double f_a = fbar * selectivity[i]; + const double z_a = m + f_a; + const double harvest_rate = + z_a > 0.0 ? (f_a / z_a) * (1.0 - std::exp(-z_a)) : 0.0; + catch_hat += n[i] * weight[i] * harvest_rate; + } + + AgeStructuredRow row; + row.year = obs.year; + row.recruitment = r0; + row.total_biomass = biomass; + row.ssb_proxy = ssb; + row.depletion = ssb / std::max(1.0e-12, unfished_ssb); + row.fbar = fbar; + row.catch_obs = obs.catch_mt; + row.catch_hat = catch_hat; + row.index_obs = obs.index; + row.index_hat = q * biomass; + rows.push_back(row); + + std::array next{}; + next[0] = r0; + + for (int a = 1; a < kAges; ++a) { + const auto prev = static_cast(a - 1); + const double f_prev = fbar * selectivity[prev]; + const double z_prev = m + f_prev; + next[static_cast(a)] = n[prev] * std::exp(-z_prev); + } + + // Plus group survivor contribution. + { + const auto last = static_cast(kAges - 1); + const double f_last = fbar * selectivity[last]; + const double z_last = m + f_last; + next[last] += n[last] * std::exp(-z_last); + } + + n = next; + } + + return rows; +} + +void write_age_structured_rows(const std::string &path, + const std::vector &rows) { + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open output CSV: " + path); + } + + out << "year,recruitment,total_biomass,ssb_proxy,depletion,Fbar," + << "catch_obs,catch_hat,index_obs,index_hat\n"; + + out << std::fixed << std::setprecision(6); + for (const auto &row : rows) { + out << row.year << "," << row.recruitment << "," << row.total_biomass << "," + << row.ssb_proxy << "," << row.depletion << "," << row.fbar << "," + << row.catch_obs << "," << row.catch_hat << "," << row.index_obs << "," + << row.index_hat << "\n"; + } +} + +} // namespace sefsc_red_snapper diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 new file mode 100755 index 0000000..e36bac9 Binary files /dev/null and b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 differ diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0.cpp b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0.cpp new file mode 100644 index 0000000..80d5ca6 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0.cpp @@ -0,0 +1,108 @@ +#include "red_snapper_model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::vector split_csv_line(const std::string &line) { + std::vector out; + std::stringstream ss(line); + std::string item; + while (std::getline(ss, item, ',')) { + out.push_back(item); + } + return out; +} + +std::vector +read_observations(const std::string &path) { + std::ifstream in(path); + if (!in) { + throw std::runtime_error("Could not open observations CSV: " + path); + } + + std::string line; + std::getline(in, line); // header + + std::vector out; + while (std::getline(in, line)) { + if (line.empty()) + continue; + const auto fields = split_csv_line(line); + if (fields.size() != 13) { + throw std::runtime_error("Expected 13 columns in observations CSV"); + } + + sefsc_red_snapper::Observation obs; + obs.year = std::stoi(fields[0]); + obs.catch_mt = std::stod(fields[1]); + obs.index = std::stod(fields[2]); + for (std::size_t a = 0; a < obs.age_comp.size(); ++a) { + obs.age_comp[a] = std::stod(fields[3 + a]); + } + + double age_comp_sum = 0.0; + for (double v : obs.age_comp) { + age_comp_sum += v; + } + if (age_comp_sum > 0.0) { + for (double &v : obs.age_comp) { + v /= age_comp_sum; + } + } + out.push_back(obs); + } + return out; +} + +void write_derived_quantities( + const std::string &path, + const std::vector &rows) { + std::ofstream out(path); + out << "year,biomass,ssb_proxy,depletion,F_proxy,index_hat\n"; + out << std::fixed << std::setprecision(6); + for (const auto &row : rows) { + out << row.year << "," << row.biomass << "," << row.ssb_proxy << "," + << row.depletion << "," << row.f_proxy << "," << row.index_hat << "\n"; + } +} + +} // namespace + +int main() { + const std::string input_path = "examples/NMFS/sefsc_red_snapper/data/" + "synthetic_red_snapper_observations.csv"; + const std::string output_path = + "examples/NMFS/sefsc_red_snapper/outputs/level0_derived_quantities.csv"; + + auto observations = read_observations(input_path); + sefsc_red_snapper::RedSnapperModel model(observations); + + // Fixed placeholder values. Next patch should estimate these. + const double log_r0 = std::log(1400.0); + const double log_q = std::log(0.001); + const double log_f = std::log(0.25); + + auto trajectory = model.deterministic_trajectory(log_r0, log_q, log_f); + write_derived_quantities(output_path, trajectory); + + std::cout << "SEFSC red-snapper-style Level-0 scaffold\n"; + std::cout << "observations: " << observations.size() << "\n"; + std::cout << "wrote: " << output_path << "\n"; + + if (!trajectory.empty()) { + const auto &last = trajectory.back(); + std::cout << "terminal biomass: " << last.biomass << "\n"; + std::cout << "terminal depletion: " << last.depletion << "\n"; + } + + return 0; +} diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_model.hpp b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_model.hpp new file mode 100644 index 0000000..71530db --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_model.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +struct Observation { + int year = 0; + double catch_mt = 0.0; + double index = 0.0; + std::array age_comp{}; +}; + +struct ProjectionScenario { + std::string scenario; + int projection_year = 0; + double catch_mt = 0.0; +}; + +struct DerivedRow { + int year = 0; + double biomass = 0.0; + double ssb_proxy = 0.0; + double depletion = 0.0; + double f_proxy = 0.0; + double index_hat = 0.0; +}; + +// Level-0 placeholder model: +// This is intentionally minimal. The next patch should replace this with +// Quadra AD/Laplace evaluation and recruitment deviations as random effects. +class RedSnapperModel { +public: + explicit RedSnapperModel(std::vector obs) + : observations_(std::move(obs)) {} + + const std::vector &observations() const { return observations_; } + + std::vector deterministic_trajectory(double log_r0, double log_q, + double log_f) const { + const double r0 = std::exp(log_r0); + const double q = std::exp(log_q); + const double f = std::exp(log_f); + + std::vector out; + out.reserve(observations_.size()); + + double biomass = r0; + const double unfished = r0; + + for (const auto &obs : observations_) { + biomass = + std::max(1.0, biomass + 0.25 * r0 - obs.catch_mt - 0.05 * biomass); + DerivedRow row; + row.year = obs.year; + row.biomass = biomass; + row.ssb_proxy = 0.35 * biomass; + row.depletion = biomass / unfished; + row.f_proxy = f * obs.catch_mt / std::max(1.0, biomass); + row.index_hat = q * biomass; + out.push_back(row); + } + + return out; + } + +private: + std::vector observations_; +}; + +} // namespace sefsc_red_snapper diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_objective.hpp b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_objective.hpp new file mode 100644 index 0000000..9aa77e2 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_objective.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "red_snapper_age_structured.hpp" + +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +struct ObjectiveOptions { + double sigma_log_index = 0.20; + double sigma_log_catch = 0.15; + double min_positive = 1.0e-12; +}; + +struct ObjectiveBreakdown { + double total = 0.0; + double index_nll = 0.0; + double catch_nll = 0.0; + int n_index = 0; + int n_catch = 0; +}; + +inline double square(double x) { return x * x; } + +inline double lognormal_nll_no_constant(double observed, double predicted, + double sigma, double min_positive) { + const double obs = std::max(observed, min_positive); + const double pred = std::max(predicted, min_positive); + const double z = (std::log(obs) - std::log(pred)) / sigma; + return 0.5 * square(z); +} + +inline ObjectiveBreakdown evaluate_objective_breakdown( + const std::vector &observations, + const AgeStructuredParams ¶ms, + const ObjectiveOptions &options = ObjectiveOptions{}) { + ObjectiveBreakdown out; + + const auto rows = + run_deterministic_age_structured_model(observations, params); + if (rows.size() != observations.size()) { + throw std::runtime_error("Objective trajectory/observation size mismatch"); + } + + for (std::size_t i = 0; i < observations.size(); ++i) { + const auto &obs = observations[i]; + const auto &pred = rows[i]; + + if (std::isfinite(obs.index) && obs.index > 0.0) { + const double nll = lognormal_nll_no_constant(obs.index, pred.index_hat, + options.sigma_log_index, + options.min_positive); + out.index_nll += nll; + ++out.n_index; + } + + if (std::isfinite(obs.catch_mt) && obs.catch_mt > 0.0) { + const double nll = lognormal_nll_no_constant(obs.catch_mt, pred.catch_hat, + options.sigma_log_catch, + options.min_positive); + out.catch_nll += nll; + ++out.n_catch; + } + } + + out.total = out.index_nll + out.catch_nll; + return out; +} + +inline double +evaluate_objective(const std::vector &observations, + const AgeStructuredParams ¶ms, + const ObjectiveOptions &options = ObjectiveOptions{}) { + return evaluate_objective_breakdown(observations, params, options).total; +} + +} // namespace sefsc_red_snapper diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit new file mode 100755 index 0000000..b836b1e Binary files /dev/null and b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit differ diff --git a/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp new file mode 100644 index 0000000..8803a53 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp @@ -0,0 +1,632 @@ +#include "red_snapper_age_structured.hpp" + +#include "../../../../core/optimizer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace sefsc_red_snapper { + +template T exp_t(const T &x) { + using std::exp; + return exp(x); +} + +template T log_t(const T &x) { + using std::log; + return log(x); +} + +template T invlogit_t(const T &x) { + return T(1.0) / (T(1.0) + exp_t(-x)); +} + +template T max_t(const T &x, double floor) { + return x > T(floor) ? x : T(floor); +} + +template T square_t(const T &x) { return x * x; } + +template +T logistic_selectivity_t(const T &age, const T &a50, const T &slope) { + return T(1.0) / (T(1.0) + exp_t(-slope * (age - a50))); +} + +template +T age_comp_nll(const std::array &observed, + const std::array &predicted, double effective_n, + double floor = 1.0e-12) { + T nll = T(0.0); + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + const double obs = std::max(observed[i], 0.0); + if (obs > 0.0) { + nll = nll - T(effective_n * obs) * log_t(max_t(predicted[i], floor)); + } + } + return nll; +} + +class RedSnapperQuadraObjective { +public: + explicit RedSnapperQuadraObjective(std::vector observations) + : observations_(std::move(observations)) {} + + template T operator()(const std::vector &par) const { + if (par.size() < 5 + observations_.size()) { + throw std::runtime_error("RedSnapperQuadraObjective expected parameters: " + "log_r0, log_fbar, log_q"); + } + + const T log_r0 = par[0]; + const T log_fbar = par[1]; + const T log_q = par[2]; + const T logit_sel_a50 = par[3]; + const T log_sel_slope = par[4]; + + const T r0 = exp_t(log_r0); + const T m = T(0.18); + const T fbar = exp_t(log_fbar); + const T q = exp_t(log_q); + const T sel_a50 = T(1.0) + T(9.0) * invlogit_t(logit_sel_a50); + const T sel_slope = exp_t(log_sel_slope); + + const T sigma_log_index = T(0.20); + const T sigma_log_catch = T(0.15); + + const T sigma_rec_dev = T(0.35); + const double age_comp_effective_n = 2.0; + const double min_positive = 1.0e-12; + + const auto weight = default_weight_at_age(); + const auto maturity = default_maturity_at_age(); + + std::array selectivity{}; + for (int a = 0; a < kAges; ++a) { + selectivity[static_cast(a)] = + logistic_selectivity_t(T(a + 1), sel_a50, sel_slope); + } + + std::array n{}; + n[0] = r0; + for (int a = 1; a < kAges; ++a) { + n[static_cast(a)] = + n[static_cast(a - 1)] * exp_t(-m); + } + n[static_cast(kAges - 1)] = + n[static_cast(kAges - 1)] / (T(1.0) - exp_t(-m)); + + T nll = T(0.0); + T fixed_prior_nll = T(0.0); + T rec_prior_nll = T(0.0); + T index_nll = T(0.0); + T catch_nll = T(0.0); + T age_comp_nll_total = T(0.0); + + auto normal_prior = [](const T &x, double mean, double sd) { + const T z = (x - T(mean)) / T(sd); + return T(0.5) * z * z; + }; + + fixed_prior_nll = + fixed_prior_nll + normal_prior(log_r0, std::log(1200.0), 1.0); + fixed_prior_nll = + fixed_prior_nll + normal_prior(log_fbar, std::log(0.025), 0.75); + fixed_prior_nll = + fixed_prior_nll + normal_prior(log_q, std::log(0.00005), 1.0); + fixed_prior_nll = fixed_prior_nll + normal_prior(sel_a50, 4.0, 0.75); + fixed_prior_nll = + fixed_prior_nll + normal_prior(log_sel_slope, std::log(1.2), 0.35); + + nll = nll + fixed_prior_nll; + + for (std::size_t t = 0; t < observations_.size(); ++t) { + + const auto &obs = observations_[t]; + + const T rec_dev = par[5 + t]; + + { + T term = T(0.5) * square_t(rec_dev / sigma_rec_dev); + rec_prior_nll = rec_prior_nll + term; + nll = nll + term; + } + T biomass = T(0.0); + for (int a = 0; a < kAges; ++a) { + biomass = biomass + n[static_cast(a)] * + T(weight[static_cast(a)]); + } + + T catch_hat = T(0.0); + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + const T f_a = fbar * selectivity[i]; + const T z_a = m + f_a; + const T harvest_rate = (f_a / z_a) * (T(1.0) - exp_t(-z_a)); + catch_hat = catch_hat + n[i] * T(weight[i]) * harvest_rate; + } + + const T index_hat = q * biomass; + + if (obs.index > 0.0) { + const T z = + (log_t(T(obs.index)) - log_t(max_t(index_hat, min_positive))) / + sigma_log_index; + { + T term = T(0.5) * square_t(z); + index_nll = index_nll + term; + nll = nll + term; + } + } + + if (obs.catch_mt > 0.0) { + const T z = + (log_t(T(obs.catch_mt)) - log_t(max_t(catch_hat, min_positive))) / + sigma_log_catch; + { + T term = T(0.5) * square_t(z); + catch_nll = catch_nll + term; + nll = nll + term; + } + } + + std::array pred_age_comp{}; + T selected_numbers_sum = T(0.0); + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + pred_age_comp[i] = n[i] * selectivity[i]; + selected_numbers_sum = selected_numbers_sum + pred_age_comp[i]; + } + for (int a = 0; a < kAges; ++a) { + const auto i = static_cast(a); + pred_age_comp[i] = + pred_age_comp[i] / max_t(selected_numbers_sum, min_positive); + } + + { + T term = age_comp_nll(obs.age_comp, pred_age_comp, age_comp_effective_n, + min_positive); + age_comp_nll_total = age_comp_nll_total + term; + nll = nll + term; + } + + std::array next{}; + next[0] = r0 * exp_t(rec_dev); + + for (int a = 1; a < kAges; ++a) { + const auto prev = static_cast(a - 1); + const T f_prev = fbar * selectivity[prev]; + const T z_prev = m + f_prev; + next[static_cast(a)] = n[prev] * exp_t(-z_prev); + } + + const auto last = static_cast(kAges - 1); + const T f_last = fbar * selectivity[last]; + const T z_last = m + f_last; + next[last] = next[last] + n[last] * exp_t(-z_last); + + n = next; + } + + return nll; + } + +private: + std::vector observations_; +}; + +void write_fit_summary(const std::string &path, const quadra::OptResult &fit) { + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open fit summary CSV: " + path); + } + + out << "field,value\n"; + out << std::setprecision(12); + out << "objective," << fit.value << "\n"; + out << "joint_objective," << fit.joint_objective << "\n"; + out << "laplace_logdet," << fit.laplace_logdet << "\n"; + out << "laplace_constant," << fit.laplace_constant << "\n"; + out << "grad_norm," << fit.grad_norm << "\n"; + out << "iterations," << fit.iterations << "\n"; + out << "converged," << (fit.converged ? "yes" : "no") << "\n"; + out << "message," << fit.message << "\n"; + out << "laplace,yes\n"; + out << "random_effects," << fit.u_hat.size() << "\n"; + + if (fit.par.size() >= 3) { + out << "log_r0," << fit.par[0] << "\n"; + out << "r0," << std::exp(fit.par[0]) << "\n"; + out << "log_fbar," << fit.par[1] << "\n"; + out << "fbar," << std::exp(fit.par[1]) << "\n"; + out << "log_q," << fit.par[2] << "\n"; + out << "q," << std::exp(fit.par[2]) << "\n"; + if (fit.par.size() >= 5) { + const double sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3])); + const double sel_slope = std::exp(fit.par[4]); + out << "logit_sel_a50," << fit.par[3] << "\n"; + out << "sel_a50," << sel_a50 << "\n"; + out << "log_sel_slope," << fit.par[4] << "\n"; + out << "sel_slope," << sel_slope << "\n"; + } + } +} + +} // namespace sefsc_red_snapper + +void write_fitted_trajectory( + const std::string &path, + const std::vector &observations, + const quadra::OptResult &fit) { + if (fit.par.size() < 3) { + throw std::runtime_error( + "Cannot write fitted trajectory: expected at least 3 fixed parameters"); + } + + sefsc_red_snapper::AgeStructuredParams params; + params.log_r0 = fit.par[0]; + params.log_fbar = fit.par[1]; + params.log_q = fit.par[2]; + if (fit.par.size() >= 5) { + params.sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3])); + params.sel_slope = std::exp(fit.par[4]); + } + + const auto rows = sefsc_red_snapper::run_deterministic_age_structured_model( + observations, params); + + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open fitted trajectory CSV: " + path); + } + + out << "year,recruitment,total_biomass,ssb_proxy,depletion,Fbar," + << "catch_obs,catch_hat,catch_log_residual,index_obs,index_hat," + << "index_log_residual\n"; + + out << std::fixed << std::setprecision(6); + + for (const auto &row : rows) { + const double catch_log_residual = + std::log(std::max(row.catch_obs, 1.0e-12)) - + std::log(std::max(row.catch_hat, 1.0e-12)); + const double index_log_residual = + std::log(std::max(row.index_obs, 1.0e-12)) - + std::log(std::max(row.index_hat, 1.0e-12)); + + out << row.year << "," << row.recruitment << "," << row.total_biomass << "," + << row.ssb_proxy << "," << row.depletion << "," << row.fbar << "," + << row.catch_obs << "," << row.catch_hat << "," << catch_log_residual + << "," << row.index_obs << "," << row.index_hat << "," + << index_log_residual << "\n"; + } +} + +struct ResidualDiagnostics { + int n = 0; + double catch_rmse_log = 0.0; + double index_rmse_log = 0.0; + double catch_mean_log_residual = 0.0; + double index_mean_log_residual = 0.0; + double max_abs_catch_log_residual = 0.0; + double max_abs_index_log_residual = 0.0; +}; + +void write_residual_diagnostics( + const std::string &path, + const std::vector &observations, + const quadra::OptResult &fit) { + sefsc_red_snapper::AgeStructuredParams params; + params.log_r0 = fit.par[0]; + params.log_fbar = fit.par[1]; + params.log_q = fit.par[2]; + if (fit.par.size() >= 5) { + params.sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3])); + params.sel_slope = std::exp(fit.par[4]); + } + + const auto rows = sefsc_red_snapper::run_deterministic_age_structured_model( + observations, params); + + ResidualDiagnostics d; + d.n = static_cast(rows.size()); + + double catch_sum = 0.0, catch_ss = 0.0; + double index_sum = 0.0, index_ss = 0.0; + + for (const auto &row : rows) { + const double cr = std::log(std::max(row.catch_obs, 1.0e-12)) - + std::log(std::max(row.catch_hat, 1.0e-12)); + const double ir = std::log(std::max(row.index_obs, 1.0e-12)) - + std::log(std::max(row.index_hat, 1.0e-12)); + + catch_sum += cr; + catch_ss += cr * cr; + index_sum += ir; + index_ss += ir * ir; + + d.max_abs_catch_log_residual = + std::max(d.max_abs_catch_log_residual, std::abs(cr)); + d.max_abs_index_log_residual = + std::max(d.max_abs_index_log_residual, std::abs(ir)); + } + + if (d.n > 0) { + d.catch_mean_log_residual = catch_sum / d.n; + d.index_mean_log_residual = index_sum / d.n; + d.catch_rmse_log = std::sqrt(catch_ss / d.n); + d.index_rmse_log = std::sqrt(index_ss / d.n); + } + + std::ofstream out(path); + out << "metric,value,note\n"; + out << std::setprecision(12); + out << "n," << d.n << ",number of fitted years\n"; + out << "catch_rmse_log," << d.catch_rmse_log + << ",root mean squared log catch residual\n"; + out << "index_rmse_log," << d.index_rmse_log + << ",root mean squared log index residual\n"; + out << "catch_mean_log_residual," << d.catch_mean_log_residual + << ",mean log observed minus predicted catch\n"; + out << "index_mean_log_residual," << d.index_mean_log_residual + << ",mean log observed minus predicted index\n"; + out << "max_abs_catch_log_residual," << d.max_abs_catch_log_residual + << ",maximum absolute log catch residual\n"; + out << "max_abs_index_log_residual," << d.max_abs_index_log_residual + << ",maximum absolute log index residual\n"; +} + +void write_selectivity_at_age(const std::string &path, + const quadra::OptResult &fit) { + if (fit.par.size() < 5) { + return; + } + + const double a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3])); + const double slope = std::exp(fit.par[4]); + + std::ofstream out(path); + out << "age,selectivity\n"; + + for (int age = 1; age <= sefsc_red_snapper::kAges; ++age) { + const double sel = 1.0 / (1.0 + std::exp(-slope * (age - a50))); + out << age << "," << sel << "\n"; + } +} + +void write_recruitment_deviations(const std::string &path, + const quadra::OptResult &fit) { + std::ofstream out(path); + out << "year,log_rec_dev,rec_multiplier\n"; + out << std::setprecision(12); + + for (std::size_t i = 0; i < fit.u_hat.size(); ++i) { + const double u = fit.u_hat[i]; + out << (i + 1) << "," << u << "," << std::exp(u) << "\n"; + } +} + +void write_objective_components( + const std::string &path, + const std::vector &observations, + const quadra::OptResult &fit) { + if (fit.par.size() < 5 || fit.u_hat.size() < observations.size()) { + throw std::runtime_error( + "Cannot write objective components: missing fit values"); + } + + const double log_r0 = fit.par[0]; + const double log_fbar = fit.par[1]; + const double log_q = fit.par[2]; + const double logit_sel_a50 = fit.par[3]; + const double log_sel_slope = fit.par[4]; + + const double r0 = std::exp(log_r0); + const double m = 0.18; + const double fbar = std::exp(log_fbar); + const double q = std::exp(log_q); + const double sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-logit_sel_a50)); + const double sel_slope = std::exp(log_sel_slope); + + const double sigma_log_index = 0.20; + const double sigma_log_catch = 0.15; + const double sigma_rec_dev = 0.35; + const double age_comp_effective_n = 2.0; + const double min_positive = 1.0e-12; + + const auto weight = sefsc_red_snapper::default_weight_at_age(); + + std::array selectivity{}; + for (int a = 0; a < sefsc_red_snapper::kAges; ++a) { + selectivity[static_cast(a)] = + sefsc_red_snapper::logistic_selectivity(static_cast(a + 1), + sel_a50, sel_slope); + } + + std::array n{}; + n[0] = r0; + for (int a = 1; a < sefsc_red_snapper::kAges; ++a) { + n[static_cast(a)] = + n[static_cast(a - 1)] * std::exp(-m); + } + n[static_cast(sefsc_red_snapper::kAges - 1)] = + n[static_cast(sefsc_red_snapper::kAges - 1)] / + (1.0 - std::exp(-m)); + + auto normal_prior = [](double x, double mean, double sd) { + const double z = (x - mean) / sd; + return 0.5 * z * z; + }; + + double fixed_prior_nll = 0.0; + double rec_prior_nll = 0.0; + double index_nll = 0.0; + double catch_nll = 0.0; + double age_comp_nll = 0.0; + + fixed_prior_nll += normal_prior(log_r0, std::log(1200.0), 1.0); + fixed_prior_nll += normal_prior(log_fbar, std::log(0.025), 0.75); + fixed_prior_nll += normal_prior(log_q, std::log(0.00005), 1.0); + fixed_prior_nll += normal_prior(sel_a50, 4.0, 0.75); + fixed_prior_nll += normal_prior(log_sel_slope, std::log(1.2), 0.35); + + for (std::size_t t = 0; t < observations.size(); ++t) { + const auto &obs = observations[t]; + const double rec_dev = fit.u_hat[t]; + + rec_prior_nll += 0.5 * std::pow(rec_dev / sigma_rec_dev, 2.0); + + double biomass = 0.0; + for (int a = 0; a < sefsc_red_snapper::kAges; ++a) { + biomass += + n[static_cast(a)] * weight[static_cast(a)]; + } + + double catch_hat = 0.0; + for (int a = 0; a < sefsc_red_snapper::kAges; ++a) { + const auto i = static_cast(a); + const double f_a = fbar * selectivity[i]; + const double z_a = m + f_a; + const double harvest_rate = (f_a / z_a) * (1.0 - std::exp(-z_a)); + catch_hat += n[i] * weight[i] * harvest_rate; + } + + const double index_hat = q * biomass; + + if (obs.index > 0.0) { + const double z = + (std::log(obs.index) - std::log(std::max(index_hat, min_positive))) / + sigma_log_index; + index_nll += 0.5 * z * z; + } + + if (obs.catch_mt > 0.0) { + const double z = (std::log(obs.catch_mt) - + std::log(std::max(catch_hat, min_positive))) / + sigma_log_catch; + catch_nll += 0.5 * z * z; + } + + std::array pred_age_comp{}; + double selected_numbers_sum = 0.0; + for (int a = 0; a < sefsc_red_snapper::kAges; ++a) { + const auto i = static_cast(a); + pred_age_comp[i] = n[i] * selectivity[i]; + selected_numbers_sum += pred_age_comp[i]; + } + + for (int a = 0; a < sefsc_red_snapper::kAges; ++a) { + const auto i = static_cast(a); + pred_age_comp[i] = + pred_age_comp[i] / std::max(selected_numbers_sum, min_positive); + + const double obs_a = std::max(obs.age_comp[i], 0.0); + if (obs_a > 0.0) { + age_comp_nll -= age_comp_effective_n * obs_a * + std::log(std::max(pred_age_comp[i], min_positive)); + } + } + + std::array next{}; + next[0] = r0 * std::exp(rec_dev); + for (int a = 1; a < sefsc_red_snapper::kAges; ++a) { + const auto prev = static_cast(a - 1); + const auto cur = static_cast(a); + const double f_prev = fbar * selectivity[prev]; + const double z_prev = m + f_prev; + next[cur] = n[prev] * std::exp(-z_prev); + } + + const int plus_group = sefsc_red_snapper::kAges - 1; + const auto pg = static_cast(plus_group); + const double f_pg = fbar * selectivity[pg]; + const double z_pg = m + f_pg; + next[pg] += n[pg] * std::exp(-z_pg); + + n = next; + } + + std::ofstream out(path); + if (!out) { + throw std::runtime_error("Could not open component CSV: " + path); + } + + out << "component,value\n"; + out << std::setprecision(12); + out << "fixed_prior_nll," << fixed_prior_nll << "\n"; + out << "rec_prior_nll," << rec_prior_nll << "\n"; + out << "index_nll," << index_nll << "\n"; + out << "catch_nll," << catch_nll << "\n"; + out << "age_comp_nll," << age_comp_nll << "\n"; + out << "joint_total," + << fixed_prior_nll + rec_prior_nll + index_nll + catch_nll + age_comp_nll + << "\n"; +} + +int main() { + const std::string input_path = "examples/NMFS/sefsc_red_snapper/data/" + "synthetic_red_snapper_observations.csv"; + const std::string summary_path = + "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv"; + const std::string trajectory_path = + "examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv"; + const std::string residual_diagnostics_path = + "examples/NMFS/sefsc_red_snapper/outputs/" + "quadra_fit_residual_diagnostics.csv"; + const std::string selectivity_path = + "examples/NMFS/sefsc_red_snapper/outputs/selectivity_at_age.csv"; + const std::string recruitment_deviations_path = + "examples/NMFS/sefsc_red_snapper/outputs/recruitment_deviations.csv"; + const std::string objective_components_path = + "examples/NMFS/sefsc_red_snapper/outputs/" + "quadra_fit_objective_components.csv"; + const auto observations = sefsc_red_snapper::read_observations(input_path); + + sefsc_red_snapper::RedSnapperQuadraObjective objective(observations); + + quadra::ParameterVector params; + params.add({"log_r0", std::log(1200.0), quadra::ParameterTransform::Identity, + false}); + params.add({"log_fbar", std::log(0.025), quadra::ParameterTransform::Identity, + false}); + params.add({"log_q", std::log(0.00005), quadra::ParameterTransform::Identity, + false}); + params.add( + {"logit_sel_a50", 0.0, quadra::ParameterTransform::Identity, false}); + params.add({"log_sel_slope", std::log(1.2), + quadra::ParameterTransform::Identity, false}); + + for (std::size_t t = 0; t < observations.size(); ++t) { + params.add({"log_rec_dev_" + std::to_string(t + 1), 0.0, + quadra::ParameterTransform::Identity, true}); + } + + quadra::LaplaceOptions opts; + + auto fit = quadra::optimize_lbfgs(objective, params, opts); + + sefsc_red_snapper::write_fit_summary(summary_path, fit); + write_fitted_trajectory(trajectory_path, observations, fit); + write_residual_diagnostics(residual_diagnostics_path, observations, fit); + write_selectivity_at_age(selectivity_path, fit); + write_recruitment_deviations(recruitment_deviations_path, fit); + write_objective_components(objective_components_path, observations, fit); + std::cout + << "SEFSC red-snapper-style Quadra Laplace recruitment-deviation fit\n"; + std::cout << "objective: " << fit.value << "\n"; + std::cout << "grad_norm: " << fit.grad_norm << "\n"; + std::cout << "converged: " << (fit.converged ? "yes" : "no") << "\n"; + std::cout << "message: " << fit.message << "\n"; + std::cout << "wrote: " << summary_path << "\n"; + std::cout << "wrote: " << trajectory_path << "\n"; + std::cout << "wrote: " << residual_diagnostics_path << "\n"; + std::cout << "wrote: " << selectivity_path << "\n"; + std::cout << "wrote: " << recruitment_deviations_path << "\n"; + std::cout << "wrote: " << objective_components_path << "\n"; + return 0; +} diff --git a/examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh b/examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh new file mode 100755 index 0000000..de4cf16 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh +Rscript examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R +python3 examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py +cat examples/NMFS/sefsc_red_snapper/outputs/quadra_vs_tmb_fit_comparison.csv diff --git a/examples/NMFS/sefsc_red_snapper/run_red_snapper_age_structured.sh b/examples/NMFS/sefsc_red_snapper/run_red_snapper_age_structured.sh new file mode 100755 index 0000000..9b7ece9 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/run_red_snapper_age_structured.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured \ + examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured diff --git a/examples/NMFS/sefsc_red_snapper/run_red_snapper_level0.sh b/examples/NMFS/sefsc_red_snapper/run_red_snapper_level0.sh new file mode 100755 index 0000000..4141354 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/run_red_snapper_level0.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 \ + examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 diff --git a/examples/NMFS/sefsc_red_snapper/run_red_snapper_objective.sh b/examples/NMFS/sefsc_red_snapper/run_red_snapper_objective.sh new file mode 100755 index 0000000..052ccd0 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/run_red_snapper_objective.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -o examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective \ + examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective diff --git a/examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh b/examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh new file mode 100755 index 0000000..812ddc9 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p examples/NMFS/sefsc_red_snapper/outputs + +c++ -DQUADRA_DEBUG_FD_FINAL_GRADIENT -DQUADRA_DEBUG_LAPLACE_GRADIENT_PARTS -std=c++17 -O3 \ + -I. \ + -Iexternal/eigen \ + -Icore \ + -Iexternal/LBFGSpp/include \ + \ + -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit \ + examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp \ + examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp + +./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit diff --git a/examples/NMFS/sefsc_red_snapper/tmb/README.md b/examples/NMFS/sefsc_red_snapper/tmb/README.md new file mode 100644 index 0000000..b698dd3 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/tmb/README.md @@ -0,0 +1,5 @@ +# TMB Reference Implementation + +This directory will contain the TMB comparison model for the SEFSC red-snapper-style example. + +The current file is a placeholder and should not be used as a scientific reference yet. diff --git a/examples/NMFS/sefsc_red_snapper/tmb/evaluate_tmb_at_quadra_fit.R b/examples/NMFS/sefsc_red_snapper/tmb/evaluate_tmb_at_quadra_fit.R new file mode 100644 index 0000000..d327feb --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/tmb/evaluate_tmb_at_quadra_fit.R @@ -0,0 +1,327 @@ +library(TMB) + +obs <- read.csv("examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv") +catch_obs <- obs$catch_mt +index_obs <- obs$index +age_cols <- grep("^age[0-9]+$", names(obs), value = TRUE) +age_comp_obs <- as.matrix(obs[, age_cols, drop = FALSE]) +age_comp_obs <- age_comp_obs / rowSums(age_comp_obs) + +cpp <- "examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp" +dyn <- sub("\\.cpp$", "", basename(cpp)) +if (!file.exists(file.path("examples/NMFS/sefsc_red_snapper/tmb", paste0(dyn, .Platform$dynlib.ext)))) { + TMB::compile(cpp) +} +dyn.load(dynlib(file.path("examples/NMFS/sefsc_red_snapper/tmb", dyn))) + +qsum <- read.csv("examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv") +qval <- setNames(qsum$value, qsum$field) + +qrec <- read.csv("examples/NMFS/sefsc_red_snapper/outputs/recruitment_deviations.csv") + +parameters <- list( + log_r0 = as.numeric(qval["log_r0"]), + log_fbar = as.numeric(qval["log_fbar"]), + log_q = as.numeric(qval["log_q"]), + logit_sel_a50 = as.numeric(qval["logit_sel_a50"]), + log_sel_slope = as.numeric(qval["log_sel_slope"]), + log_rec_dev = qrec$log_rec_dev +) + +obj <- MakeADFun( + data = list( + catch_obs = catch_obs, + index_obs = index_obs, + age_comp_obs = age_comp_obs + ), + parameters = parameters, + random = "log_rec_dev", + DLL = "red_snapper_tmb", + silent = TRUE +) + +cat("TMB Laplace objective at Quadra fit:", obj$fn(), "\n") +cat("TMB gradient at Quadra fit:\n") +print(obj$gr()) + +H <- obj$env$spHess(random = TRUE) +H <- as.matrix(H) +write.csv(H, + "examples/NMFS/sefsc_red_snapper/outputs/tmb_Huu_at_quadra_fit.csv", + row.names = FALSE +) +cat( + "TMB Huu logdet:", + as.numeric(determinant(H, logarithm = TRUE)$modulus), + "\n" +) + +rep <- obj$report() +write.csv( + data.frame( + component = c("fixed_prior_nll", "rec_prior_nll", "index_nll", "catch_nll", "age_comp_nll"), + value = c(rep$fixed_prior_nll, rep$rec_prior_nll, rep$index_nll, rep$catch_nll, rep$age_comp_nll) + ), + "examples/NMFS/sefsc_red_snapper/outputs/tmb_components_at_quadra_fit.csv", + row.names = FALSE +) +print(read.csv("examples/NMFS/sefsc_red_snapper/outputs/tmb_components_at_quadra_fit.csv")) + +cat("TMB random gradient at Quadra u:\n") +cat("length last.par:", length(obj$env$last.par), "\n") +cat("length last.par.best:", length(obj$env$last.par.best), "\n") +cat("length random:", length(obj$env$random), "\n") +cat("random indices:\n") +print(obj$env$random) + +u_from_last <- obj$env$last.par[obj$env$random] +u_from_best <- obj$env$last.par.best[obj$env$random] + +cat("max |last random - Quadra u|:\n") +print(max(abs(u_from_last - qrec$log_rec_dev))) + +cat("max |best random - Quadra u|:\n") +print(max(abs(u_from_best - qrec$log_rec_dev))) + +g <- obj$gr() +cat("TMB grad norm at Quadra fit:", sqrt(sum(g * g)), "\n") + +obj_joint <- MakeADFun( + data = list( + catch_obs = catch_obs, + index_obs = index_obs, + age_comp_obs = age_comp_obs + ), + parameters = parameters, + DLL = "red_snapper_tmb", + silent = TRUE +) + +joint_gr <- obj_joint$gr()[1:5] +laplace_gr <- obj$gr() +implied_logdet_gr <- laplace_gr - joint_gr + +cat("TMB joint fixed gradient at Quadra theta/u:\n") +print(joint_gr) + +cat("TMB Laplace gradient at Quadra fit:\n") +print(laplace_gr) + +cat("TMB implied logdet contribution:\n") +print(implied_logdet_gr) + + +cat("\nTMB profiled logdet FD at Quadra fit:\n") + +fixed_names <- c("log_r0", "log_fbar", "log_q", "logit_sel_a50", "log_sel_slope") +theta0 <- as.numeric(qval[fixed_names]) +names(theta0) <- fixed_names +u0 <- qrec$log_rec_dev + +make_obj_for_theta <- function(theta_vec, u_start = u0) { + pars <- list( + log_r0 = as.numeric(theta_vec["log_r0"]), + log_fbar = as.numeric(theta_vec["log_fbar"]), + log_q = as.numeric(theta_vec["log_q"]), + logit_sel_a50 = as.numeric(theta_vec["logit_sel_a50"]), + log_sel_slope = as.numeric(theta_vec["log_sel_slope"]), + log_rec_dev = u_start + ) + + MakeADFun( + data = list( + catch_obs = catch_obs, + index_obs = index_obs, + age_comp_obs = age_comp_obs + ), + parameters = pars, + random = "log_rec_dev", + DLL = "red_snapper_tmb", + silent = TRUE + ) +} + +get_profiled_u_and_logdet <- function(theta_vec, u_start = u0) { + o <- make_obj_for_theta(theta_vec, u_start) + + # Force evaluation so TMB performs the inner random-effect optimization. + invisible(o$fn()) + + # In this TMB version, profiled random modes are stored in last.par[random]. + u_prof <- o$env$last.par[o$env$random] + + H <- as.matrix(o$env$spHess(random = TRUE)) + logdet <- as.numeric(determinant(H, logarithm = TRUE)$modulus) + + list(u = u_prof, logdet = logdet, obj = o) +} + +eps <- 1e-5 +profiled_logdet_fd <- numeric(length(theta0)) +profiled_u_fd_norm <- numeric(length(theta0)) +names(profiled_logdet_fd) <- fixed_names +names(profiled_u_fd_norm) <- fixed_names + +for (j in seq_along(theta0)) { + th_plus <- theta0 + th_minus <- theta0 + th_plus[j] <- th_plus[j] + eps + th_minus[j] <- th_minus[j] - eps + + plus <- get_profiled_u_and_logdet(th_plus, u0) + minus <- get_profiled_u_and_logdet(th_minus, u0) + + profiled_logdet_fd[j] <- 0.5 * (plus$logdet - minus$logdet) / (2 * eps) + + # This is du*/dtheta_j from true profiling, useful for comparison later. + u_fd <- (plus$u - minus$u) / (2 * eps) + profiled_u_fd_norm[j] <- sqrt(sum(u_fd * u_fd)) +} + +cat("0.5 * profiled logdet FD gradient:\n") +print(profiled_logdet_fd) + +cat("profiled u FD column norms:\n") +print(profiled_u_fd_norm) + +cat("TMB implied logdet contribution from obj$gr - joint_gr:\n") +print(implied_logdet_gr) + +cat("difference: profiled FD - implied TMB logdet contribution:\n") +print(profiled_logdet_fd - implied_logdet_gr) + + +cat("\nTMB manual-random-optimized profiled logdet FD:\n") + +fixed_names <- c("log_r0", "log_fbar", "log_q", "logit_sel_a50", "log_sel_slope") +theta0 <- as.numeric(qval[fixed_names]) +names(theta0) <- fixed_names +u0 <- qrec$log_rec_dev + +make_joint_obj <- function(theta_vec, u_vec) { + pars <- list( + log_r0 = as.numeric(theta_vec["log_r0"]), + log_fbar = as.numeric(theta_vec["log_fbar"]), + log_q = as.numeric(theta_vec["log_q"]), + logit_sel_a50 = as.numeric(theta_vec["logit_sel_a50"]), + log_sel_slope = as.numeric(theta_vec["log_sel_slope"]), + log_rec_dev = u_vec + ) + + MakeADFun( + data = list( + catch_obs = catch_obs, + index_obs = index_obs, + age_comp_obs = age_comp_obs + ), + parameters = pars, + DLL = "red_snapper_tmb", + silent = TRUE + ) +} + +profile_u_for_theta <- function(theta_vec, u_start) { + joint <- make_joint_obj(theta_vec, u_start) + + full_start <- c(theta_vec, u_start) + ntheta <- length(theta_vec) + nu <- length(u_start) + + fn_u <- function(u_vec) { + par <- c(theta_vec, u_vec) + joint$fn(par) + } + + gr_u <- function(u_vec) { + par <- c(theta_vec, u_vec) + as.numeric(joint$gr(par)[(ntheta + 1):(ntheta + nu)]) + } + + opt <- nlminb( + start = u_start, + objective = fn_u, + gradient = gr_u, + control = list( + eval.max = 1000, + iter.max = 1000, + rel.tol = 1e-12, + x.tol = 1e-12 + ) + ) + + list( + u = opt$par, + objective = opt$objective, + convergence = opt$convergence, + message = opt$message, + grad_norm = sqrt(sum(gr_u(opt$par)^2)), + joint = joint + ) +} + +logdet_at_theta_u <- function(theta_vec, u_vec) { + # Use random-enabled TMB object only as a convenient Huu provider at fixed theta/u. + o <- make_obj_for_theta(theta_vec, u_vec) + invisible(o$fn()) + H <- as.matrix(o$env$spHess(random = TRUE)) + as.numeric(determinant(H, logarithm = TRUE)$modulus) +} + +eps <- 1e-5 +manual_profiled_logdet_fd <- numeric(length(theta0)) +manual_u_fd_norm <- numeric(length(theta0)) +manual_u_opt_grad_norm_plus <- numeric(length(theta0)) +manual_u_opt_grad_norm_minus <- numeric(length(theta0)) +manual_u_opt_conv_plus <- integer(length(theta0)) +manual_u_opt_conv_minus <- integer(length(theta0)) + +names(manual_profiled_logdet_fd) <- fixed_names +names(manual_u_fd_norm) <- fixed_names +names(manual_u_opt_grad_norm_plus) <- fixed_names +names(manual_u_opt_grad_norm_minus) <- fixed_names +names(manual_u_opt_conv_plus) <- fixed_names +names(manual_u_opt_conv_minus) <- fixed_names + +for (j in seq_along(theta0)) { + th_plus <- theta0 + th_minus <- theta0 + th_plus[j] <- th_plus[j] + eps + th_minus[j] <- th_minus[j] - eps + + plus <- profile_u_for_theta(th_plus, u0) + minus <- profile_u_for_theta(th_minus, u0) + + ld_plus <- logdet_at_theta_u(th_plus, plus$u) + ld_minus <- logdet_at_theta_u(th_minus, minus$u) + + manual_profiled_logdet_fd[j] <- 0.5 * (ld_plus - ld_minus) / (2 * eps) + manual_u_fd <- (plus$u - minus$u) / (2 * eps) + + manual_u_fd_norm[j] <- sqrt(sum(manual_u_fd * manual_u_fd)) + manual_u_opt_grad_norm_plus[j] <- plus$grad_norm + manual_u_opt_grad_norm_minus[j] <- minus$grad_norm + manual_u_opt_conv_plus[j] <- plus$convergence + manual_u_opt_conv_minus[j] <- minus$convergence +} + +cat("0.5 * manually profiled logdet FD gradient:\n") +print(manual_profiled_logdet_fd) + +cat("manual profiled u FD column norms:\n") +print(manual_u_fd_norm) + +cat("random optimizer convergence plus/minus:\n") +print(manual_u_opt_conv_plus) +print(manual_u_opt_conv_minus) + +cat("random optimizer gradient norms plus:\n") +print(manual_u_opt_grad_norm_plus) + +cat("random optimizer gradient norms minus:\n") +print(manual_u_opt_grad_norm_minus) + +cat("TMB implied logdet contribution from obj$gr - joint_gr:\n") +print(implied_logdet_gr) + +cat("difference: manual profiled FD - implied TMB logdet contribution:\n") +print(manual_profiled_logdet_fd - implied_logdet_gr) diff --git a/examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp b/examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp new file mode 100644 index 0000000..62e96ca --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp @@ -0,0 +1,164 @@ +#include + +template Type square(Type x) { return x * x; } + +template +Type logistic_selectivity(Type age, Type a50, Type slope) { + return Type(1.0) / (Type(1.0) + exp(-slope * (age - a50))); +} + +template Type objective_function::operator()() { + DATA_VECTOR(catch_obs); + DATA_VECTOR(index_obs); + DATA_MATRIX(age_comp_obs); + + PARAMETER(log_r0); + PARAMETER(log_fbar); + PARAMETER(log_q); + PARAMETER(logit_sel_a50); + PARAMETER(log_sel_slope); + PARAMETER_VECTOR(log_rec_dev); + + const int n_years = catch_obs.size(); + const int n_ages = age_comp_obs.cols(); + + Type r0 = exp(log_r0); + Type m = Type(0.18); + Type fbar = exp(log_fbar); + Type q = exp(log_q); + Type sel_a50 = Type(1.0) + Type(9.0) * invlogit(logit_sel_a50); + Type sel_slope = exp(log_sel_slope); + + Type sigma_log_index = Type(0.20); + Type sigma_log_catch = Type(0.15); + Type sigma_rec_dev = Type(0.35); + Type age_comp_effective_n = Type(2.0); + Type min_positive = Type(1.0e-12); + + vector weight(n_ages); + Type weight_values[10] = {Type(0.40), Type(0.85), Type(1.35), Type(1.95), + Type(2.60), Type(3.25), Type(3.85), Type(4.35), + Type(4.75), Type(5.05)}; + for (int a = 0; a < n_ages; ++a) { + weight(a) = weight_values[a]; + } + + vector sel(n_ages); + for (int a = 0; a < n_ages; ++a) { + sel(a) = logistic_selectivity(Type(a + 1), sel_a50, sel_slope); + } + + vector n(n_ages); + n(0) = r0; + for (int a = 1; a < n_ages; ++a) { + n(a) = n(a - 1) * exp(-m); + } + n(n_ages - 1) = n(n_ages - 1) / (Type(1.0) - exp(-m)); + + Type nll = Type(0.0); + Type fixed_prior_nll = Type(0.0); + Type rec_prior_nll = Type(0.0); + Type index_nll = Type(0.0); + Type catch_nll = Type(0.0); + Type age_comp_nll = Type(0.0); + + fixed_prior_nll += + Type(0.5) * square((log_r0 - Type(std::log(1200.0))) / Type(1.0)); + fixed_prior_nll += + Type(0.5) * square((log_fbar - Type(std::log(0.025))) / Type(0.75)); + fixed_prior_nll += + Type(0.5) * square((log_q - Type(std::log(0.00005))) / Type(1.0)); + fixed_prior_nll += Type(0.5) * square((sel_a50 - Type(4.0)) / Type(0.75)); + fixed_prior_nll += + Type(0.5) * square((log_sel_slope - Type(std::log(1.2))) / Type(0.35)); + + nll += fixed_prior_nll; + + for (int y = 0; y < n_years; ++y) { + Type rec_dev = log_rec_dev(y); + { + Type term = Type(0.5) * square(rec_dev / sigma_rec_dev); + rec_prior_nll += term; + nll += term; + } + + Type total_biomass = Type(0.0); + Type catch_hat = Type(0.0); + Type selected_sum = Type(0.0); + vector pred_age_comp(n_ages); + + for (int a = 0; a < n_ages; ++a) { + Type fa = fbar * sel(a); + Type za = m + fa; + total_biomass += n(a) * weight(a); + catch_hat += n(a) * weight(a) * fa / za * (Type(1.0) - exp(-za)); + pred_age_comp(a) = n(a) * sel(a); + selected_sum += pred_age_comp(a); + } + + Type index_hat = q * total_biomass; + + if (index_obs(y) > 0.0) { + Type z = (log(Type(index_obs(y))) - + log(CppAD::CondExpGt(index_hat, min_positive, index_hat, + min_positive))) / + sigma_log_index; + { + Type term = Type(0.5) * z * z; + index_nll += term; + nll += term; + } + } + + if (catch_obs(y) > 0.0) { + Type z = (log(Type(catch_obs(y))) - + log(CppAD::CondExpGt(catch_hat, min_positive, catch_hat, + min_positive))) / + sigma_log_catch; + { + Type term = Type(0.5) * z * z; + catch_nll += term; + nll += term; + } + } + + for (int a = 0; a < n_ages; ++a) { + pred_age_comp(a) = + pred_age_comp(a) / CppAD::CondExpGt(selected_sum, min_positive, + selected_sum, min_positive); + Type obs = age_comp_obs(y, a); + if (obs > 0.0) { + { + Type term = -age_comp_effective_n * obs * + log(CppAD::CondExpGt(pred_age_comp(a), min_positive, + pred_age_comp(a), min_positive)); + age_comp_nll += term; + nll += term; + } + } + } + + vector next(n_ages); + next.setZero(); + next(0) = r0 * exp(rec_dev); + + for (int a = 1; a < n_ages; ++a) { + Type f_prev = fbar * sel(a - 1); + Type z_prev = m + f_prev; + next(a) = n(a - 1) * exp(-z_prev); + } + + Type f_last = fbar * sel(n_ages - 1); + Type z_last = m + f_last; + next(n_ages - 1) += n(n_ages - 1) * exp(-z_last); + + n = next; + } + + REPORT(fixed_prior_nll); + REPORT(rec_prior_nll); + REPORT(index_nll); + REPORT(catch_nll); + REPORT(age_comp_nll); + return nll; +} diff --git a/examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R b/examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R new file mode 100755 index 0000000..ff20d22 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R @@ -0,0 +1,79 @@ +#!/usr/bin/env Rscript + +suppressPackageStartupMessages(library(TMB)) + +data_candidates <- c( + "examples/NMFS/sefsc_red_snapper/data/red_snapper_synthetic_observations.csv", + "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv", + "examples/NMFS/sefsc_red_snapper/data/red_snapper_observations.csv" +) + +data_path <- data_candidates[file.exists(data_candidates)][1] +if (is.na(data_path)) { + stop("Could not find SEFSC red snapper observation CSV") +} + +obs <- read.csv(data_path) + +catch_col <- grep("catch", names(obs), value = TRUE)[1] +index_col <- grep("index", names(obs), value = TRUE)[1] +age_cols <- grep("^age_|^age[0-9]+|comp", names(obs), value = TRUE) +age_cols <- age_cols[sapply(obs[age_cols], is.numeric)] + +if (is.na(catch_col) || is.na(index_col) || length(age_cols) == 0) { + stop("Could not infer catch/index/age-composition columns from data CSV") +} + +catch_obs <- as.numeric(obs[[catch_col]]) +index_obs <- as.numeric(obs[[index_col]]) +age_comp_obs <- as.matrix(obs[, age_cols, drop = FALSE]) +age_comp_obs <- age_comp_obs / rowSums(age_comp_obs) + +cpp <- "examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp" +TMB::compile(cpp) +dyn.load(TMB::dynlib(sub("\\.cpp$", "", cpp))) + +parameters <- list( + log_r0 = log(1200.0), + log_fbar = log(0.025), + log_q = log(0.00005), + logit_sel_a50 = 0.0, + log_sel_slope = log(1.2), + log_rec_dev = rep(0.0, length(catch_obs)) +) + +obj <- MakeADFun( + data = list(catch_obs = catch_obs, index_obs = index_obs, age_comp_obs = age_comp_obs), + parameters = parameters, + random = "log_rec_dev", + DLL = "red_snapper_tmb", + silent = TRUE +) + +fit <- nlminb(obj$par, obj$fn, obj$gr, control = list(eval.max = 1000, iter.max = 1000)) +pl <- obj$env$parList() + +summary_path <- "examples/NMFS/sefsc_red_snapper/outputs/tmb_fit_summary.csv" +out <- data.frame( + field = c("objective", "convergence", "message", "log_r0", "r0", + "log_fbar", "fbar", "log_q", "q", "logit_sel_a50", + "sel_a50", "log_sel_slope", "sel_slope", "random_effects"), + value = c(fit$objective, fit$convergence, fit$message, + pl$log_r0, exp(pl$log_r0), + pl$log_fbar, exp(pl$log_fbar), + pl$log_q, exp(pl$log_q), + pl$logit_sel_a50, + 1.0 + 9.0 / (1.0 + exp(-pl$logit_sel_a50)), + pl$log_sel_slope, exp(pl$log_sel_slope), + length(pl$log_rec_dev)) +) +write.csv(out, summary_path, row.names = FALSE, quote = FALSE) + +rec_path <- "examples/NMFS/sefsc_red_snapper/outputs/tmb_recruitment_deviations.csv" +write.csv(data.frame(year = seq_along(pl$log_rec_dev), + log_rec_dev = as.numeric(pl$log_rec_dev), + rec_multiplier = exp(as.numeric(pl$log_rec_dev))), + rec_path, row.names = FALSE, quote = FALSE) + +cat("wrote:", summary_path, "\n") +cat("wrote:", rec_path, "\n") diff --git a/examples/NMFS/sefsc_red_snapper/validation/age_composition_likelihood_checklist.md b/examples/NMFS/sefsc_red_snapper/validation/age_composition_likelihood_checklist.md new file mode 100644 index 0000000..8ce0fd0 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/validation/age_composition_likelihood_checklist.md @@ -0,0 +1,8 @@ +# Age-Composition Likelihood Checklist + +- [x] predicted selected age composition added +- [x] multinomial-style negative log likelihood added +- [x] fixed effective sample size added +- [ ] selectivity parameters estimated +- [ ] age-composition residuals written +- [ ] Dirichlet-multinomial alternative diff --git a/examples/NMFS/sefsc_red_snapper/validation/age_structured_deterministic_checklist.md b/examples/NMFS/sefsc_red_snapper/validation/age_structured_deterministic_checklist.md new file mode 100644 index 0000000..2277973 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/validation/age_structured_deterministic_checklist.md @@ -0,0 +1,14 @@ +# Deterministic Age-Structured Checklist + +- [x] age classes 1-10+ +- [x] weight-at-age vector +- [x] maturity-at-age vector +- [x] logistic selectivity +- [x] plus group +- [x] catch prediction using Baranov catch equation +- [x] biomass, SSB proxy, depletion, Fbar, index prediction +- [ ] likelihood contributions +- [ ] parameter estimation +- [ ] recruitment deviations +- [ ] Laplace/random-effect treatment +- [ ] TMB comparison diff --git a/examples/NMFS/sefsc_red_snapper/validation/level0_checklist.md b/examples/NMFS/sefsc_red_snapper/validation/level0_checklist.md new file mode 100644 index 0000000..b7063a9 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/validation/level0_checklist.md @@ -0,0 +1,9 @@ +# Level-0 Checklist + +- [ ] deterministic age-structured dynamics implemented +- [ ] synthetic catch observations read from `data/` +- [ ] synthetic index observations read from `data/` +- [ ] derived quantities written to `outputs/` +- [ ] minimal runner compiles from a clean checkout +- [ ] TMB reference implementation added +- [ ] Quadra/TMB comparison table added diff --git a/examples/NMFS/sefsc_red_snapper/validation/objective_checklist.md b/examples/NMFS/sefsc_red_snapper/validation/objective_checklist.md new file mode 100644 index 0000000..fe3d778 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/validation/objective_checklist.md @@ -0,0 +1,10 @@ +# Objective Function Checklist + +- [x] lognormal index likelihood +- [x] lognormal catch likelihood +- [x] objective breakdown output +- [x] reusable objective header +- [ ] parameter optimization +- [ ] age-composition likelihood +- [ ] recruitment-deviation prior +- [ ] TMB objective parity diff --git a/examples/NMFS/sefsc_red_snapper/validation/quadra_fit_checklist.md b/examples/NMFS/sefsc_red_snapper/validation/quadra_fit_checklist.md new file mode 100644 index 0000000..b670798 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/validation/quadra_fit_checklist.md @@ -0,0 +1,10 @@ +# Quadra Fit Checklist + +- [x] fixed-effect objective adapter added +- [x] Quadra optimizer path used +- [ ] fixed-effect fit compiles against current local API +- [ ] fit summary output validated +- [ ] derived trajectory generated at fitted parameters +- [ ] age-composition likelihood added +- [ ] recruitment deviations added as random effects +- [ ] Laplace uncertainty added diff --git a/examples/NMFS/sefsc_red_snapper/validation/selectivity_estimation_checklist.md b/examples/NMFS/sefsc_red_snapper/validation/selectivity_estimation_checklist.md new file mode 100644 index 0000000..3cd7cb4 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/validation/selectivity_estimation_checklist.md @@ -0,0 +1,11 @@ +# Selectivity Estimation Checklist + +- [x] estimated selectivity a50 fixed effect added +- [x] estimated selectivity slope fixed effect added +- [x] bounded a50 transform added +- [x] positive slope transform added +- [x] weak selectivity regularization added +- [x] fitted selectivity parameters written to summary +- [ ] age-composition residuals by age/year +- [ ] selectivity-at-age output +- [ ] Dirichlet-multinomial option diff --git a/examples/NMFS/sefsc_red_snapper/validation/validation_plan.md b/examples/NMFS/sefsc_red_snapper/validation/validation_plan.md new file mode 100644 index 0000000..b8cbac3 --- /dev/null +++ b/examples/NMFS/sefsc_red_snapper/validation/validation_plan.md @@ -0,0 +1,35 @@ +# SEFSC Red-Snapper-Style Validation Plan + +## Level 0: deterministic fit + +- Build a minimal deterministic age-structured model. +- Fit fixed effects only. +- Confirm objective value and parameter estimates are stable. + +## Level 1: random effects and uncertainty + +- Add recruitment deviations as random effects. +- Extract conditional random-effect uncertainty. +- Add fixed-effect covariance and confidence intervals. +- Add derived quantity uncertainty. + +## Level 2: TMB comparison + +- Implement matching TMB reference model. +- Compare: + - objective value + - fixed-effect estimates + - random-effect modes + - standard errors + - derived quantities + - projection summaries + +## Level 3: projections + +- Add projection scenarios. +- Report projection envelopes. +- Compare Quadra and TMB projection outputs where feasible. + +## Notes + +This example should remain synthetic or public-data-safe. It should not be presented as an official red snapper assessment. diff --git a/examples/opakapaka_projection/had_implementation.cpp b/examples/opakapaka_projection/had_implementation.cpp deleted file mode 100644 index 6a65269..0000000 --- a/examples/opakapaka_projection/had_implementation.cpp +++ /dev/null @@ -1,12 +0,0 @@ -// Standalone example definition for Quadra's header-only HAD graph pointer. -// -// core/had_quadra.hpp declares: -// extern threadDefine ADGraph *g_ADGraph; -// -// This file provides the one translation-unit definition needed when compiling -// this example directly with c++ rather than through a larger build target. -#include "../../core/had_quadra.hpp" - -namespace had { -threadDefine ADGraph *g_ADGraph = 0; -} // namespace had diff --git a/finalize_opakapaka_nmfs_cleanup.sh b/finalize_opakapaka_nmfs_cleanup.sh new file mode 100755 index 0000000..c7f0d4c --- /dev/null +++ b/finalize_opakapaka_nmfs_cleanup.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +DOC="docs/opakapaka_nmfs_reorg_and_huu_diagnostics.md" + +mkdir -p docs + +if [[ -f patch_opakapaka_reuse_final_huu_diagnostics.sh ]]; then + rm patch_opakapaka_reuse_final_huu_diagnostics.sh + echo "Removed temporary patch script: patch_opakapaka_reuse_final_huu_diagnostics.sh" +fi + +cat > "$DOC" <<'EOF' +# Opakapaka NMFS Reorganization and Huu Diagnostic Cleanup + +## Status + +Completed: June 2026 + +The PIFSC Opakapaka assessment-style example was moved under the NMFS +assessment examples directory and its final random-effect Hessian diagnostics +were corrected. + +## Directory Reorganization + +The Opakapaka example was moved from: + +```text +examples/pifsc_opakapaka +``` + +to: + +```text +examples/NMFS/pifsc_opakapaka +``` + +This keeps fisheries assessment applications separate from smaller framework +examples. + +The NMFS examples directory now contains assessment-oriented examples such as: + +```text +examples/NMFS/sefsc_red_snapper +examples/NMFS/pifsc_opakapaka +``` + +## Build Path Updates + +After the move, relative include paths were updated because the example is now +one directory deeper. + +For example, includes of the form: + +```cpp +#include "../../../core/..." +``` + +were updated to: + +```cpp +#include "../../../../core/..." +``` + +The Opakapaka executable is built from: + +```bash +clang++ -std=c++17 -g -I"external/eigen/" \ + examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection.cpp \ + examples/NMFS/pifsc_opakapaka/quadra/opakapaka_adgraph_global.cpp \ + -o build/examples/pifsc_opakapaka +``` + +## Diagnostic Issue + +After the move, the example built and ran, but the optimizer structure report +showed stale metadata: + +```text +random effects 0 +pattern available no +detected structure unknown +Hessian nonzeros 0 +``` + +This was inconsistent with the actual Laplace evaluation, which reported: + +```text +Quadra: Discovering Hessian pattern from AD graph for 20 random variables ... +Quadra: Model structure aware now => Hessian pattern has 58 entries. +``` + +## Root Cause + +The Opakapaka example can fall back to a local safeguarded one-dimensional +`log_q` polish after an L-BFGS line-search stall. That fallback returned a valid +fit and valid random effects, but it did not preserve the optimizer pattern +metadata in `fit.pattern`. + +As a result, the final report was reading stale metadata even though the fitted +random-effect vector was present. + +## Fix + +The example now reconstructs the final random-effect Hessian after fitting: + +```cpp +const Eigen::SparseMatrix Huu_final = + compute_final_random_effect_hessian(model, params, opts, fit); +``` + +That final Hessian is reused for: + +- optimizer structure diagnostics +- Hessian nonzero reporting +- random-effect uncertainty output + +This avoids relying on stale `fit.pattern` metadata when the fallback path was +used. + +## Validation + +After the fix, the Opakapaka example reported: + +```text +random effects 20 +pattern available yes +detected structure sparse +Laplace backend final Huu reconstruction +random solver Laplace mode solve +Hessian nonzeros 58 +``` + +The example also completed the fit and projection workflow and wrote outputs to: + +```text +examples/NMFS/pifsc_opakapaka/outputs +``` + +## Remaining Note + +The example still uses a local safeguarded `log_q` fallback after an L-BFGS +line-search stall: + +```text +L-BFGS line-search stall detected in Opakapaka example. +Using local safeguarded one-dimensional log_q fallback. +``` + +This is an optimizer robustness issue, not a structural diagnostics or +uncertainty-reporting issue. The final polished fit reports a near-zero gradient +and coherent output. + +Future work can replace the local fallback with a more general optimizer +robustness improvement. +EOF + +echo "Wrote:" +echo " $DOC" + +echo +echo "Suggested verification:" +echo 'grep -R "examples/pifsc_opakapaka" -n . \' +echo ' --exclude-dir=.git \' +echo ' --exclude-dir=.quadra_patch_backups \' +echo ' --exclude-dir=build \' +echo ' --exclude="*.bak" \' +echo ' --exclude="*.txt"' + +echo +echo "Suggested git review:" +echo " git status --short" +echo " git diff -- docs examples/NMFS core | less" diff --git a/force_remove_bad_laplace_tail_block.sh b/force_remove_bad_laplace_tail_block.sh new file mode 100755 index 0000000..eaff5e9 --- /dev/null +++ b/force_remove_bad_laplace_tail_block.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +file="core/laplace.hpp" +backup="${file}.backup_force_remove_bad_laplace_tail_block_$(date +%Y%m%d_%H%M%S)" + +if [[ ! -f "$file" ]]; then + echo "ERROR: $file not found. Run from Quadra repo root." >&2 + exit 1 +fi + +cp "$file" "$backup" + +python3 - <<'PY' +from pathlib import Path +p = Path('core/laplace.hpp') +s = p.read_text() + +# Remove the accidentally inserted tail diagnostic block. The compile error +# shows it contains timing_hdot_end/timing_hdot_start and ends with return grad; +# in a scope where grad does not exist. +idx = s.find('timing_hdot_end - timing_hdot_start') +if idx == -1: + print('No timing_hdot_end block found; file may already be clean.') + raise SystemExit(0) + +# Walk backward to the start of the diagnostic/logging statement/block. +# Prefer a nearby preprocessor guard if present, otherwise the nearest cerr line. +window_start = max(0, idx - 2500) +prefix = s[window_start:idx] +starts = [] +for marker in ['#ifdef QUADRA_GRADIENT_DIAGNOSTIC', '#if defined(QUADRA_GRADIENT_DIAGNOSTIC)', 'std::cerr']: + j = prefix.rfind(marker) + if j != -1: + starts.append(window_start + j) +if not starts: + raise RuntimeError('Found timing_hdot_end but could not identify block start.') +start = min(starts) if any(s[t:t+1] == '#' for t in starts) else max(starts) + +# Walk forward through the bad return grad; line. This removes the whole bad tail. +ret = s.find('return grad;', idx) +if ret == -1: + raise RuntimeError('Found timing_hdot_end but not following return grad;') +line_end = s.find('\n', ret) +if line_end == -1: + line_end = len(s) +else: + line_end += 1 + +removed = s[start:line_end] +new = s[:start] + s[line_end:] +p.write_text(new) + +print('Removed bad block from core/laplace.hpp') +print('Removed lines containing:') +for line in removed.splitlines(): + if 'timing_hdot' in line or 'return grad' in line or 'QUADRA_GRADIENT_DIAGNOSTIC' in line: + print(' ' + line.strip()) +PY + +echo "Backup saved to: $backup" +echo "Now run:" +echo " grep -n \"timing_hdot_end\\|return grad;\" core/laplace.hpp" +echo "Then rebuild." diff --git a/git_restore_laplace_hpp_clean.sh b/git_restore_laplace_hpp_clean.sh new file mode 100755 index 0000000..243fa0d --- /dev/null +++ b/git_restore_laplace_hpp_clean.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="core/laplace.hpp" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run from the Quadra repo root." + exit 1 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "ERROR: This does not appear to be a Git repo." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +SAVE="${FILE}.saved_before_git_restore.${STAMP}" +cp "$FILE" "$SAVE" + +echo "Saved current file to:" +echo " $SAVE" +echo + +echo "Restoring $FILE from Git HEAD..." +git checkout HEAD -- "$FILE" + +echo +echo "Checking for bad diagnostic leftovers..." +if grep -n 'timing_hdot_end\|timing_hdot_start\|return grad;' "$FILE"; then + echo + echo "ERROR: Bad identifiers still exist after Git restore." + echo "This means HEAD itself contains the bad patch." + echo "Run:" + echo " git log --oneline -- core/laplace.hpp | head" + exit 2 +fi + +echo "No bad diagnostic leftovers found." +echo +echo "Checking header guard closure:" +grep -n '^#ifndef QUADRA_LAPLACE_HPP\|^#define QUADRA_LAPLACE_HPP\|^#endif' "$FILE" | tail -10 || true + +echo +echo "Done. Rebuild without diagnostics now." diff --git a/install_du_dtheta_norm_diagnostic.sh b/install_du_dtheta_norm_diagnostic.sh new file mode 100755 index 0000000..5b75fe1 --- /dev/null +++ b/install_du_dtheta_norm_diagnostic.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="core/laplace.hpp" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run from Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="${FILE}.before_du_dtheta_norm_diagnostic.${STAMP}" +cp "$FILE" "$BACKUP" +echo "Backed up $FILE to:" +echo " $BACKUP" + +python3 - <<'PY' +from pathlib import Path + +path = Path("core/laplace.hpp") +text = path.read_text() + +if "QUADRA_DEBUG_DU_DTHETA_NORMS" in text: + print("dU norm diagnostic already installed.") + raise SystemExit(0) + +needle = """ Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + + const auto timing_du_end = std::chrono::steady_clock::now(); +""" + +replacement = """ Eigen::MatrixXd dU = + implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver); + +#ifdef QUADRA_DEBUG_DU_DTHETA_NORMS + { + std::cout << "Quadra dU diagnostic\\n"; + std::cout << " dU_col_norms = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).norm(); + if (j + 1 < dU.cols()) { + std::cout << " "; + } + } + std::cout << "\\n"; + + std::cout << " dU_col_maxabs = "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU.col(j).cwiseAbs().maxCoeff(); + if (j + 1 < dU.cols()) { + std::cout << " "; + } + } + std::cout << "\\n"; + + std::cout << " dU_first_rows ="; + const Eigen::Index nprint = std::min(5, dU.rows()); + for (Eigen::Index r = 0; r < nprint; ++r) { + std::cout << "\\n row " << r << ": "; + for (Eigen::Index j = 0; j < dU.cols(); ++j) { + std::cout << dU(r, j); + if (j + 1 < dU.cols()) { + std::cout << " "; + } + } + } + std::cout << "\\n"; + } +#endif + + const auto timing_du_end = std::chrono::steady_clock::now(); +""" + +if needle not in text: + raise RuntimeError("Could not find dU assignment block in core/laplace.hpp") + +text = text.replace(needle, replacement, 1) +path.write_text(text) +print("Installed QUADRA_DEBUG_DU_DTHETA_NORMS diagnostic.") +PY + +echo +echo "Build with:" +echo 'clang++ -std=c++17 -g -I"external/eigen/" -DQUADRA_DEBUG_DU_DTHETA_NORMS examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp' diff --git a/install_hdot_exact_vs_fd_trace_diagnostic.sh b/install_hdot_exact_vs_fd_trace_diagnostic.sh new file mode 100755 index 0000000..64c27ab --- /dev/null +++ b/install_hdot_exact_vs_fd_trace_diagnostic.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="core/laplace.hpp" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run from Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="${FILE}.before_hdot_exact_vs_fd_trace_diagnostic.${STAMP}" +cp "$FILE" "$BACKUP" +echo "Backed up $FILE to:" +echo " $BACKUP" + +python3 - <<'PY' +from pathlib import Path + +path = Path("core/laplace.hpp") +text = path.read_text() + +if "QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE" in text: + print("Diagnostic already installed.") + raise SystemExit(0) + +needle = """#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + { + const Eigen::MatrixXd zero_dU = + Eigen::MatrixXd::Zero(u_hat.size(), theta.size()); + + const auto Hdots_theta_only = random_hessian_directional_exact_all( + model, params, theta, u_hat, zero_dU, get_pattern_for_logdet); + + Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index i = 0; i < theta.size(); ++i) { + theta_only[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdots_theta_only[static_cast(i)], + options); + } + + std::cout << "Quadra logdet Hdot diagnostic\\n"; + std::cout << " theta_only_logdet_grad = " + << theta_only.transpose() << "\\n"; + std::cout << " total_logdet_grad = " + << grad.transpose() << "\\n"; + std::cout << " implicit_u_contribution= " + << (grad - theta_only).transpose() << "\\n"; + } +#endif +""" + +insert = needle + """ + +#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE + { + Eigen::VectorXd fd_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd exact_trace = Eigen::VectorXd::Zero(theta.size()); + Eigen::VectorXd rel_hdot_err = Eigen::VectorXd::Zero(theta.size()); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix Hdot_fd = + random_hessian_directional_implicit_fd_with_du( + model, params, theta, u_hat, i, dU.col(i), 1.0e-5); + + const Eigen::SparseMatrix &Hdot_exact = + Hdots[static_cast(i)]; + + fd_trace[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_fd, options); + exact_trace[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdot_exact, options); + + const Eigen::SparseMatrix diff = Hdot_exact - Hdot_fd; + rel_hdot_err[i] = + diff.norm() / std::max(1.0e-12, Hdot_fd.norm()); + } + + std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\\n"; + std::cout << " exact_total_logdet_grad = " + << exact_trace.transpose() << "\\n"; + std::cout << " fd_total_logdet_grad = " + << fd_trace.transpose() << "\\n"; + std::cout << " exact_minus_fd = " + << (exact_trace - fd_trace).transpose() << "\\n"; + std::cout << " rel_Hdot_matrix_err = " + << rel_hdot_err.transpose() << "\\n"; + } +#endif +""" + +if needle not in text: + raise RuntimeError("Could not find theta-only diagnostic block. Install that first or restore target structure.") + +text = text.replace(needle, insert, 1) +path.write_text(text) +print("Installed QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE diagnostic.") +PY + +echo +echo "Build with:" +echo 'clang++ -std=c++17 -g -I"external/eigen/" -DQUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp' diff --git a/install_logdet_theta_only_vs_total_diagnostic.sh b/install_logdet_theta_only_vs_total_diagnostic.sh new file mode 100755 index 0000000..8a65984 --- /dev/null +++ b/install_logdet_theta_only_vs_total_diagnostic.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="core/laplace.hpp" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run from Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="${FILE}.before_logdet_theta_only_diagnostic.${STAMP}" +cp "$FILE" "$BACKUP" +echo "Backed up $FILE to:" +echo " $BACKUP" + +python3 - <<'PY' +from pathlib import Path + +path = Path("core/laplace.hpp") +text = path.read_text() + +if "QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL" in text: + print("Diagnostic already installed.") + raise SystemExit(0) + +needle = """ const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } +""" + +replacement = """ const auto Hdots = random_hessian_directional_exact_all( + model, params, theta, u_hat, dU, get_pattern_for_logdet); + + for (Eigen::Index i = 0; i < theta.size(); ++i) { + const Eigen::SparseMatrix &Hdot = + Hdots[static_cast(i)]; + + grad[i] = + 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options); + } + +#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL + { + const Eigen::MatrixXd zero_dU = + Eigen::MatrixXd::Zero(u_hat.size(), theta.size()); + + const auto Hdots_theta_only = random_hessian_directional_exact_all( + model, params, theta, u_hat, zero_dU, get_pattern_for_logdet); + + Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size()); + for (Eigen::Index i = 0; i < theta.size(); ++i) { + theta_only[i] = + 0.5 * logdet_directional_derivative_from_hdot( + solver, Hdots_theta_only[static_cast(i)], + options); + } + + std::cout << "Quadra logdet Hdot diagnostic\\n"; + std::cout << " theta_only_logdet_grad = " + << theta_only.transpose() << "\\n"; + std::cout << " total_logdet_grad = " + << grad.transpose() << "\\n"; + std::cout << " implicit_u_contribution= " + << (grad - theta_only).transpose() << "\\n"; + } +#endif +""" + +if needle not in text: + raise RuntimeError("Could not find target Hdot loop in core/laplace.hpp") + +text = text.replace(needle, replacement, 1) +path.write_text(text) +print("Installed QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL diagnostic.") +PY + +echo +echo "Build with:" +echo 'clang++ -std=c++17 -g -I"external/eigen/" -DQUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp' diff --git a/install_quadra_gradient_diagnostics.sh b/install_quadra_gradient_diagnostics.sh new file mode 100755 index 0000000..15c8997 --- /dev/null +++ b/install_quadra_gradient_diagnostics.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Instrument Quadra's Laplace/exact-gradient path without changing behavior. +# Run from the Quadra repository root. + +TARGET="core/laplace.hpp" +if [[ ! -f "$TARGET" ]]; then + echo "ERROR: $TARGET not found. Run this from the Quadra repo root." >&2 + exit 1 +fi + +BACKUP="$TARGET.bak.gradient_diagnostics.$(date +%Y%m%d_%H%M%S)" +cp "$TARGET" "$BACKUP" +echo "Backed up $TARGET -> $BACKUP" + +# Add a tiny diagnostic helper include if needed. +if ! grep -q '#include ' "$TARGET"; then + awk 'NR==1{print; print "#include "; next} {print}' "$TARGET" > "$TARGET.tmp" + mv "$TARGET.tmp" "$TARGET" +fi + +if grep -q 'QUADRA_GRADIENT_DIAGNOSTIC' "$TARGET"; then + echo "Diagnostic hooks already appear to be installed; leaving file unchanged." + exit 0 +fi + +cat > /tmp/quadra_diag_block.txt <<'DIAG' + +#if defined(QUADRA_GRADIENT_DIAGNOSTIC) +#define QUADRA_DIAG_VEC(name, v) \ + do { \ + std::cerr << "[quadra gradient diagnostic] " << name \ + << " size=" << (v).size() \ + << " norm=" << (v).norm() \ + << " values=" << (v).transpose() << "\n"; \ + } while (false) +#define QUADRA_DIAG_MAT(name, m) \ + do { \ + std::cerr << "[quadra gradient diagnostic] " << name \ + << " rows=" << (m).rows() \ + << " cols=" << (m).cols() \ + << " norm=" << (m).norm() << "\n"; \ + } while (false) +#define QUADRA_DIAG_SCALAR(name, x) \ + do { \ + std::cerr << "[quadra gradient diagnostic] " << name << " = " << (x) << "\n"; \ + } while (false) +#else +#define QUADRA_DIAG_VEC(name, v) do {} while (false) +#define QUADRA_DIAG_MAT(name, m) do {} while (false) +#define QUADRA_DIAG_SCALAR(name, x) do {} while (false) +#endif +DIAG + +# Insert macro block after includes / before first namespace-ish content. +awk ' + BEGIN { inserted=0 } + /^#include / { print; next } + inserted==0 { system("cat /tmp/quadra_diag_block.txt"); inserted=1 } + { print } +' "$TARGET" > "$TARGET.tmp" +mv "$TARGET.tmp" "$TARGET" + +echo "Installed diagnostic macros in $TARGET." +echo +cat <<'NEXT' +Next manual step: + Add these calls inside the exact/profile Laplace gradient function, immediately after each value is computed: + + QUADRA_DIAG_VEC("grad_u", grad_u); + QUADRA_DIAG_VEC("grad_theta", grad_theta); + QUADRA_DIAG_MAT("H_u_theta", H_u_theta); + QUADRA_DIAG_MAT("du_dtheta", du_dtheta); + QUADRA_DIAG_VEC("implicit correction", grad_u.transpose() * du_dtheta); + QUADRA_DIAG_VEC("logdet_grad", logdet_grad); + QUADRA_DIAG_VEC("final analytic grad", grad); + +Compile with: + -DQUADRA_GRADIENT_DIAGNOSTIC + +Then paste the diagnostic block output. +NEXT diff --git a/install_tmb_manual_profiled_logdet_fd_diagnostic.sh b/install_tmb_manual_profiled_logdet_fd_diagnostic.sh new file mode 100755 index 0000000..7c2441e --- /dev/null +++ b/install_tmb_manual_profiled_logdet_fd_diagnostic.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="examples/NMFS/sefsc_red_snapper/tmb/evaluate_tmb_at_quadra_fit.R" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run from Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="${FILE}.before_manual_random_profiled_logdet_fd.${STAMP}" +cp "$FILE" "$BACKUP" +echo "Backed up $FILE to:" +echo " $BACKUP" + +python3 - <<'PY' +from pathlib import Path + +path = Path("examples/NMFS/sefsc_red_snapper/tmb/evaluate_tmb_at_quadra_fit.R") +text = path.read_text() + +if "TMB manual-random-optimized profiled logdet FD" in text: + print("Manual profiled logdet FD diagnostic already installed.") + raise SystemExit(0) + +append = r''' + +cat("\nTMB manual-random-optimized profiled logdet FD:\n") + +fixed_names <- c("log_r0", "log_fbar", "log_q", "logit_sel_a50", "log_sel_slope") +theta0 <- as.numeric(qval[fixed_names]) +names(theta0) <- fixed_names +u0 <- qrec$log_rec_dev + +make_joint_obj <- function(theta_vec, u_vec) { + pars <- list( + log_r0 = as.numeric(theta_vec["log_r0"]), + log_fbar = as.numeric(theta_vec["log_fbar"]), + log_q = as.numeric(theta_vec["log_q"]), + logit_sel_a50 = as.numeric(theta_vec["logit_sel_a50"]), + log_sel_slope = as.numeric(theta_vec["log_sel_slope"]), + log_rec_dev = u_vec + ) + + MakeADFun( + data = list( + catch_obs = catch_obs, + index_obs = index_obs, + age_comp_obs = age_comp_obs + ), + parameters = pars, + DLL = "red_snapper_tmb", + silent = TRUE + ) +} + +profile_u_for_theta <- function(theta_vec, u_start) { + joint <- make_joint_obj(theta_vec, u_start) + + full_start <- c(theta_vec, u_start) + ntheta <- length(theta_vec) + nu <- length(u_start) + + fn_u <- function(u_vec) { + par <- c(theta_vec, u_vec) + joint$fn(par) + } + + gr_u <- function(u_vec) { + par <- c(theta_vec, u_vec) + as.numeric(joint$gr(par)[(ntheta + 1):(ntheta + nu)]) + } + + opt <- nlminb( + start = u_start, + objective = fn_u, + gradient = gr_u, + control = list( + eval.max = 1000, + iter.max = 1000, + rel.tol = 1e-12, + x.tol = 1e-12 + ) + ) + + list( + u = opt$par, + objective = opt$objective, + convergence = opt$convergence, + message = opt$message, + grad_norm = sqrt(sum(gr_u(opt$par)^2)), + joint = joint + ) +} + +logdet_at_theta_u <- function(theta_vec, u_vec) { + # Use random-enabled TMB object only as a convenient Huu provider at fixed theta/u. + o <- make_obj_for_theta(theta_vec, u_vec) + invisible(o$fn()) + H <- as.matrix(o$env$spHess(random = TRUE)) + as.numeric(determinant(H, logarithm = TRUE)$modulus) +} + +eps <- 1e-5 +manual_profiled_logdet_fd <- numeric(length(theta0)) +manual_u_fd_norm <- numeric(length(theta0)) +manual_u_opt_grad_norm_plus <- numeric(length(theta0)) +manual_u_opt_grad_norm_minus <- numeric(length(theta0)) +manual_u_opt_conv_plus <- integer(length(theta0)) +manual_u_opt_conv_minus <- integer(length(theta0)) + +names(manual_profiled_logdet_fd) <- fixed_names +names(manual_u_fd_norm) <- fixed_names +names(manual_u_opt_grad_norm_plus) <- fixed_names +names(manual_u_opt_grad_norm_minus) <- fixed_names +names(manual_u_opt_conv_plus) <- fixed_names +names(manual_u_opt_conv_minus) <- fixed_names + +for (j in seq_along(theta0)) { + th_plus <- theta0 + th_minus <- theta0 + th_plus[j] <- th_plus[j] + eps + th_minus[j] <- th_minus[j] - eps + + plus <- profile_u_for_theta(th_plus, u0) + minus <- profile_u_for_theta(th_minus, u0) + + ld_plus <- logdet_at_theta_u(th_plus, plus$u) + ld_minus <- logdet_at_theta_u(th_minus, minus$u) + + manual_profiled_logdet_fd[j] <- 0.5 * (ld_plus - ld_minus) / (2 * eps) + manual_u_fd <- (plus$u - minus$u) / (2 * eps) + + manual_u_fd_norm[j] <- sqrt(sum(manual_u_fd * manual_u_fd)) + manual_u_opt_grad_norm_plus[j] <- plus$grad_norm + manual_u_opt_grad_norm_minus[j] <- minus$grad_norm + manual_u_opt_conv_plus[j] <- plus$convergence + manual_u_opt_conv_minus[j] <- minus$convergence +} + +cat("0.5 * manually profiled logdet FD gradient:\n") +print(manual_profiled_logdet_fd) + +cat("manual profiled u FD column norms:\n") +print(manual_u_fd_norm) + +cat("random optimizer convergence plus/minus:\n") +print(manual_u_opt_conv_plus) +print(manual_u_opt_conv_minus) + +cat("random optimizer gradient norms plus:\n") +print(manual_u_opt_grad_norm_plus) + +cat("random optimizer gradient norms minus:\n") +print(manual_u_opt_grad_norm_minus) + +cat("TMB implied logdet contribution from obj$gr - joint_gr:\n") +print(implied_logdet_gr) + +cat("difference: manual profiled FD - implied TMB logdet contribution:\n") +print(manual_profiled_logdet_fd - implied_logdet_gr) +''' + +text = text + append +path.write_text(text) +print("Installed manual-random-optimized profiled logdet FD diagnostic.") +PY + +echo +echo "Run:" +echo "Rscript $FILE" diff --git a/install_tmb_profiled_logdet_fd_diagnostic.sh b/install_tmb_profiled_logdet_fd_diagnostic.sh new file mode 100755 index 0000000..7f4d228 --- /dev/null +++ b/install_tmb_profiled_logdet_fd_diagnostic.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="examples/NMFS/sefsc_red_snapper/tmb/evaluate_tmb_at_quadra_fit.R" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run from Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP="${FILE}.before_profiled_logdet_fd.${STAMP}" +cp "$FILE" "$BACKUP" +echo "Backed up $FILE to:" +echo " $BACKUP" + +python3 - <<'PY' +from pathlib import Path + +path = Path("examples/NMFS/sefsc_red_snapper/tmb/evaluate_tmb_at_quadra_fit.R") +text = path.read_text() + +if "TMB profiled logdet FD at Quadra fit" in text: + print("Profiled logdet FD diagnostic already installed.") + raise SystemExit(0) + +append = r''' + +cat("\nTMB profiled logdet FD at Quadra fit:\n") + +fixed_names <- c("log_r0", "log_fbar", "log_q", "logit_sel_a50", "log_sel_slope") +theta0 <- as.numeric(qval[fixed_names]) +names(theta0) <- fixed_names +u0 <- qrec$log_rec_dev + +make_obj_for_theta <- function(theta_vec, u_start = u0) { + pars <- list( + log_r0 = as.numeric(theta_vec["log_r0"]), + log_fbar = as.numeric(theta_vec["log_fbar"]), + log_q = as.numeric(theta_vec["log_q"]), + logit_sel_a50 = as.numeric(theta_vec["logit_sel_a50"]), + log_sel_slope = as.numeric(theta_vec["log_sel_slope"]), + log_rec_dev = u_start + ) + + MakeADFun( + data = list( + catch_obs = catch_obs, + index_obs = index_obs, + age_comp_obs = age_comp_obs + ), + parameters = pars, + random = "log_rec_dev", + DLL = "red_snapper_tmb", + silent = TRUE + ) +} + +get_profiled_u_and_logdet <- function(theta_vec, u_start = u0) { + o <- make_obj_for_theta(theta_vec, u_start) + + # Force evaluation so TMB performs the inner random-effect optimization. + invisible(o$fn()) + + # In this TMB version, profiled random modes are stored in last.par[random]. + u_prof <- o$env$last.par[o$env$random] + + H <- as.matrix(o$env$spHess(random = TRUE)) + logdet <- as.numeric(determinant(H, logarithm = TRUE)$modulus) + + list(u = u_prof, logdet = logdet, obj = o) +} + +eps <- 1e-5 +profiled_logdet_fd <- numeric(length(theta0)) +profiled_u_fd_norm <- numeric(length(theta0)) +names(profiled_logdet_fd) <- fixed_names +names(profiled_u_fd_norm) <- fixed_names + +for (j in seq_along(theta0)) { + th_plus <- theta0 + th_minus <- theta0 + th_plus[j] <- th_plus[j] + eps + th_minus[j] <- th_minus[j] - eps + + plus <- get_profiled_u_and_logdet(th_plus, u0) + minus <- get_profiled_u_and_logdet(th_minus, u0) + + profiled_logdet_fd[j] <- 0.5 * (plus$logdet - minus$logdet) / (2 * eps) + + # This is du*/dtheta_j from true profiling, useful for comparison later. + u_fd <- (plus$u - minus$u) / (2 * eps) + profiled_u_fd_norm[j] <- sqrt(sum(u_fd * u_fd)) +} + +cat("0.5 * profiled logdet FD gradient:\n") +print(profiled_logdet_fd) + +cat("profiled u FD column norms:\n") +print(profiled_u_fd_norm) + +cat("TMB implied logdet contribution from obj$gr - joint_gr:\n") +print(implied_logdet_gr) + +cat("difference: profiled FD - implied TMB logdet contribution:\n") +print(profiled_logdet_fd - implied_logdet_gr) +''' + +text = text + append +path.write_text(text) +print("Installed TMB profiled logdet FD diagnostic.") +PY + +echo +echo "Run:" +echo "Rscript $FILE" diff --git a/move_assessment_examples_to_nmfs.sh b/move_assessment_examples_to_nmfs.sh new file mode 100755 index 0000000..4b8fb34 --- /dev/null +++ b/move_assessment_examples_to_nmfs.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +OLD="examples/NMFS/sefsc_red_snapper" +NEW="examples/NMFS/sefsc_red_snapper" +README="examples/NMFS/README.md" + +if [[ ! -d "$OLD" && ! -d "$NEW" ]]; then + echo "ERROR: neither $OLD nor $NEW exists. Run from repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +CHANGED_LIST="nmfs_example_move_changed_files_${STAMP}.txt" + +echo "Preparing NMFS example reorganization..." + +mkdir -p examples/NMFS + +if [[ -d "$OLD" ]]; then + if [[ -d "$NEW" ]]; then + echo "ERROR: $NEW already exists while $OLD also exists." + echo "Resolve manually to avoid overwriting." + exit 1 + fi + + echo "Moving:" + echo " $OLD" + echo "to:" + echo " $NEW" + mv "$OLD" "$NEW" +else + echo "$NEW already exists; skipping directory move." +fi + +cat > "$README" <<'EOF' +# NMFS Assessment Examples + +This directory contains fisheries stock assessment examples implemented with +Quadra. + +These examples are application-oriented and are separated from smaller framework +examples so that the repository clearly distinguishes between: + +- core Quadra demonstrations +- fisheries assessment model applications +- validation and comparison studies + +## Current examples + +### SEFSC Red Snapper + +Path: + +```text +examples/NMFS/sefsc_red_snapper +``` + +This example includes: + +- age-structured population dynamics +- recruitment deviations as random effects +- Laplace approximation +- exact gradient validation +- comparison against a TMB implementation + +The Red Snapper example is currently treated as a completed validation model for +Quadra's exact Laplace machinery. +EOF + +echo "Updating path references..." + +find . \ + -path "./.git" -prune -o \ + -type f \ + ! -name "*.o" \ + ! -name "*.so" \ + ! -name "*.dylib" \ + ! -name "*.dll" \ + ! -name "*.a" \ + ! -name "*.png" \ + ! -name "*.jpg" \ + ! -name "*.jpeg" \ + ! -name "*.pdf" \ + ! -name "*.zip" \ + ! -name "*.tar" \ + ! -name "*.gz" \ + ! -name "*.bak" \ + ! -name "*.backup" \ + ! -name "*.saved*" \ + ! -name "*.broken*" \ + -print0 | +while IFS= read -r -d '' file; do + if grep -Iq . "$file"; then + perl -0pi -e 's#examples/NMFS/sefsc_red_snapper#examples/NMFS/sefsc_red_snapper#g' "$file" + fi +done + +echo "Collecting changed files..." +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git status --short | tee "$CHANGED_LIST" +else + find examples/NMFS -maxdepth 3 -type f | sort | tee "$CHANGED_LIST" +fi + +echo +echo "Done." +echo +echo "Suggested checks:" +echo " git status --short" +echo " grep -R \"examples/NMFS/sefsc_red_snapper\" -n . --exclude-dir=.git" +echo +echo "Suggested Red Snapper build from repo root:" +echo ' clang++ -std=c++17 -g -I"external/eigen/" \' +echo ' examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp \' +echo ' examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp' +echo +echo "Changed-file list saved to:" +echo " $CHANGED_LIST" diff --git a/move_pifsc_opakapaka_to_nmfs.sh b/move_pifsc_opakapaka_to_nmfs.sh new file mode 100755 index 0000000..98e0f38 --- /dev/null +++ b/move_pifsc_opakapaka_to_nmfs.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +OLD="examples/NMFS/pifsc_opakapaka" +NEW="examples/NMFS/pifsc_opakapaka" +README="examples/NMFS/README.md" + +if [[ ! -d "$OLD" && ! -d "$NEW" ]]; then + echo "ERROR: neither $OLD nor $NEW exists. Run from repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +CHANGED_LIST="pifsc_opakapaka_nmfs_move_changed_files_${STAMP}.txt" + +mkdir -p examples/NMFS + +if [[ -d "$OLD" ]]; then + if [[ -d "$NEW" ]]; then + echo "ERROR: $NEW already exists while $OLD also exists." + echo "Resolve manually to avoid overwriting." + exit 1 + fi + + echo "Moving:" + echo " $OLD" + echo "to:" + echo " $NEW" + mv "$OLD" "$NEW" +else + echo "$NEW already exists; skipping directory move." +fi + +echo "Updating active path references..." + +# Update active repo files, but avoid: +# - .git +# - build artifacts +# - historical patch backups +# - previously generated changed-file inventories +# - compiled Opakapaka executable +find . \ + -path "./.git" -prune -o \ + -path "./.quadra_patch_backups" -prune -o \ + -type f \ + ! -name "*.o" \ + ! -name "*.so" \ + ! -name "*.dylib" \ + ! -name "*.dll" \ + ! -name "*.a" \ + ! -name "*.png" \ + ! -name "*.jpg" \ + ! -name "*.jpeg" \ + ! -name "*.pdf" \ + ! -name "*.zip" \ + ! -name "*.tar" \ + ! -name "*.gz" \ + ! -name "*.bak" \ + ! -name "*.backup" \ + ! -name "*.saved*" \ + ! -name "*.broken*" \ + ! -name "nmfs_example_move_changed_files_*.txt" \ + ! -name "pifsc_opakapaka_nmfs_move_changed_files_*.txt" \ + ! -path "./examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection" \ + -print0 | +while IFS= read -r -d '' file; do + if grep -Iq . "$file"; then + perl -0pi -e 's#examples/NMFS/pifsc_opakapaka#examples/NMFS/pifsc_opakapaka#g' "$file" + fi +done + +echo "Updating examples/NMFS/README.md..." + +if [[ ! -f "$README" ]]; then + cat > "$README" <<'EOF' +# NMFS Assessment Examples + +This directory contains fisheries stock assessment examples implemented with +Quadra. + +These examples are application-oriented and are separated from smaller framework +examples so that the repository clearly distinguishes between: + +- core Quadra demonstrations +- fisheries assessment model applications +- validation and comparison studies + +## Current examples +EOF +fi + +if ! grep -q "PIFSC Opakapaka" "$README"; then + cat >> "$README" <<'EOF' + +### PIFSC Opakapaka + +Path: + +```text +examples/NMFS/pifsc_opakapaka +``` + +This example includes: + +- Pacific Islands assessment-style projection workflow +- synthetic data input +- uncertainty reporting +- derived quantities +- projection uncertainty outputs +- comparison against a TMB implementation +EOF +fi + +echo "Collecting changed files..." +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git status --short | tee "$CHANGED_LIST" +else + find examples/NMFS -maxdepth 4 -type f | sort | tee "$CHANGED_LIST" +fi + +echo +echo "Remaining active references to old path, excluding backups and git:" +grep -R "examples/NMFS/pifsc_opakapaka" -n . \ + --exclude-dir=.git \ + --exclude-dir=.quadra_patch_backups \ + --exclude="*.bak" \ + --exclude="*.backup" \ + --exclude="nmfs_example_move_changed_files_*.txt" \ + --exclude="pifsc_opakapaka_nmfs_move_changed_files_*.txt" || true + +echo +echo "Done." +echo +echo "Suggested build/check:" +echo ' clang++ -std=c++17 -g -I"external/eigen/" \' +echo ' examples/NMFS/pifsc_opakapaka/quadra/opakapaka_projection.cpp \' +echo ' -o build/examples/NMFS/pifsc_opakapaka' +echo +echo "Changed-file list saved to:" +echo " $CHANGED_LIST" diff --git a/nmfs_example_move_changed_files_20260613_173133.txt b/nmfs_example_move_changed_files_20260613_173133.txt new file mode 100644 index 0000000..701ca28 --- /dev/null +++ b/nmfs_example_move_changed_files_20260613_173133.txt @@ -0,0 +1,86 @@ + M add_science_center_validation_roadmap_v1.sh + M core/laplace.hpp + M core/laplace/exact_gradient_workspace.hpp + M core/optimizer.hpp + D examples/pifsc_opakapaka/had_implementation.cpp + D examples/sefsc_red_snapper/README.md + D examples/sefsc_red_snapper/compare_quadra_tmb_fit.py + D examples/sefsc_red_snapper/data/README.md + D examples/sefsc_red_snapper/data/red_snapper_projection_scenarios.csv + D examples/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv + D examples/sefsc_red_snapper/quadra/README.md + D examples/sefsc_red_snapper/quadra/evaluate_red_snapper_objective + D examples/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_age_structured + D examples/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_age_structured.hpp + D examples/sefsc_red_snapper/quadra/red_snapper_level0 + D examples/sefsc_red_snapper/quadra/red_snapper_level0.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_model.hpp + D examples/sefsc_red_snapper/quadra/red_snapper_objective.hpp + D examples/sefsc_red_snapper/quadra/red_snapper_quadra_fit + D examples/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp + D examples/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh + D examples/sefsc_red_snapper/run_red_snapper_age_structured.sh + D examples/sefsc_red_snapper/run_red_snapper_level0.sh + D examples/sefsc_red_snapper/run_red_snapper_objective.sh + D examples/sefsc_red_snapper/run_red_snapper_quadra_fit.sh + D examples/sefsc_red_snapper/tmb/README.md + D examples/sefsc_red_snapper/tmb/red_snapper_tmb.cpp + D examples/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R + D examples/sefsc_red_snapper/validation/age_composition_likelihood_checklist.md + D examples/sefsc_red_snapper/validation/age_structured_deterministic_checklist.md + D examples/sefsc_red_snapper/validation/level0_checklist.md + D examples/sefsc_red_snapper/validation/objective_checklist.md + D examples/sefsc_red_snapper/validation/quadra_fit_checklist.md + D examples/sefsc_red_snapper/validation/selectivity_estimation_checklist.md + D examples/sefsc_red_snapper/validation/validation_plan.md +?? a.out +?? a.out.dSYM/ +?? add_laplace_result_component_fields.sh +?? add_sefsc_red_snapper_age_comp_likelihood_v1.sh +?? add_sefsc_red_snapper_age_structured_v1.sh +?? add_sefsc_red_snapper_fitted_trajectory_v1.sh +?? add_sefsc_red_snapper_level0_scaffold_v1.sh +?? add_sefsc_red_snapper_objective_v1.sh +?? add_sefsc_red_snapper_quadra_fit_v1.sh +?? add_sefsc_red_snapper_recruitment_devs_v1.sh +?? add_sefsc_red_snapper_residual_diagnostics_v1.sh +?? add_sefsc_red_snapper_selectivity_estimation_v1.sh +?? add_sefsc_red_snapper_selectivity_output_v1.sh +?? add_sefsc_red_snapper_tmb_comparison_v1.sh +?? bad_laplace_tail_removed.txt +?? cleanup_laplace_diagnostics_to_header.sh +?? core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035 +?? core/laplace.hpp.bad-gradient-diagnostic.20260613_110931.bak +?? core/laplace.hpp.bad_gradient_diagnostics.20260613_110717 +?? core/laplace.hpp.bak.gradient_diagnostics.20260613_110318 +?? core/laplace.hpp.before_diagnostics_header_cleanup.20260613_170558 +?? core/laplace.hpp.before_du_dtheta_norm_diagnostic.20260613_144107 +?? core/laplace.hpp.before_hdot_exact_vs_fd_trace_diagnostic.20260613_142755 +?? core/laplace.hpp.before_laplace_result_component_fields.20260613_113223 +?? core/laplace.hpp.before_logdet_theta_only_diagnostic.20260613_142400 +?? core/laplace.hpp.broken_after_bad_cleanup.20260613_111249 +?? core/laplace.hpp.broken_after_bad_cleanup.20260613_112529 +?? core/laplace.hpp.pre_bad_tail_cleanup.20260613_111124.bak +?? core/laplace.hpp.saved_before_git_restore.20260613_112952 +?? core/laplace/laplace_gradient_diagnostics.hpp +?? docs/exact_laplace_gradient_validation.md +?? examples/NMFS/ +?? examples/pifsc_opakapaka/quadra/opakapaka_projection +?? examples/pifsc_opakapaka/validation/README.md +?? force_remove_bad_laplace_tail_block.sh +?? git_restore_laplace_hpp_clean.sh +?? install_du_dtheta_norm_diagnostic.sh +?? install_hdot_exact_vs_fd_trace_diagnostic.sh +?? install_logdet_theta_only_vs_total_diagnostic.sh +?? install_quadra_gradient_diagnostics.sh +?? install_tmb_manual_profiled_logdet_fd_diagnostic.sh +?? install_tmb_profiled_logdet_fd_diagnostic.sh +?? move_assessment_examples_to_nmfs.sh +?? nmfs_example_move_changed_files_20260613_173133.txt +?? restore_laplace_hpp_safely.sh +?? restore_laplace_hpp_safely_macos.sh +?? restore_quadra_laplace_before_gradient_diagnostics.sh +?? tests/test_hdot_validation diff --git a/pifsc_opakapaka_nmfs_move_changed_files_20260613_173652.txt b/pifsc_opakapaka_nmfs_move_changed_files_20260613_173652.txt new file mode 100644 index 0000000..9db13bf --- /dev/null +++ b/pifsc_opakapaka_nmfs_move_changed_files_20260613_173652.txt @@ -0,0 +1,96 @@ + M add_science_center_validation_roadmap_v1.sh + M core/laplace.hpp + M core/laplace/exact_gradient_workspace.hpp + M core/optimizer.hpp + D examples/pifsc_opakapaka/README.md + D examples/pifsc_opakapaka/data/synthetic_opakapaka_projection_data.csv + D examples/pifsc_opakapaka/had_implementation.cpp + D examples/pifsc_opakapaka/quadra/opakapaka_adgraph_global.cpp + D examples/pifsc_opakapaka/quadra/opakapaka_model.hpp + D examples/pifsc_opakapaka/quadra/opakapaka_projection.cpp + D examples/pifsc_opakapaka/quadra/opakapaka_projection_structure_demo.cpp + D examples/pifsc_opakapaka/tmb/opakapaka_projection_tmb.cpp + D examples/pifsc_opakapaka/tmb/run_opakapaka_projection_tmb.R + D examples/pifsc_opakapaka/validation/opakapaka_projection_memory_scenarios.tsv + D examples/pifsc_opakapaka/validation/validation_plan.md + D examples/sefsc_red_snapper/README.md + D examples/sefsc_red_snapper/compare_quadra_tmb_fit.py + D examples/sefsc_red_snapper/data/README.md + D examples/sefsc_red_snapper/data/red_snapper_projection_scenarios.csv + D examples/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv + D examples/sefsc_red_snapper/quadra/README.md + D examples/sefsc_red_snapper/quadra/evaluate_red_snapper_objective + D examples/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_age_structured + D examples/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_age_structured.hpp + D examples/sefsc_red_snapper/quadra/red_snapper_level0 + D examples/sefsc_red_snapper/quadra/red_snapper_level0.cpp + D examples/sefsc_red_snapper/quadra/red_snapper_model.hpp + D examples/sefsc_red_snapper/quadra/red_snapper_objective.hpp + D examples/sefsc_red_snapper/quadra/red_snapper_quadra_fit + D examples/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp + D examples/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh + D examples/sefsc_red_snapper/run_red_snapper_age_structured.sh + D examples/sefsc_red_snapper/run_red_snapper_level0.sh + D examples/sefsc_red_snapper/run_red_snapper_objective.sh + D examples/sefsc_red_snapper/run_red_snapper_quadra_fit.sh + D examples/sefsc_red_snapper/tmb/README.md + D examples/sefsc_red_snapper/tmb/red_snapper_tmb.cpp + D examples/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R + D examples/sefsc_red_snapper/validation/age_composition_likelihood_checklist.md + D examples/sefsc_red_snapper/validation/age_structured_deterministic_checklist.md + D examples/sefsc_red_snapper/validation/level0_checklist.md + D examples/sefsc_red_snapper/validation/objective_checklist.md + D examples/sefsc_red_snapper/validation/quadra_fit_checklist.md + D examples/sefsc_red_snapper/validation/selectivity_estimation_checklist.md + D examples/sefsc_red_snapper/validation/validation_plan.md +?? a.out +?? a.out.dSYM/ +?? add_laplace_result_component_fields.sh +?? add_sefsc_red_snapper_age_comp_likelihood_v1.sh +?? add_sefsc_red_snapper_age_structured_v1.sh +?? add_sefsc_red_snapper_fitted_trajectory_v1.sh +?? add_sefsc_red_snapper_level0_scaffold_v1.sh +?? add_sefsc_red_snapper_objective_v1.sh +?? add_sefsc_red_snapper_quadra_fit_v1.sh +?? add_sefsc_red_snapper_recruitment_devs_v1.sh +?? add_sefsc_red_snapper_residual_diagnostics_v1.sh +?? add_sefsc_red_snapper_selectivity_estimation_v1.sh +?? add_sefsc_red_snapper_selectivity_output_v1.sh +?? add_sefsc_red_snapper_tmb_comparison_v1.sh +?? bad_laplace_tail_removed.txt +?? cleanup_laplace_diagnostics_to_header.sh +?? core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035 +?? core/laplace.hpp.bad-gradient-diagnostic.20260613_110931.bak +?? core/laplace.hpp.bad_gradient_diagnostics.20260613_110717 +?? core/laplace.hpp.bak.gradient_diagnostics.20260613_110318 +?? core/laplace.hpp.before_diagnostics_header_cleanup.20260613_170558 +?? core/laplace.hpp.before_du_dtheta_norm_diagnostic.20260613_144107 +?? core/laplace.hpp.before_hdot_exact_vs_fd_trace_diagnostic.20260613_142755 +?? core/laplace.hpp.before_laplace_result_component_fields.20260613_113223 +?? core/laplace.hpp.before_logdet_theta_only_diagnostic.20260613_142400 +?? core/laplace.hpp.broken_after_bad_cleanup.20260613_111249 +?? core/laplace.hpp.broken_after_bad_cleanup.20260613_112529 +?? core/laplace.hpp.pre_bad_tail_cleanup.20260613_111124.bak +?? core/laplace.hpp.saved_before_git_restore.20260613_112952 +?? core/laplace/laplace_gradient_diagnostics.hpp +?? docs/exact_laplace_gradient_validation.md +?? examples/NMFS/ +?? force_remove_bad_laplace_tail_block.sh +?? git_restore_laplace_hpp_clean.sh +?? install_du_dtheta_norm_diagnostic.sh +?? install_hdot_exact_vs_fd_trace_diagnostic.sh +?? install_logdet_theta_only_vs_total_diagnostic.sh +?? install_quadra_gradient_diagnostics.sh +?? install_tmb_manual_profiled_logdet_fd_diagnostic.sh +?? install_tmb_profiled_logdet_fd_diagnostic.sh +?? move_assessment_examples_to_nmfs.sh +?? move_pifsc_opakapaka_to_nmfs.sh +?? nmfs_example_move_changed_files_20260613_173133.txt +?? pifsc_opakapaka_nmfs_move_changed_files_20260613_173652.txt +?? restore_laplace_hpp_safely.sh +?? restore_laplace_hpp_safely_macos.sh +?? restore_quadra_laplace_before_gradient_diagnostics.sh +?? tests/test_hdot_validation diff --git a/restore_laplace_hpp_safely.sh b/restore_laplace_hpp_safely.sh new file mode 100755 index 0000000..2ee7039 --- /dev/null +++ b/restore_laplace_hpp_safely.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="core/laplace.hpp" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run this from the Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BROKEN_COPY="${FILE}.broken_after_bad_cleanup.${STAMP}" +cp "$FILE" "$BROKEN_COPY" +echo "Saved current broken file to: $BROKEN_COPY" +echo + +echo "Searching for local backups..." +mapfile -t backups < <( + find core -maxdepth 2 -type f \ + \( -name 'laplace.hpp*bak*' \ + -o -name 'laplace.hpp*backup*' \ + -o -name 'laplace.hpp*before*' \ + -o -name 'laplace.hpp*pre*' \ + -o -name 'laplace.hpp.*' \) \ + ! -name "$(basename "$BROKEN_COPY")" \ + -print 2>/dev/null | sort -r +) + +if (( ${#backups[@]} > 0 )); then + echo "Candidate backups:" + i=0 + for b in "${backups[@]}"; do + i=$((i+1)) + printf " [%d] %s\n" "$i" "$b" + done + echo + chosen="${backups[0]}" + echo "Restoring newest candidate:" + echo " $chosen" + cp "$chosen" "$FILE" +else + echo "No local backup found." + echo + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Falling back to Git restore of $FILE." + echo "Your broken version was saved above before restore." + git checkout -- "$FILE" + else + echo "ERROR: Not in a Git repo and no local backup found." + echo "Manual recovery needed." + exit 1 + fi +fi + +echo +echo "Post-restore sanity checks:" +echo " #ifndef count: $(grep -c '^#ifndef QUADRA_LAPLACE_HPP' "$FILE" || true)" +echo " #define count: $(grep -c '^#define QUADRA_LAPLACE_HPP' "$FILE" || true)" +echo " #endif count: $(grep -c '^#endif' "$FILE" || true)" +echo +echo "Remaining bad diagnostic identifiers, if any:" +grep -n 'timing_hdot_end\|timing_hdot_start\|return grad;' "$FILE" || true +echo +echo "Done. Now rebuild without diagnostics." diff --git a/restore_laplace_hpp_safely_macos.sh b/restore_laplace_hpp_safely_macos.sh new file mode 100755 index 0000000..142fa20 --- /dev/null +++ b/restore_laplace_hpp_safely_macos.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +FILE="core/laplace.hpp" + +if [[ ! -f "$FILE" ]]; then + echo "ERROR: $FILE not found. Run this from the Quadra repo root." + exit 1 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +BROKEN_COPY="${FILE}.broken_after_bad_cleanup.${STAMP}" +cp "$FILE" "$BROKEN_COPY" +echo "Saved current file to: $BROKEN_COPY" +echo + +echo "Searching for local backups..." + +BACKUPS_FILE="$(mktemp)" +find core -maxdepth 2 -type f \ + \( -name 'laplace.hpp*bak*' \ + -o -name 'laplace.hpp*backup*' \ + -o -name 'laplace.hpp*before*' \ + -o -name 'laplace.hpp*pre*' \ + -o -name 'laplace.hpp.*' \) \ + ! -name "$(basename "$BROKEN_COPY")" \ + -print 2>/dev/null | sort -r > "$BACKUPS_FILE" + +if [[ -s "$BACKUPS_FILE" ]]; then + echo "Candidate backups:" + nl -ba "$BACKUPS_FILE" + echo + CHOSEN="$(head -n 1 "$BACKUPS_FILE")" + echo "Restoring newest candidate:" + echo " $CHOSEN" + cp "$CHOSEN" "$FILE" +else + echo "No local backup found." + echo + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Falling back to Git restore of $FILE." + echo "Your current version was saved above before restore." + git checkout -- "$FILE" + else + echo "ERROR: Not in a Git repo and no local backup found." + rm -f "$BACKUPS_FILE" + exit 1 + fi +fi + +rm -f "$BACKUPS_FILE" + +echo +echo "Post-restore sanity checks:" +echo " #ifndef count: $(grep -c '^#ifndef QUADRA_LAPLACE_HPP' "$FILE" || true)" +echo " #define count: $(grep -c '^#define QUADRA_LAPLACE_HPP' "$FILE" || true)" +echo " #endif count: $(grep -c '^#endif' "$FILE" || true)" +echo +echo "Remaining bad diagnostic identifiers, if any:" +grep -n 'timing_hdot_end\|timing_hdot_start\|return grad;' "$FILE" || true +echo +echo "Done. Now rebuild without diagnostics." diff --git a/restore_quadra_laplace_before_gradient_diagnostics.sh b/restore_quadra_laplace_before_gradient_diagnostics.sh new file mode 100755 index 0000000..f4a4bcd --- /dev/null +++ b/restore_quadra_laplace_before_gradient_diagnostics.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET="core/laplace.hpp" +if [[ ! -f "$TARGET" ]]; then + echo "ERROR: $TARGET not found. Run this from the Quadra repo root." >&2 + exit 1 +fi + +latest_backup=$(ls -t core/laplace.hpp.bak.gradient_diagnostics.* 2>/dev/null | head -1 || true) +if [[ -z "$latest_backup" ]]; then + echo "ERROR: No gradient diagnostic backup found: core/laplace.hpp.bak.gradient_diagnostics.*" >&2 + echo "Nothing changed." >&2 + exit 1 +fi + +save_current="core/laplace.hpp.bad_gradient_diagnostics.$(date +%Y%m%d_%H%M%S)" +cp "$TARGET" "$save_current" +cp "$latest_backup" "$TARGET" + +echo "Saved current broken file -> $save_current" +echo "Restored $TARGET from -> $latest_backup" +echo +echo "Now rebuild without diagnostic changes first. If that succeeds, run:" +echo " grep -n \"exact\|gradient\|logdet_grad\|du_dtheta\|H_u_theta\|return\" core/laplace.hpp | tail -120" +echo "and paste the relevant region so we can place diagnostics in the correct scope." diff --git a/tests/test_hdot_validation b/tests/test_hdot_validation new file mode 100755 index 0000000..35677bd Binary files /dev/null and b/tests/test_hdot_validation differ diff --git a/tests/test_laplace_structure_report.cpp b/tests/test_laplace_structure_report.cpp new file mode 100644 index 0000000..c4fa1f0 --- /dev/null +++ b/tests/test_laplace_structure_report.cpp @@ -0,0 +1,133 @@ +#include "../core/laplace/laplace_structure_report.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +void require(bool ok, const std::string &message) { + if (!ok) { + throw std::runtime_error(message); + } +} + +void require_near(double x, double y, double tol, const std::string &message) { + if (std::abs(x - y) > tol) { + throw std::runtime_error(message + ": got " + std::to_string(x) + + ", expected " + std::to_string(y)); + } +} + +void test_diagonal_report() { + Eigen::MatrixXd H = Eigen::MatrixXd::Zero(3, 3); + H(0, 0) = 4.0; + H(1, 1) = 9.0; + H(2, 2) = 16.0; + + const auto report = quadra::summarize_laplace_hessian_structure(H, 1.0e-12); + + require(report.random_effects == 3, "diagonal: random_effects"); + require(report.total_entries == 9, "diagonal: total_entries"); + require(report.structural_nonzeros == 3, "diagonal: structural_nonzeros"); + require_near(report.structural_density, 3.0 / 9.0, 1.0e-12, + "diagonal: structural_density"); + require(report.eigen_success, "diagonal: eigen_success"); + require(report.positive_definite, "diagonal: positive_definite"); + require_near(report.min_eigenvalue, 4.0, 1.0e-12, "diagonal: min_eigenvalue"); + require_near(report.max_eigenvalue, 16.0, 1.0e-12, + "diagonal: max_eigenvalue"); + + // Absolute curvature values are 16, 9, 4. 25 / 29 = 86.2%, so 90% + // requires all three nonzero entries. + bool found_90 = false; + for (const auto &row : report.effective_sparsity) { + if (row.label == "90%") { + found_90 = true; + require(row.entries_required == 3, "diagonal: 90% entries"); + } + } + require(found_90, "diagonal: found 90% row"); + + // Diagonal-only matrix should have effective bandwidth zero for all + // targets below 100%. + for (const auto &row : report.effective_bandwidth) { + if (row.label != "100%") { + require(row.bandwidth == 0, "diagonal: non-100% bandwidth"); + } + } +} + +void test_tridiagonal_effective_bandwidth() { + Eigen::MatrixXd H = Eigen::MatrixXd::Zero(4, 4); + H.diagonal().array() = 10.0; + + for (int i = 0; i < 3; ++i) { + H(i, i + 1) = -4.0; + H(i + 1, i) = -4.0; + } + + // Weak long-range tails. These make the matrix structurally denser than + // tridiagonal, but most curvature remains in bandwidth 1. + H(0, 2) = 0.1; + H(2, 0) = 0.1; + H(1, 3) = 0.1; + H(3, 1) = 0.1; + H(0, 3) = 0.01; + H(3, 0) = 0.01; + + const auto report = quadra::summarize_laplace_hessian_structure(H, 1.0e-12); + + require(report.random_effects == 4, "tri: random_effects"); + require(report.total_entries == 16, "tri: total_entries"); + require(report.structural_nonzeros == 16, "tri: structural_nonzeros"); + require(report.positive_definite, "tri: positive_definite"); + + std::size_t bw95 = 999; + std::size_t entries95 = 0; + for (const auto &row : report.effective_bandwidth) { + if (row.label == "95%") { + bw95 = row.bandwidth; + } + } + for (const auto &row : report.effective_sparsity) { + if (row.label == "95%") { + entries95 = row.entries_required; + } + } + + require(bw95 == 1, "tri: 95% effective bandwidth should be 1"); + require(entries95 < report.structural_nonzeros, + "tri: 95% effective sparsity should compress structural nnz"); +} + +void test_non_positive_definite_detection() { + Eigen::MatrixXd H = Eigen::MatrixXd::Zero(2, 2); + H(0, 0) = 1.0; + H(1, 1) = -1.0; + + const auto report = quadra::summarize_laplace_hessian_structure(H, 1.0e-12); + + require(report.eigen_success, "nonpd: eigen_success"); + require(!report.positive_definite, "nonpd: should not be positive definite"); + require(report.min_eigenvalue < 0.0, "nonpd: min eigenvalue negative"); +} + +} // namespace + +int main() { + try { + test_diagonal_report(); + test_tridiagonal_effective_bandwidth(); + test_non_positive_definite_detection(); + } catch (const std::exception &e) { + std::cerr << "test_laplace_structure_report failed: " << e.what() << "\n"; + return 1; + } + + std::cout << "test_laplace_structure_report passed\n"; + return 0; +}