use super::weighted_design_products::{mirror_upper_to_lower, xt_diag_x_design, xt_diag_y_design};
use super::{
BlockwiseTermFitResult, GamlssLambdaLayout, LOCATION_SCALE_N_OUTPUTS,
LocationScaleFamilyBuilder, build_location_scale_block, fit_location_scale_terms,
solve_penalizedweighted_projection, spatial_length_scale_term_indices,
};
use crate::block_layout::block_count::validate_block_count;
use crate::custom_family::{
BlockWorkingSet, BlockwiseFitOptions, CustomFamily, CustomFamilyBlockPsiDerivative,
FamilyEvaluation, ParameterBlockSpec, ParameterBlockState,
};
use crate::gamlss::GamlssError;
use crate::model_types::UnifiedFitResult;
use gam_linalg::matrix::LinearOperator;
use gam_math::jet_scalar::JetScalar;
use gam_math::nested_dual::JetField;
use gam_terms::smooth::{
SpatialLengthScaleOptimizationOptions, TermCollectionDesign, TermCollectionSpec,
get_spatial_length_scale, spatial_term_uses_per_axis_psi,
};
use ndarray::{Array1, Array2, s};
use statrs::function::gamma::ln_gamma;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DispersionFamilyKind {
NegativeBinomial,
Gamma,
Beta,
Tweedie { p: f64 },
}
impl DispersionFamilyKind {
pub const fn family_tag(self) -> &'static str {
match self {
DispersionFamilyKind::NegativeBinomial => FAMILY_NEGBIN_LOCATION_SCALE,
DispersionFamilyKind::Gamma => FAMILY_GAMMA_LOCATION_SCALE,
DispersionFamilyKind::Beta => FAMILY_BETA_LOCATION_SCALE,
DispersionFamilyKind::Tweedie { .. } => FAMILY_TWEEDIE_LOCATION_SCALE,
}
}
pub(crate) const fn mean_is_logit(self) -> bool {
matches!(self, DispersionFamilyKind::Beta)
}
pub fn base_link(self) -> gam_problem::InverseLink {
use gam_problem::{InverseLink, StandardLink};
if self.mean_is_logit() {
InverseLink::Standard(StandardLink::Logit)
} else {
InverseLink::Standard(StandardLink::Log)
}
}
pub fn likelihood_spec(self) -> gam_problem::LikelihoodSpec {
use gam_problem::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
let response = match self {
DispersionFamilyKind::NegativeBinomial => ResponseFamily::NegativeBinomial {
theta: 1.0,
theta_fixed: false,
},
DispersionFamilyKind::Gamma => ResponseFamily::Gamma,
DispersionFamilyKind::Beta => ResponseFamily::Beta { phi: 1.0 },
DispersionFamilyKind::Tweedie { p } => ResponseFamily::Tweedie { p },
};
let link = if self.mean_is_logit() {
InverseLink::Standard(StandardLink::Logit)
} else {
InverseLink::Standard(StandardLink::Log)
};
LikelihoodSpec::new(response, link)
}
}
pub const FAMILY_NEGBIN_LOCATION_SCALE: &str = "negbin-location-scale";
pub const FAMILY_GAMMA_LOCATION_SCALE: &str = "gamma-location-scale";
pub const FAMILY_BETA_LOCATION_SCALE: &str = "beta-location-scale";
pub const FAMILY_TWEEDIE_LOCATION_SCALE: &str = "tweedie-location-scale";
const DISPERSION_PARALLEL_ROW_THRESHOLD: usize = 1024;
pub(super) struct DispersionRowKernel {
pub(super) loglik: f64,
pub(super) mean_weight: f64,
pub(super) mean_response: f64,
pub(super) disp_weight: f64,
pub(super) disp_response: f64,
}
#[inline]
fn dispersion_geometry_error(row: usize, quantity: &'static str, eta: f64, value: f64) -> String {
GamlssError::RowGeometryUnrepresentable {
row,
quantity,
eta,
value,
}
.into()
}
fn validate_dispersion_row_geometry_inputs(
kind: DispersionFamilyKind,
row: usize,
yi: f64,
eta_mu: f64,
eta_d: f64,
prior_weight: f64,
) -> Result<(), String> {
if !eta_mu.is_finite() || !eta_d.is_finite() {
return Err(GamlssError::NonFinite {
reason: format!(
"{} requires finite predictors at row {row}; eta_mu={eta_mu}, eta_d={eta_d}",
kind.family_tag()
),
}
.into());
}
if !prior_weight.is_finite() || prior_weight < 0.0 {
return Err(GamlssError::InvalidInput {
reason: format!(
"{} requires finite non-negative prior weights; weight[{row}]={prior_weight}",
kind.family_tag()
),
}
.into());
}
if prior_weight == 0.0 {
return Ok(());
}
let (support_ok, support) = match kind {
DispersionFamilyKind::NegativeBinomial => (
yi.is_finite() && yi >= 0.0 && yi.fract() == 0.0,
"a finite non-negative integer",
),
DispersionFamilyKind::Gamma => (yi.is_finite() && yi > 0.0, "finite and > 0"),
DispersionFamilyKind::Beta => (
yi.is_finite() && yi > 0.0 && yi < 1.0,
"finite and strictly inside (0, 1)",
),
DispersionFamilyKind::Tweedie { p } => (
yi.is_finite() && yi >= 0.0 && p.is_finite() && p > 1.0 && p < 2.0,
"finite and >= 0 with power strictly inside (1, 2)",
),
};
if !support_ok {
return Err(GamlssError::InvalidInput {
reason: format!(
"{} response outside support at row {row}: y={yi} (requires {support})",
kind.family_tag()
),
}
.into());
}
let require_positive = |quantity, eta, value: f64| {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(dispersion_geometry_error(row, quantity, eta, value))
}
};
match kind {
DispersionFamilyKind::NegativeBinomial => {
let mu = eta_mu.exp();
let theta = eta_d.exp();
require_positive("negative-binomial mean exp(eta_mu)", eta_mu, mu)?;
require_positive("negative-binomial precision exp(eta_d)", eta_d, theta)
}
DispersionFamilyKind::Gamma => {
require_positive("Gamma mean exp(eta_mu)", eta_mu, eta_mu.exp())?;
require_positive("Gamma precision exp(eta_d)", eta_d, eta_d.exp())
}
DispersionFamilyKind::Beta => {
let mu = gam_linalg::utils::stable_logistic(eta_mu);
if !mu.is_finite() || mu <= 0.0 || mu >= 1.0 {
return Err(dispersion_geometry_error(
row,
"Beta mean logistic(eta_mu) in the open unit interval",
eta_mu,
mu,
));
}
let phi = eta_d.exp();
require_positive("Beta precision exp(eta_d)", eta_d, phi)?;
require_positive("Beta first shape mu*phi", eta_mu, mu * phi)?;
require_positive("Beta second shape (1-mu)*phi", eta_mu, (1.0 - mu) * phi)
}
DispersionFamilyKind::Tweedie { .. } => {
require_positive("Tweedie mean exp(eta_mu)", eta_mu, eta_mu.exp())?;
require_positive("Tweedie dispersion exp(-eta_d)", eta_d, (-eta_d).exp())
}
}
}
fn validate_dispersion_row_kernel_output(
row: usize,
eta_mu: f64,
eta_d: f64,
prior_weight: f64,
output: &DispersionRowKernel,
) -> Result<(), String> {
if prior_weight == 0.0 {
return Ok(());
}
for (quantity, eta, value, strictly_positive) in [
(
"dispersion-family row log likelihood",
eta_mu,
output.loglik,
false,
),
(
"dispersion-family mean working weight",
eta_mu,
output.mean_weight,
true,
),
(
"dispersion-family mean working response",
eta_mu,
output.mean_response,
false,
),
(
"dispersion-family precision working weight",
eta_d,
output.disp_weight,
true,
),
(
"dispersion-family precision working response",
eta_d,
output.disp_response,
false,
),
] {
if !value.is_finite() || (strictly_positive && value <= 0.0) {
return Err(dispersion_geometry_error(row, quantity, eta, value));
}
}
Ok(())
}
#[cfg(test)]
mod test_support {
use super::*;
#[inline]
pub(super) fn dispersion_nb_nll_generic<S: gam_math::jet_scalar::JetScalar<2>>(
yi: f64,
mu_value: f64,
theta_value: f64,
wi: f64,
) -> S {
let mu = S::variable(mu_value, 0);
let theta = S::variable(theta_value, 1);
let tpm = theta.add(&mu);
let loglik = theta
.add(&S::constant(yi))
.ln_gamma()
.sub(&theta.ln_gamma())
.sub(&S::constant(ln_gamma(yi + 1.0)))
.add(&theta.mul(&theta.ln()))
.sub(&theta.mul(&tpm.ln()))
.add(&mu.ln().scale(yi))
.sub(&tpm.ln().scale(yi));
loglik.scale(-wi)
}
#[inline]
pub(super) fn dispersion_gamma_nll_generic<S: gam_math::jet_scalar::JetScalar<2>>(
yi: f64,
y_pos: f64,
mu_value: f64,
nu_value: f64,
wi: f64,
) -> S {
let mu = S::variable(mu_value, 0);
let nu = S::variable(nu_value, 1);
let loglik = nu
.mul(&nu.ln())
.sub(&nu.mul(&mu.ln()))
.sub(&nu.ln_gamma())
.add(&nu.sub(&S::constant(1.0)).scale(y_pos.ln()))
.sub(&nu.mul(&mu.recip().scale(yi)));
loglik.scale(-wi)
}
#[inline]
pub(super) fn dispersion_beta_nll_generic<S: gam_math::jet_scalar::JetScalar<2>>(
yi: f64,
mu_value: f64,
phi_value: f64,
wi: f64,
) -> S {
let mu = S::variable(mu_value, 0);
let phi = S::variable(phi_value, 1);
let one_minus_mu = S::constant(1.0).sub(&mu);
let yc = yi;
let a = mu.mul(&phi);
let b = one_minus_mu.mul(&phi);
let loglik = phi
.ln_gamma()
.sub(&a.ln_gamma())
.sub(&b.ln_gamma())
.add(&a.sub(&S::constant(1.0)).scale(yc.ln()))
.add(&b.sub(&S::constant(1.0)).scale((-yc).ln_1p()));
loglik.scale(-wi)
}
#[inline]
pub(super) fn dispersion_nb_nll_order2(
yi: f64,
mu_value: f64,
theta_value: f64,
wi: f64,
) -> gam_math::jet_scalar::Order2<2> {
type O2 = gam_math::jet_scalar::Order2<2>;
let mu = O2::variable(mu_value, 0);
let theta = O2::variable(theta_value, 1);
let tpm = theta.add(&mu);
let theta_plus_y = theta.add(&O2::constant(yi));
let loglik = order2_ln_gamma(&theta_plus_y)
.sub(&order2_ln_gamma(&theta))
.sub(&O2::constant(ln_gamma(yi + 1.0)))
.add(&theta.mul(&theta.ln()))
.sub(&theta.mul(&tpm.ln()))
.add(&mu.ln().scale(yi))
.sub(&tpm.ln().scale(yi));
loglik.scale(-wi)
}
#[inline]
pub(super) fn dispersion_gamma_nll_order2(
yi: f64,
y_pos: f64,
mu_value: f64,
nu_value: f64,
wi: f64,
) -> gam_math::jet_scalar::Order2<2> {
type O2 = gam_math::jet_scalar::Order2<2>;
let mu = O2::variable(mu_value, 0);
let nu = O2::variable(nu_value, 1);
let loglik = nu
.mul(&nu.ln())
.sub(&nu.mul(&mu.ln()))
.sub(&order2_ln_gamma(&nu))
.add(&nu.sub(&O2::constant(1.0)).scale(y_pos.ln()))
.sub(&nu.mul(&mu.recip().scale(yi)));
loglik.scale(-wi)
}
}
#[inline]
pub(crate) fn dispersion_beta_nll_order2(
yi: f64,
mu_value: f64,
phi_value: f64,
wi: f64,
) -> gam_math::jet_scalar::Order2<2> {
type O2 = gam_math::jet_scalar::Order2<2>;
let mu = O2::variable(mu_value, 0);
let phi = O2::variable(phi_value, 1);
let one_minus_mu = O2::constant(1.0).sub(&mu);
let yc = yi;
let a = mu.mul(&phi);
let b = one_minus_mu.mul(&phi);
let loglik = order2_ln_gamma(&phi)
.sub(&order2_ln_gamma(&a))
.sub(&order2_ln_gamma(&b))
.add(&a.sub(&O2::constant(1.0)).scale(yc.ln()))
.add(&b.sub(&O2::constant(1.0)).scale((-yc).ln_1p()));
loglik.scale(-wi)
}
#[inline]
fn order2_ln_gamma<const K: usize>(
x: &gam_math::jet_scalar::Order2<K>,
) -> gam_math::jet_scalar::Order2<K> {
gam_math::jet_scalar::Order2(
x.0.compose_unary(gam_math::jet_tower::ln_gamma_derivative_stack_order2(x.0.v)),
)
}
#[inline]
pub(crate) fn dispersion_gamma_disp_order2(
yi: f64,
y_pos: f64,
mu_value: f64,
nu_value: f64,
wi: f64,
) -> gam_math::jet_scalar::Order2<1> {
type O1 = gam_math::jet_scalar::Order2<1>;
let mu = O1::constant(mu_value);
let nu = O1::variable(nu_value, 0);
let loglik = nu
.mul(&nu.ln())
.sub(&nu.mul(&mu.ln()))
.sub(&order2_ln_gamma(&nu))
.add(&nu.sub(&O1::constant(1.0)).scale(y_pos.ln()))
.sub(&nu.mul(&mu.recip().scale(yi)));
loglik.scale(-wi)
}
#[inline]
pub(crate) fn dispersion_tweedie_disp_order2(
yi: f64,
eta_mu: f64,
eta_d: f64,
p: f64,
wi: f64,
) -> gam_math::jet_scalar::Order2<1> {
type O1 = gam_math::jet_scalar::Order2<1>;
let one_minus_p = 1.0 - p;
let two_minus_p = 2.0 - p;
let mu = O1::constant(eta_mu).exp();
let phi = O1::variable(eta_d, 0).scale(-1.0).exp();
if yi > 0.0 {
let dev = mu
.powf(two_minus_p)
.scale(1.0 / two_minus_p)
.sub(&mu.powf(one_minus_p).scale(yi / one_minus_p))
.add(&O1::constant(
yi.powf(two_minus_p) / (one_minus_p * two_minus_p),
))
.scale(2.0);
let loglik = dev
.mul(&phi.recip().scale(-0.5))
.sub(&phi.scale(2.0 * std::f64::consts::PI).ln().scale(0.5))
.sub(&O1::constant(0.5 * p * yi.ln()));
loglik.scale(-wi)
} else {
let c = mu.powf(two_minus_p).scale(1.0 / two_minus_p);
let loglik = c.mul(&phi.recip()).scale(-1.0);
loglik.scale(-wi)
}
}
#[inline]
fn dispersion_nb_loglik(yi: f64, mu: f64, theta: f64, wi: f64) -> f64 {
let log_theta_share = log_positive_share(theta, mu);
let log_mu_share = log_positive_share(mu, theta);
let s = ln_gamma(theta + yi) - ln_gamma(theta) - ln_gamma(yi + 1.0)
+ theta * log_theta_share
+ yi * log_mu_share;
-(s * -wi)
}
#[inline]
fn log_positive_share(numerator: f64, other: f64) -> f64 {
if numerator >= other {
-(other / numerator).ln_1p()
} else {
let ratio = numerator / other;
numerator.ln() - other.ln() - ratio.ln_1p()
}
}
#[inline]
fn positive_share(numerator: f64, other: f64) -> f64 {
if numerator >= other {
1.0 / (1.0 + other / numerator)
} else {
let ratio = numerator / other;
ratio / (1.0 + ratio)
}
}
#[inline]
fn nb_log_precision_fisher_jensen(mu: f64, theta: f64) -> f64 {
let r = positive_share(theta, mu);
let q = positive_share(mu, theta);
if theta <= 32.0 {
let total = theta + mu;
let remainder_theta = gam_math::jet_tower::trigamma(theta) - theta.recip();
let remainder_total = gam_math::jet_tower::trigamma(total) - total.recip();
return theta * theta * (remainder_theta - remainder_total);
}
let one_minus_r2 = q * (1.0 + r);
let r2 = r * r;
let one_minus_r3 = q * (1.0 + r + r2);
let r4 = r2 * r2;
let one_minus_r5 = q * (1.0 + r + r2 + r2 * r + r4);
let r6 = r4 * r2;
let one_minus_r7 = q * (1.0 + r + r2 + r2 * r + r4 + r4 * r + r6);
let inv = theta.recip();
let inv2 = inv * inv;
0.5 * one_minus_r2 + (inv / 6.0) * one_minus_r3 - (inv * inv2 / 30.0) * one_minus_r5
+ (inv * inv2 * inv2 / 42.0) * one_minus_r7
}
#[inline]
fn dispersion_gamma_loglik(yi: f64, y_pos: f64, mu: f64, nu: f64, wi: f64) -> f64 {
let s = nu * nu.ln() - nu * mu.ln() - ln_gamma(nu) + (nu - 1.0) * y_pos.ln()
- nu * ((1.0 / mu) * yi);
-(s * -wi)
}
#[inline]
fn dispersion_beta_loglik(yi: f64, mu: f64, phi: f64, wi: f64) -> f64 {
let one_minus_mu = 1.0 - mu;
let yc = yi;
let a = mu * phi;
let b = one_minus_mu * phi;
let s =
ln_gamma(phi) - ln_gamma(a) - ln_gamma(b) + (a - 1.0) * yc.ln() + (b - 1.0) * (-yc).ln_1p();
-(s * -wi)
}
#[inline]
fn dispersion_tweedie_loglik(yi: f64, eta_mu: f64, eta_d: f64, p: f64, wi: f64) -> f64 {
let one_minus_p = 1.0 - p;
let two_minus_p = 2.0 - p;
let mu = eta_mu.exp();
let phi = (-eta_d).exp();
let s = if yi > 0.0 {
let dev = (mu.powf(two_minus_p) * (1.0 / two_minus_p)
- mu.powf(one_minus_p) * (yi / one_minus_p)
+ yi.powf(two_minus_p) / (one_minus_p * two_minus_p))
* 2.0;
dev * ((1.0 / phi) * -0.5)
- (phi * (2.0 * std::f64::consts::PI)).ln() * 0.5
- 0.5 * p * yi.ln()
} else {
let c = mu.powf(two_minus_p) * (1.0 / two_minus_p);
(c * (1.0 / phi)) * -1.0
};
-(s * -wi)
}
#[inline]
pub(crate) fn dispersion_row_loglik(
kind: DispersionFamilyKind,
yi: f64,
eta_mu: f64,
eta_d: f64,
prior_weight: f64,
) -> f64 {
if prior_weight <= 0.0 {
return 0.0;
}
let wi = prior_weight;
let em = eta_mu;
let ed = eta_d;
match kind {
DispersionFamilyKind::NegativeBinomial => {
let mu = em.exp();
let theta = ed.exp();
dispersion_nb_loglik(yi, mu, theta, wi)
}
DispersionFamilyKind::Gamma => {
let mu = em.exp();
let nu = ed.exp();
let y_pos = yi;
dispersion_gamma_loglik(yi, y_pos, mu, nu, wi)
}
DispersionFamilyKind::Beta => {
let mu = gam_linalg::utils::stable_logistic(em);
let phi = ed.exp();
dispersion_beta_loglik(yi, mu, phi, wi)
}
DispersionFamilyKind::Tweedie { p } => dispersion_tweedie_loglik(yi, em, ed, p, wi),
}
}
pub(crate) fn dispersion_eta_nll_order2(
kind: DispersionFamilyKind,
yi: f64,
em: f64,
ed: f64,
wi: f64,
) -> gam_math::jet_scalar::Order2<2> {
type O2 = gam_math::jet_scalar::Order2<2>;
let eta_mu = O2::variable(em, 0);
let eta_d = O2::variable(ed, 1);
match kind {
DispersionFamilyKind::NegativeBinomial => {
let theta = eta_d.exp();
let theta_plus_y = theta.add(&O2::constant(yi));
let log_total = if em >= ed {
eta_mu.add(&eta_d.sub(&eta_mu).exp().add(&O2::constant(1.0)).ln())
} else {
eta_d.add(&eta_mu.sub(&eta_d).exp().add(&O2::constant(1.0)).ln())
};
let loglik = order2_ln_gamma(&theta_plus_y)
.sub(&order2_ln_gamma(&theta))
.sub(&O2::constant(ln_gamma(yi + 1.0)))
.add(&theta.mul(&eta_d.sub(&log_total)))
.add(&eta_mu.sub(&log_total).scale(yi));
loglik.scale(-wi)
}
DispersionFamilyKind::Gamma => {
let mu = eta_mu.exp();
let nu = eta_d.exp();
let y_pos = yi;
let loglik = nu
.mul(&nu.ln())
.sub(&nu.mul(&mu.ln()))
.sub(&order2_ln_gamma(&nu))
.add(&nu.sub(&O2::constant(1.0)).scale(y_pos.ln()))
.sub(&nu.mul(&mu.recip().scale(yi)));
loglik.scale(-wi)
}
DispersionFamilyKind::Beta => {
let mu = eta_mu.scale(-1.0).exp().add(&O2::constant(1.0)).recip();
let phi = eta_d.exp();
let one_minus_mu = O2::constant(1.0).sub(&mu);
let yc = yi;
let a = mu.mul(&phi);
let b = one_minus_mu.mul(&phi);
let loglik = order2_ln_gamma(&phi)
.sub(&order2_ln_gamma(&a))
.sub(&order2_ln_gamma(&b))
.add(&a.sub(&O2::constant(1.0)).scale(yc.ln()))
.add(&b.sub(&O2::constant(1.0)).scale((-yc).ln_1p()));
loglik.scale(-wi)
}
DispersionFamilyKind::Tweedie { p } => {
let one_minus_p = 1.0 - p;
let two_minus_p = 2.0 - p;
let mu = eta_mu.exp();
let phi = eta_d.scale(-1.0).exp();
if yi > 0.0 {
let dev = mu
.powf(two_minus_p)
.scale(1.0 / two_minus_p)
.sub(&mu.powf(one_minus_p).scale(yi / one_minus_p))
.add(&O2::constant(
yi.powf(two_minus_p) / (one_minus_p * two_minus_p),
))
.scale(2.0);
let loglik = dev
.mul(&phi.recip().scale(-0.5))
.sub(&phi.scale(2.0 * std::f64::consts::PI).ln().scale(0.5))
.sub(&O2::constant(0.5 * p * yi.ln()));
loglik.scale(-wi)
} else {
let c = mu.powf(two_minus_p).scale(1.0 / two_minus_p);
let loglik = c.mul(&phi.recip()).scale(-1.0);
loglik.scale(-wi)
}
}
}
}
pub(crate) fn dispersion_row_observed_hessian_weights(
kind: DispersionFamilyKind,
yi: f64,
eta_mu: f64,
eta_d: f64,
prior_weight: f64,
) -> (f64, f64, f64) {
if prior_weight <= 0.0 {
return (0.0, 0.0, 0.0);
}
let tower = dispersion_eta_nll_order2(kind, yi, eta_mu, eta_d, prior_weight);
let h = tower.h();
(h[0][0], h[0][1], h[1][1])
}
type O3 = gam_math::jet_tower::Tower3<2>;
fn o3_exp(x: &O3) -> O3 {
x.compose_unary_with(|v| {
let e = v.exp();
[e, e, e, e]
})
}
fn o3_ln(x: &O3) -> O3 {
x.compose_unary_with(|v| [v.ln(), v.recip(), -v.powi(-2), 2.0 * v.powi(-3)])
}
fn o3_recip(x: &O3) -> O3 {
x.compose_unary_with(|v| [v.recip(), -v.powi(-2), 2.0 * v.powi(-3), -6.0 * v.powi(-4)])
}
fn o3_powf(x: &O3, a: f64) -> O3 {
x.compose_unary_with(|v| {
[
v.powf(a),
a * v.powf(a - 1.0),
a * (a - 1.0) * v.powf(a - 2.0),
a * (a - 1.0) * (a - 2.0) * v.powf(a - 3.0),
]
})
}
fn o3_ln_gamma(x: &O3) -> O3 {
x.compose_unary_with(|v| {
let stack = gam_math::jet_tower::ln_gamma_derivative_stack(v);
[stack[0], stack[1], stack[2], stack[3]]
})
}
pub(crate) fn dispersion_eta_nll_order3(
kind: DispersionFamilyKind,
yi: f64,
em: f64,
ed: f64,
wi: f64,
) -> O3 {
let eta_mu = O3::variable(em, 0);
let eta_d = O3::variable(ed, 1);
match kind {
DispersionFamilyKind::NegativeBinomial => {
let theta = o3_exp(&eta_d);
let theta_plus_y = theta.add(&O3::constant(yi));
let log_total = if em >= ed {
eta_mu.add(&o3_ln(
&o3_exp(&eta_d.sub(&eta_mu)).add(&O3::constant(1.0)),
))
} else {
eta_d.add(&o3_ln(
&o3_exp(&eta_mu.sub(&eta_d)).add(&O3::constant(1.0)),
))
};
let loglik = o3_ln_gamma(&theta_plus_y)
.sub(&o3_ln_gamma(&theta))
.sub(&O3::constant(ln_gamma(yi + 1.0)))
.add(&theta.mul(&eta_d.sub(&log_total)))
.add(&eta_mu.sub(&log_total).scale(yi));
loglik.scale(-wi)
}
DispersionFamilyKind::Gamma => {
let mu = o3_exp(&eta_mu);
let nu = o3_exp(&eta_d);
let y_pos = yi;
let loglik = nu
.mul(&o3_ln(&nu))
.sub(&nu.mul(&o3_ln(&mu)))
.sub(&o3_ln_gamma(&nu))
.add(&nu.sub(&O3::constant(1.0)).scale(y_pos.ln()))
.sub(&nu.mul(&o3_recip(&mu).scale(yi)));
loglik.scale(-wi)
}
DispersionFamilyKind::Beta => {
let mu = o3_recip(&o3_exp(&eta_mu.scale(-1.0)).add(&O3::constant(1.0)));
let phi = o3_exp(&eta_d);
let one_minus_mu = O3::constant(1.0).sub(&mu);
let yc = yi;
let a = mu.mul(&phi);
let b = one_minus_mu.mul(&phi);
let loglik = o3_ln_gamma(&phi)
.sub(&o3_ln_gamma(&a))
.sub(&o3_ln_gamma(&b))
.add(&a.sub(&O3::constant(1.0)).scale(yc.ln()))
.add(&b.sub(&O3::constant(1.0)).scale((-yc).ln_1p()));
loglik.scale(-wi)
}
DispersionFamilyKind::Tweedie { p } => {
let one_minus_p = 1.0 - p;
let two_minus_p = 2.0 - p;
let mu = o3_exp(&eta_mu);
let phi = o3_exp(&eta_d.scale(-1.0));
if yi > 0.0 {
let dev = o3_powf(&mu, two_minus_p)
.scale(1.0 / two_minus_p)
.sub(&o3_powf(&mu, one_minus_p).scale(yi / one_minus_p))
.add(&O3::constant(
yi.powf(two_minus_p) / (one_minus_p * two_minus_p),
))
.scale(2.0);
let loglik = dev
.mul(&o3_recip(&phi).scale(-0.5))
.sub(&o3_ln(&phi.scale(2.0 * std::f64::consts::PI)).scale(0.5))
.sub(&O3::constant(0.5 * p * yi.ln()));
loglik.scale(-wi)
} else {
let c = o3_powf(&mu, two_minus_p).scale(1.0 / two_minus_p);
let loglik = c.mul(&o3_recip(&phi)).scale(-1.0);
loglik.scale(-wi)
}
}
}
}
pub(crate) fn dispersion_row_observed_hessian_directional(
kind: DispersionFamilyKind,
yi: f64,
eta_mu: f64,
eta_d: f64,
prior_weight: f64,
du_mu: f64,
du_d: f64,
) -> (f64, f64, f64) {
if prior_weight <= 0.0 {
return (0.0, 0.0, 0.0);
}
let tower = dispersion_eta_nll_order3(kind, yi, eta_mu, eta_d, prior_weight);
let t3 = &tower.t3;
(
t3[0][0][0] * du_mu + t3[0][0][1] * du_d,
t3[0][1][0] * du_mu + t3[0][1][1] * du_d,
t3[1][1][0] * du_mu + t3[1][1][1] * du_d,
)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DispersionAloRowGeometry {
pub nll_score: [f64; 2],
pub observed_hessian: [[f64; 2]; 2],
}
pub fn dispersion_alo_row_geometry(
kind: DispersionFamilyKind,
row: usize,
y: f64,
eta_mu: f64,
eta_d: f64,
prior_weight: f64,
) -> Result<DispersionAloRowGeometry, String> {
validate_dispersion_row_geometry_inputs(kind, row, y, eta_mu, eta_d, prior_weight)?;
if prior_weight == 0.0 {
return Ok(DispersionAloRowGeometry {
nll_score: [0.0; 2],
observed_hessian: [[0.0; 2]; 2],
});
}
let tower = dispersion_eta_nll_order2(kind, y, eta_mu, eta_d, prior_weight);
let (_, gradient, hessian) = tower.into_channels();
let geometry = DispersionAloRowGeometry {
nll_score: gradient,
observed_hessian: hessian,
};
if geometry
.nll_score
.iter()
.chain(geometry.observed_hessian.iter().flatten())
.any(|value| !value.is_finite())
{
return Err(GamlssError::RowGeometryUnrepresentable {
row,
quantity: "dispersion-family ALO row geometry",
eta: eta_mu,
value: f64::NAN,
}
.into());
}
Ok(geometry)
}
#[inline]
pub(crate) fn tower_score_info<const K: usize>(
tower: &gam_math::jet_scalar::Order2<K>,
idx: usize,
wi: f64,
) -> (f64, f64) {
if wi == 0.0 {
(0.0, 0.0)
} else {
(-tower.g()[idx] / wi, tower.h()[idx][idx] / wi)
}
}
pub(super) fn dispersion_row_kernel(
kind: DispersionFamilyKind,
yi: f64,
eta_mu: f64,
eta_d: f64,
prior_weight: f64,
) -> DispersionRowKernel {
let em = eta_mu;
let ed = eta_d;
if prior_weight <= 0.0 {
return DispersionRowKernel {
loglik: 0.0,
mean_weight: 0.0,
mean_response: em,
disp_weight: 0.0,
disp_response: ed,
};
}
let wi = prior_weight;
match kind {
DispersionFamilyKind::NegativeBinomial => {
let mu = em.exp();
let theta = ed.exp(); let loglik = dispersion_nb_loglik(yi, mu, theta, wi);
let mean_eta_information = if mu >= theta {
theta / (1.0 + theta / mu)
} else {
mu / (1.0 + mu / theta)
};
let mean_weight = wi * mean_eta_information;
let mean_response = em + (yi - mu) / mu;
let theta_fraction = if theta >= mu {
(mu / theta - yi / theta) / (1.0 + mu / theta)
} else {
(1.0 - yi / mu) / (1.0 + theta / mu)
};
let score_theta = gam_math::jet_tower::digamma(theta + yi)
- gam_math::jet_tower::digamma(theta)
+ log_positive_share(theta, mu)
+ theta_fraction;
let score_eta = theta * score_theta;
let eta_information = nb_log_precision_fisher_jensen(mu, theta);
let disp_weight = wi * eta_information;
let disp_response = ed + score_eta / eta_information;
DispersionRowKernel {
loglik,
mean_weight,
mean_response,
disp_weight,
disp_response,
}
}
DispersionFamilyKind::Gamma => {
let mu = em.exp();
let nu = ed.exp(); let tower = dispersion_gamma_disp_order2(yi, yi, mu, nu, wi);
let (s_nu, info_nu_raw) = tower_score_info(&tower, 0, wi);
let loglik = -tower.value();
let mean_weight = wi * nu;
let mean_response = em + (yi - mu) / mu;
let disp_weight = wi * nu * nu * info_nu_raw;
let disp_response = ed + s_nu / (nu * info_nu_raw);
DispersionRowKernel {
loglik,
mean_weight,
mean_response,
disp_weight,
disp_response,
}
}
DispersionFamilyKind::Beta => {
let logit = gam_solve::mixture_link::logit_inverse_link_jet5(em);
let mu = logit.mu;
let phi = ed.exp(); let q = logit.d1;
let tower = dispersion_beta_nll_order2(yi, mu, phi, wi);
let (score_mu, _) = tower_score_info(&tower, 0, wi);
let (s_phi, _) = tower_score_info(&tower, 1, wi);
let loglik = -tower.value();
let a = mu * phi;
let b = (1.0 - mu) * phi;
let tri_a = gam_math::jet_tower::trigamma(a);
let tri_b = gam_math::jet_tower::trigamma(b);
let tri_phi = gam_math::jet_tower::trigamma(phi);
let info_mu = phi * phi * (tri_a + tri_b);
let one_minus_mu = 1.0 - mu;
let info_phi = mu * mu * tri_a + one_minus_mu * one_minus_mu * tri_b - tri_phi;
let mean_weight = wi * q * q * info_mu;
let mean_response = em + score_mu / (q * info_mu);
let disp_weight = wi * phi * phi * info_phi;
let disp_response = ed + s_phi / (phi * info_phi);
DispersionRowKernel {
loglik,
mean_weight,
mean_response,
disp_weight,
disp_response,
}
}
DispersionFamilyKind::Tweedie { p } => {
let mu = em.exp();
let phi = (-ed).exp();
let two_minus_p = 2.0 - p;
let mean_weight = wi * mu.powf(two_minus_p) / phi;
let mean_response = em + (yi - mu) / mu;
let tower = dispersion_tweedie_disp_order2(yi, em, ed, p, wi);
let loglik = -tower.value();
let (s_eta, info_eta_raw) = tower_score_info(&tower, 0, wi);
let curvature_eta = if yi > 0.0 { 0.5 } else { info_eta_raw };
let disp_weight = wi * curvature_eta;
let disp_response = ed + s_eta / curvature_eta;
DispersionRowKernel {
loglik,
mean_weight,
mean_response,
disp_weight,
disp_response,
}
}
}
}
#[derive(Clone)]
pub(crate) struct DispersionGlmLocationScaleFamily {
pub(crate) kind: DispersionFamilyKind,
pub(crate) y: Array1<f64>,
pub(crate) weights: Array1<f64>,
}
impl DispersionGlmLocationScaleFamily {
pub(crate) const BLOCK_MEAN: usize = 0;
pub(crate) const BLOCK_DISP: usize = 1;
}
impl CustomFamily for DispersionGlmLocationScaleFamily {
fn joint_jeffreys_term_required(&self) -> bool {
true
}
fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
validate_block_count::<GamlssError>(self.kind.family_tag(), 2, block_states.len())?;
let eta_mu = &block_states[Self::BLOCK_MEAN].eta;
let eta_d = &block_states[Self::BLOCK_DISP].eta;
let n = self.y.len();
if eta_mu.len() != n || eta_d.len() != n || self.weights.len() != n {
return Err(format!(
"{} row-count mismatch: y={n}, eta_mu={}, eta_d={}, weights={}",
self.kind.family_tag(),
eta_mu.len(),
eta_d.len(),
self.weights.len()
));
}
for i in 0..n {
validate_dispersion_row_geometry_inputs(
self.kind,
i,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)?;
}
let kernels: Vec<DispersionRowKernel> =
if rayon::current_thread_index().is_none() && n > DISPERSION_PARALLEL_ROW_THRESHOLD {
use rayon::iter::{IntoParallelIterator, ParallelIterator};
(0..n)
.into_par_iter()
.map(|i| {
dispersion_row_kernel(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)
})
.collect()
} else {
(0..n)
.map(|i| {
dispersion_row_kernel(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)
})
.collect()
};
let mut log_likelihood = 0.0;
for (i, row) in kernels.iter().enumerate() {
validate_dispersion_row_kernel_output(i, eta_mu[i], eta_d[i], self.weights[i], row)?;
log_likelihood += row.loglik;
if !log_likelihood.is_finite() {
return Err(dispersion_geometry_error(
i,
"dispersion-family cumulative log likelihood",
eta_mu[i],
log_likelihood,
));
}
}
let mean_weights = Array1::from_iter(kernels.iter().map(|row| row.mean_weight));
let mean_response = Array1::from_iter(kernels.iter().map(|row| row.mean_response));
let disp_weights = Array1::from_iter(kernels.iter().map(|row| row.disp_weight));
let disp_response = Array1::from_iter(kernels.iter().map(|row| row.disp_response));
Ok(FamilyEvaluation {
log_likelihood,
blockworking_sets: vec![
BlockWorkingSet::diagonal_checked(mean_response, mean_weights)?,
BlockWorkingSet::diagonal_checked(disp_response, disp_weights)?,
],
})
}
fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
validate_block_count::<GamlssError>(self.kind.family_tag(), 2, block_states.len())?;
let eta_mu = &block_states[Self::BLOCK_MEAN].eta;
let eta_d = &block_states[Self::BLOCK_DISP].eta;
let n = self.y.len();
if eta_mu.len() != n || eta_d.len() != n || self.weights.len() != n {
return Err(GamlssError::DimensionMismatch {
reason: format!(
"{} log-likelihood row-count mismatch: y={n}, eta_mu={}, eta_d={}, weights={}",
self.kind.family_tag(),
eta_mu.len(),
eta_d.len(),
self.weights.len()
),
}
.into());
}
for i in 0..n {
validate_dispersion_row_geometry_inputs(
self.kind,
i,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)?;
}
let per_row: Vec<f64> =
if rayon::current_thread_index().is_none() && n > DISPERSION_PARALLEL_ROW_THRESHOLD {
use rayon::iter::{IntoParallelIterator, ParallelIterator};
(0..n)
.into_par_iter()
.map(|i| {
dispersion_row_loglik(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)
})
.collect()
} else {
(0..n)
.map(|i| {
dispersion_row_loglik(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)
})
.collect()
};
let mut ll = 0.0;
for (i, loglik) in per_row.into_iter().enumerate() {
if !loglik.is_finite() {
return Err(dispersion_geometry_error(
i,
"dispersion-family row log likelihood",
eta_mu[i],
loglik,
));
}
ll += loglik;
if !ll.is_finite() {
return Err(dispersion_geometry_error(
i,
"dispersion-family cumulative log likelihood",
eta_mu[i],
ll,
));
}
}
Ok(ll)
}
fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
crate::location_scale_engine::location_scale_coefficient_hessian_cost(
self.y.len() as u64,
specs,
)
}
fn exact_newton_joint_hessian_with_specs(
&self,
block_states: &[ParameterBlockState],
specs: &[ParameterBlockSpec],
) -> Result<Option<Array2<f64>>, String> {
validate_block_count::<GamlssError>(self.kind.family_tag(), 2, block_states.len())?;
if specs.len() != 2 {
return Err(format!(
"{} exact joint Hessian expects 2 specs, got {}",
self.kind.family_tag(),
specs.len()
));
}
let eta_mu = &block_states[Self::BLOCK_MEAN].eta;
let eta_d = &block_states[Self::BLOCK_DISP].eta;
let n = self.y.len();
if eta_mu.len() != n || eta_d.len() != n || self.weights.len() != n {
return Err(format!(
"{} exact joint Hessian row-count mismatch: y={n}, eta_mu={}, eta_d={}, weights={}",
self.kind.family_tag(),
eta_mu.len(),
eta_d.len(),
self.weights.len()
));
}
for i in 0..n {
validate_dispersion_row_geometry_inputs(
self.kind,
i,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)?;
}
let observed: Vec<(f64, f64, f64)> =
if rayon::current_thread_index().is_none() && n > DISPERSION_PARALLEL_ROW_THRESHOLD {
use rayon::iter::{IntoParallelIterator, ParallelIterator};
(0..n)
.into_par_iter()
.map(|i| {
dispersion_row_observed_hessian_weights(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)
})
.collect()
} else {
(0..n)
.map(|i| {
dispersion_row_observed_hessian_weights(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)
})
.collect()
};
for (i, &(h_mm, h_md, h_dd)) in observed.iter().enumerate() {
for (quantity, eta, value) in [
("dispersion-family observed mean curvature", eta_mu[i], h_mm),
(
"dispersion-family observed cross curvature",
eta_mu[i],
h_md,
),
(
"dispersion-family observed precision curvature",
eta_d[i],
h_dd,
),
] {
if !value.is_finite() {
return Err(dispersion_geometry_error(i, quantity, eta, value));
}
}
}
let mean_weights = Array1::from_shape_fn(n, |i| observed[i].0);
let cross_weights = Array1::from_shape_fn(n, |i| observed[i].1);
let disp_weights = Array1::from_shape_fn(n, |i| observed[i].2);
let mean_spec = &specs[Self::BLOCK_MEAN];
let disp_spec = &specs[Self::BLOCK_DISP];
if mean_spec.design.nrows() != n || disp_spec.design.nrows() != n {
return Err(format!(
"{} exact joint Hessian design row mismatch: y={n}, mean rows={}, precision rows={}",
self.kind.family_tag(),
mean_spec.design.nrows(),
disp_spec.design.nrows()
));
}
let p_mean = mean_spec.design.ncols();
let p_disp = disp_spec.design.ncols();
if block_states[Self::BLOCK_MEAN].beta.len() != p_mean
|| block_states[Self::BLOCK_DISP].beta.len() != p_disp
{
return Err(format!(
"{} exact joint Hessian beta/design mismatch: mean beta {} vs cols {}, precision beta {} vs cols {}",
self.kind.family_tag(),
block_states[Self::BLOCK_MEAN].beta.len(),
p_mean,
block_states[Self::BLOCK_DISP].beta.len(),
p_disp
));
}
let h_mean = xt_diag_x_design(&mean_spec.design, &mean_weights)?;
let h_cross = xt_diag_y_design(&mean_spec.design, &cross_weights, &disp_spec.design)?;
let h_disp = xt_diag_x_design(&disp_spec.design, &disp_weights)?;
let total = p_mean + p_disp;
let mut h = Array2::<f64>::zeros((total, total));
h.slice_mut(s![0..p_mean, 0..p_mean]).assign(&h_mean);
h.slice_mut(s![0..p_mean, p_mean..total]).assign(&h_cross);
h.slice_mut(s![p_mean..total, p_mean..total])
.assign(&h_disp);
mirror_upper_to_lower(&mut h);
Ok(Some(h))
}
fn exact_newton_joint_hessian_directional_derivative_with_specs(
&self,
block_states: &[ParameterBlockState],
specs: &[ParameterBlockSpec],
d_beta_flat: &Array1<f64>,
) -> Result<Option<Array2<f64>>, String> {
validate_block_count::<GamlssError>(self.kind.family_tag(), 2, block_states.len())?;
if specs.len() != 2 {
return Err(format!(
"{} joint Hessian directional derivative expects 2 specs, got {}",
self.kind.family_tag(),
specs.len()
));
}
let eta_mu = &block_states[Self::BLOCK_MEAN].eta;
let eta_d = &block_states[Self::BLOCK_DISP].eta;
let n = self.y.len();
if eta_mu.len() != n || eta_d.len() != n || self.weights.len() != n {
return Err(format!(
"{} joint Hessian directional derivative row-count mismatch: y={n}, eta_mu={}, eta_d={}, weights={}",
self.kind.family_tag(),
eta_mu.len(),
eta_d.len(),
self.weights.len()
));
}
for i in 0..n {
validate_dispersion_row_geometry_inputs(
self.kind,
i,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
)?;
}
let mean_spec = &specs[Self::BLOCK_MEAN];
let disp_spec = &specs[Self::BLOCK_DISP];
if mean_spec.design.nrows() != n || disp_spec.design.nrows() != n {
return Err(format!(
"{} joint Hessian directional derivative design row mismatch: y={n}, mean rows={}, precision rows={}",
self.kind.family_tag(),
mean_spec.design.nrows(),
disp_spec.design.nrows()
));
}
let p_mean = mean_spec.design.ncols();
let p_disp = disp_spec.design.ncols();
if d_beta_flat.len() != p_mean + p_disp {
return Err(format!(
"{} joint Hessian directional derivative direction length mismatch: got {}, expected {}",
self.kind.family_tag(),
d_beta_flat.len(),
p_mean + p_disp
));
}
let u_mu = d_beta_flat.slice(s![0..p_mean]).to_owned();
let u_d = d_beta_flat.slice(s![p_mean..p_mean + p_disp]).to_owned();
let du_mu = mean_spec.design.apply(&u_mu);
let du_d = disp_spec.design.apply(&u_d);
let directional: Vec<(f64, f64, f64)> =
if rayon::current_thread_index().is_none() && n > DISPERSION_PARALLEL_ROW_THRESHOLD {
use rayon::iter::{IntoParallelIterator, ParallelIterator};
(0..n)
.into_par_iter()
.map(|i| {
dispersion_row_observed_hessian_directional(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
du_mu[i],
du_d[i],
)
})
.collect()
} else {
(0..n)
.map(|i| {
dispersion_row_observed_hessian_directional(
self.kind,
self.y[i],
eta_mu[i],
eta_d[i],
self.weights[i],
du_mu[i],
du_d[i],
)
})
.collect()
};
for (i, &(d_mm, d_md, d_dd)) in directional.iter().enumerate() {
for (quantity, eta, value) in [
(
"dispersion-family directional mean curvature drift",
eta_mu[i],
d_mm,
),
(
"dispersion-family directional cross curvature drift",
eta_mu[i],
d_md,
),
(
"dispersion-family directional precision curvature drift",
eta_d[i],
d_dd,
),
] {
if !value.is_finite() {
return Err(dispersion_geometry_error(i, quantity, eta, value));
}
}
}
let mean_drift = Array1::from_shape_fn(n, |i| directional[i].0);
let cross_drift = Array1::from_shape_fn(n, |i| directional[i].1);
let disp_drift = Array1::from_shape_fn(n, |i| directional[i].2);
let dh_mean = xt_diag_x_design(&mean_spec.design, &mean_drift)?;
let dh_cross = xt_diag_y_design(&mean_spec.design, &cross_drift, &disp_spec.design)?;
let dh_disp = xt_diag_x_design(&disp_spec.design, &disp_drift)?;
let total = p_mean + p_disp;
let mut dh = Array2::<f64>::zeros((total, total));
dh.slice_mut(s![0..p_mean, 0..p_mean]).assign(&dh_mean);
dh.slice_mut(s![0..p_mean, p_mean..total]).assign(&dh_cross);
dh.slice_mut(s![p_mean..total, p_mean..total])
.assign(&dh_disp);
mirror_upper_to_lower(&mut dh);
Ok(Some(dh))
}
fn likelihood_blocks_uncoupled(&self) -> bool {
false
}
fn has_explicit_joint_hessian(&self) -> bool {
true
}
fn outer_hyper_hessian_dense_available(&self, specs: &[ParameterBlockSpec]) -> bool {
assert!(
crate::custom_family::validate_blockspec_consistency(specs).is_ok(),
"DispersionGlmLocationScale outer hyper-Hessian dense availability: \
inconsistent parameter block specs"
);
specs.len() < 2
}
}
pub struct DispersionGlmLocationScaleTermSpec {
pub kind: DispersionFamilyKind,
pub y: Array1<f64>,
pub weights: Array1<f64>,
pub meanspec: TermCollectionSpec,
pub log_dispspec: TermCollectionSpec,
pub mean_offset: Array1<f64>,
pub log_disp_offset: Array1<f64>,
}
pub(crate) struct DispersionGlmLocationScaleTermBuilder {
pub(crate) kind: DispersionFamilyKind,
pub(crate) y: Array1<f64>,
pub(crate) weights: Array1<f64>,
pub(crate) meanspec: TermCollectionSpec,
pub(crate) noisespec: TermCollectionSpec,
pub(crate) mean_offset: Array1<f64>,
pub(crate) noise_offset: Array1<f64>,
}
pub(crate) fn dispersion_location_scale_warm_start(
kind: DispersionFamilyKind,
y: &Array1<f64>,
weights: &Array1<f64>,
mean_block: &ParameterBlockSpec,
disp_block: &ParameterBlockSpec,
mean_beta_hint: Option<&Array1<f64>>,
disp_beta_hint: Option<&Array1<f64>>,
) -> Result<(Array1<f64>, Array1<f64>), String> {
let ridge_floor = 1e-10;
let mean_beta = if let Some(beta) = mean_beta_hint {
beta.clone()
} else {
let target = Array1::from_shape_fn(y.len(), |i| {
if kind.mean_is_logit() {
let yi = y[i].clamp(1e-3, 1.0 - 1e-3);
(yi / (1.0 - yi)).ln()
} else {
(y[i].max(0.0) + 0.1).ln()
}
});
solve_penalizedweighted_projection(
&mean_block.design,
&mean_block.offset,
&target,
weights,
&mean_block.penalties,
&mean_block.initial_log_lambdas,
ridge_floor,
)?
};
let disp_beta = if let Some(beta) = disp_beta_hint {
beta.clone()
} else {
let mean_eta = mean_block.design.apply(&mean_beta) + &mean_block.offset;
let target = Array1::from_shape_fn(y.len(), |i| {
dispersion_moment_log_precision_seed(kind, y[i], mean_eta[i])
});
solve_penalizedweighted_projection(
&disp_block.design,
&disp_block.offset,
&target,
weights,
&disp_block.penalties,
&disp_block.initial_log_lambdas,
ridge_floor,
)?
};
Ok((mean_beta, disp_beta))
}
#[inline]
fn dispersion_moment_log_precision_seed(kind: DispersionFamilyKind, yi: f64, eta_mu: f64) -> f64 {
const LOG_PRECISION_FLOOR: f64 = -10.0;
const LOG_PRECISION_CEILING: f64 = 10.0;
let em = eta_mu;
let raw = match kind {
DispersionFamilyKind::Beta => {
0.0
}
DispersionFamilyKind::Gamma => {
let mu = em.exp().max(1e-12);
let e2 = (yi - mu).powi(2).max(1e-8 * mu * mu);
(mu * mu / e2).max(1e-6).ln()
}
DispersionFamilyKind::NegativeBinomial => {
let mu = em.exp().max(1e-12);
let e2 = (yi - mu).powi(2);
let excess = (e2 - mu).max(1e-6 * (mu + mu * mu));
(mu * mu / excess).max(1e-6).ln()
}
DispersionFamilyKind::Tweedie { p } => {
let mu = em.exp().max(1e-12);
let e2 = (yi - mu).powi(2).max(1e-8 * mu.powf(p));
(mu.powf(p) / e2).max(1e-6).ln()
}
};
raw.clamp(LOG_PRECISION_FLOOR, LOG_PRECISION_CEILING)
}
impl LocationScaleFamilyBuilder for DispersionGlmLocationScaleTermBuilder {
type Family = DispersionGlmLocationScaleFamily;
fn meanspec(&self) -> &TermCollectionSpec {
&self.meanspec
}
fn noisespec(&self) -> &TermCollectionSpec {
&self.noisespec
}
fn build_blocks(
&self,
theta: &Array1<f64>,
mean_design: &TermCollectionDesign,
noise_design: &TermCollectionDesign,
mean_beta_hint: Option<Array1<f64>>,
noise_beta_hint: Option<Array1<f64>>,
) -> Result<Vec<ParameterBlockSpec>, String> {
let layout = GamlssLambdaLayout::two_block(
mean_design.penalties.len(),
self.noise_penalty_count(noise_design),
);
layout.validate_theta_len(theta.len(), "dispersion location-scale")?;
let mean_offset = mean_design
.compose_offset(self.mean_offset.view(), "dispersion location-scale mean")
.map_err(|error| error.to_string())?;
let noise_offset = noise_design
.compose_offset(
self.noise_offset.view(),
"dispersion location-scale log-precision",
)
.map_err(|error| error.to_string())?;
let mut meanspec = build_location_scale_block(
"mu",
mean_design.design.clone(),
mean_offset,
mean_design.penalties_as_penalty_matrix(),
mean_design.nullspace_dims.clone(),
layout.mean_from(theta),
mean_beta_hint,
0,
LOCATION_SCALE_N_OUTPUTS,
"DispersionLocationScale::build_blocks: mu",
)?;
let disp_penalties = noise_design.penalties_as_penalty_matrix();
let disp_nullspace = noise_design.nullspace_dims.clone();
let mut dispspec = build_location_scale_block(
"log_precision",
noise_design.design.clone(),
noise_offset,
disp_penalties,
disp_nullspace,
layout.noise_from(theta),
noise_beta_hint,
1,
LOCATION_SCALE_N_OUTPUTS,
"DispersionLocationScale::build_blocks: log_precision",
)?;
if meanspec.initial_beta.is_none() || dispspec.initial_beta.is_none() {
let (mean_beta0, disp_beta0) = dispersion_location_scale_warm_start(
self.kind,
&self.y,
&self.weights,
&meanspec,
&dispspec,
meanspec.initial_beta.as_ref(),
dispspec.initial_beta.as_ref(),
)?;
if meanspec.initial_beta.is_none() {
meanspec.initial_beta = Some(mean_beta0);
}
if dispspec.initial_beta.is_none() {
dispspec.initial_beta = Some(disp_beta0);
}
}
Ok(vec![meanspec, dispspec])
}
fn build_family(
&self,
mean_design: &TermCollectionDesign,
noise_design: &TermCollectionDesign,
) -> Self::Family {
assert_eq!(
mean_design.design.nrows(),
self.y.len(),
"DispersionGlmLocationScale::build_family: mean design row count must match y"
);
assert_eq!(
noise_design.design.nrows(),
self.y.len(),
"DispersionGlmLocationScale::build_family: noise design row count must match y"
);
DispersionGlmLocationScaleFamily {
kind: self.kind,
y: self.y.clone(),
weights: self.weights.clone(),
}
}
fn extract_primary_betas(
&self,
fit: &UnifiedFitResult,
) -> Result<(Array1<f64>, Array1<f64>), String> {
let mean_beta = fit
.block_states
.get(DispersionGlmLocationScaleFamily::BLOCK_MEAN)
.ok_or_else(|| "missing dispersion mean block state".to_string())?
.beta
.clone();
let disp_beta = fit
.block_states
.get(DispersionGlmLocationScaleFamily::BLOCK_DISP)
.ok_or_else(|| "missing dispersion log-precision block state".to_string())?
.beta
.clone();
Ok((mean_beta, disp_beta))
}
fn build_psiderivative_blocks(
&self,
data: ndarray::ArrayView2<'_, f64>,
meanspec: &TermCollectionSpec,
noisespec: &TermCollectionSpec,
mean_design: &TermCollectionDesign,
noise_design: &TermCollectionDesign,
) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
Err(format!(
"dispersion location-scale ({:?}) does not implement analytic spatial \
psi derivatives; the κ/ψ joint optimizer must be explicitly disabled before \
this builder is consulted. Called with data {n_rows}×{n_cols}, mean \
spec (linear={mean_lin}, random={mean_re}, smooth={mean_sm}), noise \
spec (linear={noise_lin}, random={noise_re}, smooth={noise_sm}), \
mean design cols={mean_p}, noise design cols={noise_p}",
self.kind,
n_rows = data.nrows(),
n_cols = data.ncols(),
mean_lin = meanspec.linear_terms.len(),
mean_re = meanspec.random_effect_terms.len(),
mean_sm = meanspec.smooth_terms.len(),
noise_lin = noisespec.linear_terms.len(),
noise_re = noisespec.random_effect_terms.len(),
noise_sm = noisespec.smooth_terms.len(),
mean_p = mean_design.design.ncols(),
noise_p = noise_design.design.ncols(),
))
}
}
fn validate_dispersion_family_data(
kind: DispersionFamilyKind,
y: &Array1<f64>,
weights: &Array1<f64>,
) -> Result<(), String> {
if y.len() != weights.len() {
return Err(format!(
"{}: response/weights length mismatch: y={}, weights={}",
kind.family_tag(),
y.len(),
weights.len()
));
}
for (i, &w) in weights.iter().enumerate() {
if !w.is_finite() || w < 0.0 {
return Err(format!(
"{}: prior weights must be finite and non-negative; got weights[{i}] = {w}",
kind.family_tag()
));
}
}
for (i, &yi) in y.iter().enumerate() {
if weights[i] == 0.0 {
continue;
}
let (ok, requirement) = match kind {
DispersionFamilyKind::NegativeBinomial => (
yi.is_finite() && yi >= 0.0 && yi.fract() == 0.0,
"a finite non-negative integer count",
),
DispersionFamilyKind::Gamma => (yi.is_finite() && yi > 0.0, "finite and > 0"),
DispersionFamilyKind::Beta => (
yi.is_finite() && yi > 0.0 && yi < 1.0,
"finite and strictly inside (0, 1)",
),
DispersionFamilyKind::Tweedie { .. } => {
(yi.is_finite() && yi >= 0.0, "finite and >= 0")
}
};
if !ok {
return Err(format!(
"{}: response outside family support at row {i}: y = {yi} (must be {requirement}; \
set the row's prior weight to 0 to exclude it)",
kind.family_tag()
));
}
}
Ok(())
}
fn validate_dispersion_spatial_hyperparameter_request(
kind: DispersionFamilyKind,
meanspec: &TermCollectionSpec,
log_dispspec: &TermCollectionSpec,
kappa_options: &SpatialLengthScaleOptimizationOptions,
) -> Result<(), GamlssError> {
if !kappa_options.enabled {
return Ok(());
}
let unfrozen_terms = |spec: &TermCollectionSpec| -> Vec<usize> {
spatial_length_scale_term_indices(spec)
.into_iter()
.filter(|&idx| {
let scalar_scale_is_locked = get_spatial_length_scale(spec, idx)
.is_some_and(|scale| scale.is_finite() && scale > 0.0)
&& !spatial_term_uses_per_axis_psi(spec, idx);
!scalar_scale_is_locked
})
.collect()
};
let mean_terms = unfrozen_terms(meanspec);
let log_disp_terms = unfrozen_terms(log_dispspec);
if mean_terms.is_empty() && log_disp_terms.is_empty() {
return Ok(());
}
let term_names = |spec: &TermCollectionSpec, indices: &[usize]| -> Vec<String> {
indices
.iter()
.filter_map(|&idx| spec.smooth_terms.get(idx).map(|term| term.name.clone()))
.collect()
};
Err(GamlssError::UnsupportedConfiguration {
reason: format!(
"dispersion location-scale ({kind:?}) cannot optimize spatial hyperparameters: \
exact coupled D_beta H and D_beta H_psi derivatives are unavailable for \
unfrozen spatial terms (mean={:?}, log_precision={:?}). Supply locked spatial \
geometry or explicitly set spatial length-scale optimization enabled=false; the \
fitter will not silently freeze a requested spatial optimization",
term_names(meanspec, &mean_terms),
term_names(log_dispspec, &log_disp_terms),
),
})
}
pub fn fit_dispersion_glm_location_scale_terms(
data: ndarray::ArrayView2<'_, f64>,
spec: DispersionGlmLocationScaleTermSpec,
options: &BlockwiseFitOptions,
kappa_options: &SpatialLengthScaleOptimizationOptions,
) -> Result<BlockwiseTermFitResult, String> {
if let DispersionFamilyKind::Tweedie { p } = spec.kind {
if !(p.is_finite() && p > 1.0 && p < 2.0) {
return Err(format!(
"Tweedie location-scale requires a variance power strictly in (1, 2); got p={p}"
));
}
}
validate_dispersion_family_data(spec.kind, &spec.y, &spec.weights)?;
validate_dispersion_spatial_hyperparameter_request(
spec.kind,
&spec.meanspec,
&spec.log_dispspec,
kappa_options,
)?;
let mut options = options.clone();
options.compute_covariance = true;
fit_location_scale_terms(
data,
DispersionGlmLocationScaleTermBuilder {
kind: spec.kind,
y: spec.y,
weights: spec.weights,
meanspec: spec.meanspec,
noisespec: spec.log_dispspec,
mean_offset: spec.mean_offset,
noise_offset: spec.log_disp_offset,
},
&options,
kappa_options,
)
}
#[cfg(test)]
mod tests {
use super::test_support::{dispersion_gamma_nll_order2, dispersion_nb_nll_order2};
use super::*;
use crate::gamlss::test_support::dispersion_tweedie_nll_generic;
use gam_math::nested_dual::JetField;
#[test]
fn saved_alo_gamma_row_geometry_matches_closed_form_and_keeps_meat_distinct() {
let y = 4.0;
let mu: f64 = 2.0;
let nu: f64 = 3.0;
let weight = 1.7;
let geometry = dispersion_alo_row_geometry(
DispersionFamilyKind::Gamma,
0,
y,
mu.ln(),
nu.ln(),
weight,
)
.expect("Gamma row geometry must be representable");
let ratio = y / mu;
let a = gam_math::jet_tower::digamma(nu) - nu.ln() - 1.0 + mu.ln() - y.ln() + ratio;
let expected_score = [weight * nu * (1.0 - ratio), weight * nu * a];
let expected_hessian = [
[weight * nu * ratio, weight * nu * (1.0 - ratio)],
[
weight * nu * (1.0 - ratio),
weight * nu * (a + nu * gam_math::jet_tower::trigamma(nu) - 1.0),
],
];
for coordinate in 0..2 {
assert_close(
"Gamma ALO score",
geometry.nll_score[coordinate],
expected_score[coordinate],
2e-12,
);
for other in 0..2 {
assert_close(
"Gamma ALO observed Hessian",
geometry.observed_hessian[coordinate][other],
expected_hessian[coordinate][other],
2e-12,
);
}
}
let score_meat = [
[
expected_score[0] * expected_score[0],
expected_score[0] * expected_score[1],
],
[
expected_score[1] * expected_score[0],
expected_score[1] * expected_score[1],
],
];
assert_ne!(
geometry.observed_hessian, score_meat,
"the deletion curvature must not be replaced by score covariance"
);
}
#[inline]
fn order1_ln_gamma<const K: usize>(
x: &gam_math::jet_scalar::Order1<K>,
) -> gam_math::jet_scalar::Order1<K> {
x.compose_unary([
ln_gamma(x.v),
gam_math::jet_tower::digamma(x.v),
0.0,
0.0,
0.0,
])
}
#[inline]
fn dispersion_nb_disp_order1(
yi: f64,
mu_value: f64,
theta_value: f64,
wi: f64,
) -> gam_math::jet_scalar::Order1<1> {
type O1 = gam_math::jet_scalar::Order1<1>;
let mu = O1::constant(mu_value);
let theta = O1::variable(theta_value, 0);
let tpm = theta.add(&mu);
let theta_plus_y = theta.add(&O1::constant(yi));
let loglik = order1_ln_gamma(&theta_plus_y)
.sub(&order1_ln_gamma(&theta))
.sub(&O1::constant(ln_gamma(yi + 1.0)))
.add(&theta.mul(&theta.ln()))
.sub(&theta.mul(&tpm.ln()))
.add(&mu.ln().scale(yi))
.sub(&tpm.ln().scale(yi));
loglik.scale(-wi)
}
#[inline]
fn dispersion_nb_disp_order2(
yi: f64,
mu_value: f64,
theta_value: f64,
wi: f64,
) -> gam_math::jet_scalar::Order2<1> {
use gam_math::jet_scalar::JetScalar;
use statrs::function::gamma::ln_gamma;
type O1 = gam_math::jet_scalar::Order2<1>;
let mu = O1::constant(mu_value);
let theta = O1::variable(theta_value, 0);
let tpm = theta.add(&mu);
let theta_plus_y = theta.add(&O1::constant(yi));
let loglik = order2_ln_gamma(&theta_plus_y)
.sub(&order2_ln_gamma(&theta))
.sub(&O1::constant(ln_gamma(yi + 1.0)))
.add(&theta.mul(&theta.ln()))
.sub(&theta.mul(&tpm.ln()))
.add(&mu.ln().scale(yi))
.sub(&tpm.ln().scale(yi));
loglik.scale(-wi)
}
pub(crate) fn beta_fisher_cross_info_mu_phi(mu: f64, phi: f64) -> f64 {
let a = mu * phi;
let b = (1.0 - mu) * phi;
phi * (mu * gam_math::jet_tower::trigamma_derivative_stack(a)[0]
- (1.0 - mu) * gam_math::jet_tower::trigamma_derivative_stack(b)[0])
}
pub(crate) fn assert_close(label: &str, got: f64, want: f64, tol: f64) {
assert!(
(got - want).abs() <= tol,
"{label}: got {got:.12e}, want {want:.12e}, |diff|={:.3e}",
(got - want).abs()
);
}
#[test]
fn spatial_hyperparameter_request_is_a_typed_error_until_explicitly_frozen() {
let locked_meanspec = crate::gamlss::tests::simple_matern_term_collection(&[0, 1], 0.6);
let mut meanspec = locked_meanspec.clone();
let gam_terms::smooth::SmoothBasisSpec::Matern { spec, .. } =
&mut meanspec.smooth_terms[0].basis
else {
panic!("test fixture must contain a Matérn term");
};
spec.aniso_log_scales = Some(vec![0.0, 0.0]);
let log_dispspec = crate::gamlss::tests::empty_term_collection();
let enabled = SpatialLengthScaleOptimizationOptions::default();
let error = validate_dispersion_spatial_hyperparameter_request(
DispersionFamilyKind::Gamma,
&meanspec,
&log_dispspec,
&enabled,
)
.expect_err("enabled dispersion spatial optimization must be rejected");
assert!(matches!(
error,
GamlssError::UnsupportedConfiguration { .. }
));
let n = 8;
let public_error = match fit_dispersion_glm_location_scale_terms(
Array2::zeros((n, 2)).view(),
DispersionGlmLocationScaleTermSpec {
kind: DispersionFamilyKind::Gamma,
y: Array1::from_elem(n, 1.0),
weights: Array1::from_elem(n, 1.0),
meanspec: meanspec.clone(),
log_dispspec: log_dispspec.clone(),
mean_offset: Array1::zeros(n),
log_disp_offset: Array1::zeros(n),
},
&BlockwiseFitOptions::default(),
&enabled,
) {
Ok(_) => panic!("public fit must not silently freeze spatial optimization"),
Err(error) => error,
};
assert!(public_error.contains("will not silently freeze"));
validate_dispersion_spatial_hyperparameter_request(
DispersionFamilyKind::Gamma,
&locked_meanspec,
&log_dispspec,
&enabled,
)
.expect("a caller-supplied locked spatial scale is explicit frozen geometry");
let auto_meanspec = crate::gamlss::tests::simple_matern_term_collection(&[0, 1], 0.0);
assert!(matches!(
validate_dispersion_spatial_hyperparameter_request(
DispersionFamilyKind::Gamma,
&auto_meanspec,
&log_dispspec,
&enabled,
),
Err(GamlssError::UnsupportedConfiguration { .. })
));
let mut frozen = enabled;
frozen.enabled = false;
validate_dispersion_spatial_hyperparameter_request(
DispersionFamilyKind::Gamma,
&meanspec,
&log_dispspec,
&frozen,
)
.expect("an explicit frozen-geometry request is supported");
validate_dispersion_spatial_hyperparameter_request(
DispersionFamilyKind::Gamma,
&log_dispspec,
&log_dispspec,
&SpatialLengthScaleOptimizationOptions::default(),
)
.expect("enabled spatial optimization is irrelevant without spatial coordinates");
}
#[test]
pub(crate) fn beta_tower_mixed_channel_matches_cross_information_formula() {
let mu = 0.1;
let phi = 10.0;
let a = mu * phi;
let b = (1.0 - mu) * phi;
let digamma_a = gam_math::jet_tower::digamma_derivative_stack(a)[0];
let digamma_b = gam_math::jet_tower::digamma_derivative_stack(b)[0];
let score_neutral_y = 1.0 / (1.0 + (-(digamma_a - digamma_b)).exp());
let tower = dispersion_beta_nll_order2(score_neutral_y, mu, phi, 1.0);
let trigamma_a = std::f64::consts::PI * std::f64::consts::PI / 6.0;
let trigamma_b = gam_math::jet_tower::trigamma_derivative_stack(b)[0];
let analytic = phi * (mu * trigamma_a - (1.0 - mu) * trigamma_b);
let helper = beta_fisher_cross_info_mu_phi(mu, phi);
assert!(
analytic > 0.58,
"audit example should have visibly nonzero cross information, got {analytic}"
);
assert_close("helper cross information", helper, analytic, 1e-12);
assert_close("tower mixed channel", tower.h()[0][1], analytic, 1e-8);
let q = mu * (1.0 - mu);
let em = (mu / (1.0 - mu)).ln();
let ed = phi.ln();
let eta_tower =
dispersion_eta_nll_order2(DispersionFamilyKind::Beta, score_neutral_y, em, ed, 1.0);
assert_close(
"eta-scale observed cross curvature",
eta_tower.h()[0][1],
q * phi * analytic,
1e-8,
);
}
#[test]
pub(crate) fn order2_matches_dense_tower_all_channels() {
use gam_math::jet_scalar::Order2;
use gam_math::jet_tower::Tower4;
fn check_o2_vs_tower4(label: &str, o2: Order2<2>, t4: Tower4<2>) {
let band = |a: f64, b: f64| 1e-9 + 1e-9 * a.abs().max(b.abs());
assert!(
(o2.value() - t4.v).abs() <= band(o2.value(), t4.v),
"{label} value: {} vs {}",
o2.value(),
t4.v
);
for a in 0..2 {
assert!(
(o2.g()[a] - t4.g[a]).abs() <= band(o2.g()[a], t4.g[a]),
"{label} grad[{a}]: {} vs {}",
o2.g()[a],
t4.g[a]
);
for b in 0..2 {
assert!(
(o2.h()[a][b] - t4.h[a][b]).abs() <= band(o2.h()[a][b], t4.h[a][b]),
"{label} hess[{a}][{b}]: {} vs {}",
o2.h()[a][b],
t4.h[a][b]
);
}
}
}
let wi = 1.7_f64;
for &(yi, mu, theta) in &[(0.0, 1.2, 3.0), (4.0, 2.5, 0.7), (10.0, 0.6, 5.0)] {
check_o2_vs_tower4(
"nb",
dispersion_nb_nll_order2(yi, mu, theta, wi),
test_support::dispersion_nb_nll_generic::<Tower4<2>>(yi, mu, theta, wi),
);
}
for &(yi, mu, nu) in &[
(0.5_f64, 1.1_f64, 2.0_f64),
(3.0, 4.0, 0.9),
(1.0, 0.3, 6.0),
] {
let y_pos = yi.max(1e-300);
check_o2_vs_tower4(
"gamma",
dispersion_gamma_nll_order2(yi, y_pos, mu, nu, wi),
test_support::dispersion_gamma_nll_generic::<Tower4<2>>(yi, y_pos, mu, nu, wi),
);
}
for &(yi, mu, phi) in &[(0.3, 0.4, 5.0), (0.9, 0.6, 12.0), (0.01, 0.2, 3.0)] {
check_o2_vs_tower4(
"beta",
dispersion_beta_nll_order2(yi, mu, phi, wi),
test_support::dispersion_beta_nll_generic::<Tower4<2>>(yi, mu, phi, wi),
);
}
for &(yi, eta_mu, eta_d, p) in &[
(0.0, 0.4, -0.3, 1.5),
(2.5, -0.2, 0.5, 1.3),
(0.0, 1.0, 0.1, 1.7),
(5.0, 0.7, -0.6, 1.6),
] {
check_o2_vs_tower4(
"tweedie",
dispersion_tweedie_nll_generic::<Order2<2>>(yi, eta_mu, eta_d, p, wi),
dispersion_tweedie_nll_generic::<Tower4<2>>(yi, eta_mu, eta_d, p, wi),
);
}
}
#[test]
pub(crate) fn pruned_disp_towers_bit_identical_to_full_order2() {
use gam_math::jet_scalar::Order2;
let mut state: u64 = 0x9E3779B97F4A7C15;
let mut next = || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 11) as f64) / ((1u64 << 53) as f64)
};
let bits = |x: f64| x.to_bits();
let n_per = 600; for _ in 0..n_per {
let wi = 0.25 + 3.0 * next();
let yi_count = (next() * 12.0).floor();
{
let mu = (0.05 + 4.0 * next()).max(1e-300);
let theta = (0.05 + 6.0 * next()).max(1e-12);
let full = dispersion_nb_nll_order2(yi_count, mu, theta, wi);
let prn = dispersion_nb_disp_order2(yi_count, mu, theta, wi);
assert_eq!(bits(full.value()), bits(prn.value()), "nb value");
assert_eq!(bits(full.g()[1]), bits(prn.g()[0]), "nb grad");
assert_eq!(bits(full.h()[1][1]), bits(prn.h()[0][0]), "nb hess");
let prn1 = dispersion_nb_disp_order1(yi_count, mu, theta, wi);
assert_eq!(bits(prn.value()), bits(prn1.value()), "nb order1 value");
assert_eq!(bits(prn.g()[0]), bits(prn1.g()[0]), "nb order1 grad");
assert_close(
"nb stable value-only",
dispersion_nb_loglik(yi_count, mu, theta, wi),
-prn.value(),
1e-12,
);
}
{
let mu = (0.05 + 4.0 * next()).max(1e-300);
let nu = (0.05 + 6.0 * next()).max(1e-12);
let yi = 0.01 + 8.0 * next();
let y_pos = yi.max(1e-300);
let full = dispersion_gamma_nll_order2(yi, y_pos, mu, nu, wi);
let prn = dispersion_gamma_disp_order2(yi, y_pos, mu, nu, wi);
assert_eq!(bits(full.value()), bits(prn.value()), "gamma value");
assert_eq!(bits(full.g()[1]), bits(prn.g()[0]), "gamma grad");
assert_eq!(bits(full.h()[1][1]), bits(prn.h()[0][0]), "gamma hess");
assert_eq!(
bits(dispersion_gamma_loglik(yi, y_pos, mu, nu, wi)),
bits(-prn.value()),
"gamma value-only"
);
}
{
let mu = (1e-6 + (1.0 - 2e-6) * next()).clamp(1e-12, 1.0 - 1e-12);
let phi = (0.05 + 20.0 * next()).max(1e-12);
let yi = next();
let full = dispersion_beta_nll_order2(yi, mu, phi, wi);
assert_eq!(
bits(dispersion_beta_loglik(yi, mu, phi, wi)),
bits(-full.value()),
"beta value-only"
);
}
for &(yi, eta_mu, eta_d, p) in &[
(
0.0_f64,
-4.0 + 8.0 * next(),
-4.0 + 8.0 * next(),
1.1 + 0.8 * next(),
),
(
0.01 + 9.0 * next(),
-4.0 + 8.0 * next(),
-4.0 + 8.0 * next(),
1.1 + 0.8 * next(),
),
(3.0, -8.0, 8.0, 1.5),
] {
let em = eta_mu;
let ed = eta_d;
let full = dispersion_tweedie_nll_generic::<Order2<2>>(yi, em, ed, p, wi);
let prn = dispersion_tweedie_disp_order2(yi, em, ed, p, wi);
assert_eq!(bits(full.value()), bits(prn.value()), "tweedie value");
assert_eq!(bits(full.g()[1]), bits(prn.g()[0]), "tweedie grad");
assert_eq!(bits(full.h()[1][1]), bits(prn.h()[0][0]), "tweedie hess");
assert_eq!(
bits(dispersion_tweedie_loglik(yi, em, ed, p, wi)),
bits(-prn.value()),
"tweedie value-only"
);
}
}
}
#[test]
pub(crate) fn observed_eta_hessian_matches_gamma_closed_form() {
let (yi, mu, nu): (f64, f64, f64) = (4.0, 2.0, 3.0);
let (h_mm, h_md, h_dd) = dispersion_row_observed_hessian_weights(
DispersionFamilyKind::Gamma,
yi,
mu.ln(),
nu.ln(),
1.0,
);
assert_close("gamma observed d2/d_eta_mu2", h_mm, nu * yi / mu, 1e-10);
assert_close(
"gamma observed cross d2/d_eta_mu d_eta_nu",
h_md,
nu * (1.0 - yi / mu),
1e-10,
);
let nll =
|ed: f64| -dispersion_row_loglik(DispersionFamilyKind::Gamma, yi, mu.ln(), ed, 1.0);
let h = 1e-5;
let ed0 = nu.ln();
let fd = (nll(ed0 + h) - 2.0 * nll(ed0) + nll(ed0 - h)) / (h * h);
assert_close("gamma observed d2/d_eta_nu2 (FD)", h_dd, fd, 1e-4);
}
#[test]
pub(crate) fn observed_eta_hessian_is_exact_beyond_former_clamp() {
let (h_mm, h_md, h_dd) = dispersion_row_observed_hessian_weights(
DispersionFamilyKind::Gamma,
4.0,
35.0,
0.5,
1.0,
);
assert!(h_mm > 0.0);
assert!(h_md.is_finite());
assert!(h_dd != 0.0);
let kernel = dispersion_row_kernel(DispersionFamilyKind::Gamma, 4.0, 35.0, 0.5, 1.0);
assert!(kernel.mean_weight > 0.0);
assert_ne!(kernel.mean_response, 35.0);
assert!(kernel.disp_weight > 0.0);
}
#[test]
fn negative_binomial_balanced_ratios_and_precision_information_keep_tail_geometry() {
let huge = 1.0e200_f64;
let kernel = dispersion_row_kernel(
DispersionFamilyKind::NegativeBinomial,
1.0,
huge.ln(),
huge.ln(),
1.0,
);
assert!(kernel.loglik.is_finite());
assert!(kernel.mean_weight.is_finite());
let precision = huge.ln().exp();
assert!((kernel.mean_weight / precision - 0.5).abs() <= 8.0 * f64::EPSILON);
assert!(kernel.disp_weight.is_finite() && kernel.disp_weight > 0.0);
let eta_info = nb_log_precision_fisher_jensen(1.0, 1.0e17);
assert!(eta_info.is_finite() && eta_info > 0.0);
assert!((eta_info * 1.0e17 - 1.0).abs() < 1.0e-12);
let log_share = log_positive_share((-700.0_f64).exp(), 700.0_f64.exp());
assert!(log_share.is_finite());
assert!((log_share + 1400.0).abs() < 1.0e-12);
}
#[test]
pub(crate) fn parallel_evaluate_matches_serial_reference() {
let n = DISPERSION_PARALLEL_ROW_THRESHOLD * 3 + 7;
let mut state: u64 = 0xD1B5_4A32_D192_ED03;
let mut next = || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 11) as f64) / ((1u64 << 53) as f64)
};
for kind in [
DispersionFamilyKind::NegativeBinomial,
DispersionFamilyKind::Gamma,
DispersionFamilyKind::Beta,
DispersionFamilyKind::Tweedie { p: 1.5 },
] {
let y = Array1::from_shape_fn(n, |_| match kind {
DispersionFamilyKind::Beta => 1e-3 + (1.0 - 2e-3) * next(),
DispersionFamilyKind::NegativeBinomial => (next() * 12.0).floor(),
_ => 0.05 + 8.0 * next(),
});
let weights = Array1::from_shape_fn(n, |_| 0.25 + 2.0 * next());
let eta_mu = Array1::from_shape_fn(n, |_| -1.0 + 2.0 * next());
let eta_d = Array1::from_shape_fn(n, |_| -1.0 + 2.0 * next());
let family = DispersionGlmLocationScaleFamily {
kind,
y: y.clone(),
weights: weights.clone(),
};
let states = vec![
ParameterBlockState {
beta: Array1::zeros(0),
eta: eta_mu.clone(),
},
ParameterBlockState {
beta: Array1::zeros(0),
eta: eta_d.clone(),
},
];
let mut ll_ref = 0.0;
let mut mw_ref = Array1::<f64>::zeros(n);
let mut mr_ref = Array1::<f64>::zeros(n);
let mut dw_ref = Array1::<f64>::zeros(n);
let mut dr_ref = Array1::<f64>::zeros(n);
for i in 0..n {
let row = dispersion_row_kernel(kind, y[i], eta_mu[i], eta_d[i], weights[i]);
ll_ref += row.loglik;
mw_ref[i] = row.mean_weight;
mr_ref[i] = row.mean_response;
dw_ref[i] = row.disp_weight;
dr_ref[i] = row.disp_response;
}
let eval = family.evaluate(&states).expect("parallel evaluate");
assert_close(
&format!("{kind:?} evaluate log-likelihood"),
eval.log_likelihood,
ll_ref,
1e-9,
);
let BlockWorkingSet::Diagonal {
working_response: mr,
working_weights: mw,
} = &eval.blockworking_sets[0]
else {
panic!("mean block not diagonal");
};
let BlockWorkingSet::Diagonal {
working_response: dr,
working_weights: dw,
} = &eval.blockworking_sets[1]
else {
panic!("dispersion block not diagonal");
};
for i in 0..n {
assert_close("mean weight", mw[i], mw_ref[i], 1e-9);
assert_close("mean response", mr[i], mr_ref[i], 1e-9);
assert_close("disp weight", dw[i], dw_ref[i], 1e-9);
assert_close("disp response", dr[i], dr_ref[i], 1e-9);
}
let ll_only = family
.log_likelihood_only(&states)
.expect("parallel log_likelihood_only");
assert_close(
&format!("{kind:?} log_likelihood_only"),
ll_only,
ll_ref,
1e-9,
);
}
}
}