use super::*;
#[derive(Clone, Debug)]
pub struct OuterGradientFdComponent {
pub block: String,
pub index: usize,
pub analytic: f64,
pub fd: f64,
}
impl OuterGradientFdComponent {
pub fn abs_gap(&self) -> f64 {
(self.analytic - self.fd).abs()
}
pub fn ratio(&self) -> Option<f64> {
if self.fd.abs() > 1e-12 {
Some(self.analytic / self.fd)
} else {
None
}
}
}
#[derive(Clone, Debug)]
pub struct OuterGradientFdAudit {
pub value: f64,
pub components: Vec<OuterGradientFdComponent>,
pub hessian_eigenvalues: Vec<f64>,
}
impl OuterGradientFdAudit {
pub fn analytic_block_norms(&self) -> Vec<(String, f64)> {
let mut order: Vec<String> = Vec::new();
let mut acc: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
for c in &self.components {
if !acc.contains_key(&c.block) {
order.push(c.block.clone());
}
*acc.entry(c.block.clone()).or_insert(0.0) += c.analytic * c.analytic;
}
order
.into_iter()
.map(|b| {
let v = acc.get(&b).copied().unwrap_or(0.0).sqrt();
(b, v)
})
.collect()
}
pub fn worst_component(&self) -> Option<&OuterGradientFdComponent> {
self.components.iter().max_by(|a, b| {
a.abs_gap()
.partial_cmp(&b.abs_gap())
.unwrap_or(std::cmp::Ordering::Equal)
})
}
pub fn min_abs_eigenvalue(&self) -> Option<f64> {
self.hessian_eigenvalues
.iter()
.map(|e| e.abs())
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn log_verdict(&self, context: &str) {
log::warn!("[OUTER-FD-AUDIT/{context}] value={:.6e}", self.value);
for (block, norm) in self.analytic_block_norms() {
log::warn!("[OUTER-FD-AUDIT/{context}] block={block} |g_analytic|={norm:.6e}");
}
for c in &self.components {
let ratio = c
.ratio()
.map(|r| format!("{r:.4}"))
.unwrap_or_else(|| "n/a".to_string());
log::warn!(
"[OUTER-FD-AUDIT/{context}] block={} i={} analytic={:.6e} fd={:.6e} gap={:.3e} ratio={}",
c.block,
c.index,
c.analytic,
c.fd,
c.abs_gap(),
ratio
);
}
if !self.hessian_eigenvalues.is_empty() {
let evs: Vec<String> = self
.hessian_eigenvalues
.iter()
.map(|e| format!("{e:.4e}"))
.collect();
log::warn!(
"[OUTER-FD-AUDIT/{context}] hessian_eigenvalues=[{}] min_abs={:.4e}",
evs.join(", "),
self.min_abs_eigenvalue().unwrap_or(f64::NAN)
);
}
match self.worst_component() {
Some(w) if w.abs_gap() > 1e-3 && w.abs_gap() > 1e-3 * w.fd.abs().max(1.0) => {
log::warn!(
"[OUTER-FD-AUDIT/{context}] VERDICT=DESYNC worst_block={} worst_i={} gap={:.3e} (analytic gradient disagrees with FD of the criterion: fix the derivative)",
w.block,
w.index,
w.abs_gap()
);
}
_ => {
let flat = self.min_abs_eigenvalue().map(|m| m < 1e-6).unwrap_or(false);
if flat {
log::warn!(
"[OUTER-FD-AUDIT/{context}] VERDICT=FLATNESS min_abs_eig={:.3e} (analytic≈FD but the outer Hessian is near-singular: weak identifiability, fix termination not the gradient)",
self.min_abs_eigenvalue().unwrap_or(f64::NAN)
);
} else {
log::warn!(
"[OUTER-FD-AUDIT/{context}] VERDICT=CLEAN analytic≈FD and outer Hessian well-conditioned at this θ"
);
}
}
}
}
}
pub fn outer_gradient_fd_audit<EvalF>(
theta0: &Array1<f64>,
h: f64,
block_for_index: impl Fn(usize) -> String,
mut eval: EvalF,
) -> Result<OuterGradientFdAudit, String>
where
EvalF: FnMut(
&Array1<f64>,
crate::solver::estimate::reml::unified::EvalMode,
) -> Result<(f64, Array1<f64>, HessianResult), String>,
{
use crate::solver::estimate::reml::unified::EvalMode;
let (value, analytic_grad, hess) = eval(theta0, EvalMode::ValueGradientHessian)?;
if analytic_grad.len() != theta0.len() {
return Err(format!(
"outer_gradient_fd_audit: analytic gradient length {} != theta length {}",
analytic_grad.len(),
theta0.len()
));
}
let mut components = Vec::with_capacity(theta0.len());
for i in 0..theta0.len() {
let mut tp = theta0.clone();
tp[i] += h;
let mut tm = theta0.clone();
tm[i] -= h;
let (vp, _, _) = eval(&tp, EvalMode::ValueOnly)?;
let (vm, _, _) = eval(&tm, EvalMode::ValueOnly)?;
let fd = (vp - vm) / (2.0 * h);
components.push(OuterGradientFdComponent {
block: block_for_index(i),
index: i,
analytic: analytic_grad[i],
fd,
});
}
let hessian_eigenvalues = match hess.materialize_dense() {
Ok(Some(mut hmat)) => {
let n = hmat.nrows();
if n == hmat.ncols() && n > 0 {
for r in 0..n {
for c in (r + 1)..n {
let avg = 0.5 * (hmat[[r, c]] + hmat[[c, r]]);
hmat[[r, c]] = avg;
hmat[[c, r]] = avg;
}
}
match crate::linalg::faer_ndarray::FaerEigh::eigh(&hmat, faer::Side::Lower) {
Ok((vals, _)) => {
let mut v: Vec<f64> = vals.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v
}
Err(_) => Vec::new(),
}
} else {
Vec::new()
}
}
_ => Vec::new(),
};
Ok(OuterGradientFdAudit {
value,
components,
hessian_eigenvalues,
})
}