Skip to content

Diagnostics Component

The diagnostics module provides comprehensive model assessment tools including residuals, calibration metrics, discrimination measures, and interaction detection.

Code Location

crates/rustystats-core/src/diagnostics/
├── mod.rs           # Re-exports
├── residuals.rs     # Residual computations
├── dispersion.rs    # Dispersion estimation
├── likelihood.rs    # Log-likelihood, AIC, BIC
├── calibration.rs   # A/E ratios, calibration curves
├── discrimination.rs # Gini, AUC, lift
├── loss.rs          # Loss functions (MSE, MAE)
├── interactions.rs  # Interaction detection
└── factor_analysis.rs # Per-factor diagnostics

python/rustystats/diagnostics.py  # Python API

Residuals

Types of Residuals

/// Response residuals: y - μ
pub fn resid_response(
    y: &Array1<f64>,
    mu: &Array1<f64>,
) -> Array1<f64> {
    y - mu
}

/// Pearson residuals: (y - μ) / √V(μ)
pub fn resid_pearson(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    family: &dyn Family,
) -> Array1<f64> {
    let var = family.variance(mu);
    (y - mu) / var.mapv(|v| v.sqrt())
}

/// Deviance residuals: sign(y - μ) × √d
pub fn resid_deviance(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    family: &dyn Family,
) -> Array1<f64> {
    let unit_dev = family.unit_deviance(y, mu);
    Zip::from(y).and(mu).and(&unit_dev)
        .map_collect(|&yi, &mui, &di| {
            let sign = if yi > mui { 1.0 } else { -1.0 };
            sign * di.sqrt()
        })
}

/// Working residuals: (y - μ) × g'(μ)
pub fn resid_working(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    link: &dyn Link,
) -> Array1<f64> {
    let deriv = link.derivative(mu);
    (y - mu) * deriv
}

When to Use Each

Residual Use Case
Response Simple interpretation
Pearson Detecting outliers, checking overdispersion
Deviance Most diagnostic plots, Q-Q plots
Working Partial residual plots

Dispersion Estimation

/// Pearson-based dispersion estimate
pub fn estimate_dispersion_pearson(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    family: &dyn Family,
    df_resid: usize,
    weights: Option<&Array1<f64>>,
) -> f64 {
    let chi2 = pearson_chi2(y, mu, family, weights);
    chi2 / df_resid as f64
}

/// Pearson chi-squared statistic
pub fn pearson_chi2(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    family: &dyn Family,
    weights: Option<&Array1<f64>>,
) -> f64 {
    let var = family.variance(mu);

    let mut chi2 = 0.0;
    for i in 0..y.len() {
        let r = (y[i] - mu[i]).powi(2) / var[i];
        let w = weights.map_or(1.0, |w| w[i]);
        chi2 += w * r;
    }

    chi2
}

Likelihood and Information Criteria

/// Log-likelihood for Poisson
pub fn log_likelihood_poisson(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    weights: Option<&Array1<f64>>,
) -> f64 {
    let mut ll = 0.0;
    for i in 0..y.len() {
        let w = weights.map_or(1.0, |w| w[i]);
        // log P(Y=y) = y*log(μ) - μ - log(y!)
        let log_factorial = (1..=(y[i] as usize))
            .map(|k| (k as f64).ln())
            .sum::<f64>();
        ll += w * (y[i] * mu[i].ln() - mu[i] - log_factorial);
    }
    ll
}

/// AIC = -2*loglik + 2*p
pub fn aic(log_likelihood: f64, n_params: usize) -> f64 {
    -2.0 * log_likelihood + 2.0 * n_params as f64
}

/// BIC = -2*loglik + p*log(n)
pub fn bic(log_likelihood: f64, n_params: usize, n_obs: usize) -> f64 {
    -2.0 * log_likelihood + (n_params as f64) * (n_obs as f64).ln()
}

/// Null deviance (intercept-only model)
pub fn null_deviance(
    y: &Array1<f64>,
    family: &dyn Family,
    weights: Option<&Array1<f64>>,
) -> f64 {
    // Fit intercept-only model: μ = weighted mean of y
    let mu_null = match weights {
        Some(w) => {
            let sum_wy: f64 = y.iter().zip(w.iter()).map(|(y, w)| y * w).sum();
            let sum_w: f64 = w.sum();
            sum_wy / sum_w
        }
        None => y.mean().unwrap(),
    };

    let mu_null_vec = Array1::from_elem(y.len(), mu_null);
    family.deviance(y, &mu_null_vec, weights)
}

Calibration Metrics

Actual vs Expected

pub struct CalibrationResult {
    pub overall_ae: f64,
    pub by_decile: Vec<DecileStats>,
    pub hosmer_lemeshow: Option<HLTest>,
}

pub fn compute_calibration_curve(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    weights: Option<&Array1<f64>>,
    n_bins: usize,
) -> CalibrationResult {
    let n = y.len();

    // Overall A/E
    let total_actual: f64 = match weights {
        Some(w) => y.iter().zip(w.iter()).map(|(y, w)| y * w).sum(),
        None => y.sum(),
    };
    let total_expected: f64 = match weights {
        Some(w) => mu.iter().zip(w.iter()).map(|(m, w)| m * w).sum(),
        None => mu.sum(),
    };
    let overall_ae = total_actual / total_expected;

    // Sort by predicted risk
    let mut indices: Vec<usize> = (0..n).collect();
    indices.sort_by(|&a, &b| mu[a].partial_cmp(&mu[b]).unwrap());

    // Compute A/E by decile
    let bin_size = n / n_bins;
    let mut by_decile = Vec::with_capacity(n_bins);

    for bin in 0..n_bins {
        let start = bin * bin_size;
        let end = if bin == n_bins - 1 { n } else { (bin + 1) * bin_size };

        let mut actual = 0.0;
        let mut expected = 0.0;
        let mut exposure = 0.0;

        for &i in &indices[start..end] {
            let w = weights.map_or(1.0, |w| w[i]);
            actual += y[i] * w;
            expected += mu[i] * w;
            exposure += w;
        }

        by_decile.push(DecileStats {
            decile: bin + 1,
            actual,
            expected,
            ae_ratio: actual / expected,
            exposure,
        });
    }

    CalibrationResult { overall_ae, by_decile, hosmer_lemeshow: None }
}

Discrimination Metrics

Gini Coefficient

pub fn compute_gini(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    weights: Option<&Array1<f64>>,
) -> f64 {
    // Gini = 2 * AUC - 1
    let auc = compute_auc(y, mu, weights);
    2.0 * auc - 1.0
}

pub fn compute_auc(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    weights: Option<&Array1<f64>>,
) -> f64 {
    let n = y.len();

    // Sort by predicted risk (descending)
    let mut indices: Vec<usize> = (0..n).collect();
    indices.sort_by(|&a, &b| mu[b].partial_cmp(&mu[a]).unwrap());

    // Compute AUC using trapezoidal rule
    let mut cum_actual = 0.0;
    let mut cum_exposure = 0.0;
    let total_actual: f64 = y.sum();
    let total_exposure: f64 = weights.map_or(n as f64, |w| w.sum());

    let mut auc = 0.0;
    let mut prev_x = 0.0;
    let mut prev_y = 0.0;

    for &i in &indices {
        let w = weights.map_or(1.0, |w| w[i]);
        cum_actual += y[i];
        cum_exposure += w;

        let x = cum_exposure / total_exposure;
        let y_val = cum_actual / total_actual;

        // Trapezoidal area
        auc += (x - prev_x) * (y_val + prev_y) / 2.0;

        prev_x = x;
        prev_y = y_val;
    }

    auc
}

Lorenz Curve

pub fn compute_lorenz_curve(
    y: &Array1<f64>,
    mu: &Array1<f64>,
    weights: Option<&Array1<f64>>,
    n_points: usize,
) -> Vec<(f64, f64)> {
    let n = y.len();

    // Sort by predicted risk
    let mut indices: Vec<usize> = (0..n).collect();
    indices.sort_by(|&a, &b| mu[a].partial_cmp(&mu[b]).unwrap());

    let total_actual: f64 = y.sum();
    let total_exposure: f64 = weights.map_or(n as f64, |w| w.sum());

    let mut curve = Vec::with_capacity(n_points + 1);
    curve.push((0.0, 0.0));

    let step = n / n_points;
    let mut cum_actual = 0.0;
    let mut cum_exposure = 0.0;

    for (idx, &i) in indices.iter().enumerate() {
        let w = weights.map_or(1.0, |w| w[i]);
        cum_actual += y[i];
        cum_exposure += w;

        if (idx + 1) % step == 0 || idx == n - 1 {
            curve.push((
                cum_exposure / total_exposure,
                cum_actual / total_actual,
            ));
        }
    }

    curve
}

Interaction Detection

Greedy residual-based detection of potential interactions:

pub struct InteractionConfig {
    pub max_candidates: usize,
    pub min_strength: f64,
}

pub fn detect_interactions(
    residuals: &Array1<f64>,
    factors: &[FactorData],
    config: &InteractionConfig,
) -> Vec<InteractionCandidate> {
    let n_factors = factors.len();
    let mut candidates = Vec::new();

    // Test all pairs
    for i in 0..n_factors {
        for j in (i + 1)..n_factors {
            let strength = compute_interaction_strength(
                residuals,
                &factors[i],
                &factors[j],
            );

            if strength > config.min_strength {
                candidates.push(InteractionCandidate {
                    factor1: factors[i].name.clone(),
                    factor2: factors[j].name.clone(),
                    strength,
                });
            }
        }
    }

    // Sort by strength
    candidates.sort_by(|a, b| b.strength.partial_cmp(&a.strength).unwrap());
    candidates.truncate(config.max_candidates);

    candidates
}

fn compute_interaction_strength(
    residuals: &Array1<f64>,
    factor1: &FactorData,
    factor2: &FactorData,
) -> f64 {
    // Compute variance of residuals explained by interaction
    // Uses grouping by factor combinations

    // ... implementation details

    explained_variance / total_variance
}

Python API: ModelDiagnostics

class ModelDiagnostics:
    """Comprehensive model diagnostics with JSON export."""

    def __init__(self, result, data, categorical_factors, continuous_factors):
        self.model_summary = self._compute_model_summary(result)
        self.fit_statistics = self._compute_fit_stats(result)
        self.calibration = self._compute_calibration(result, data)
        self.discrimination = self._compute_discrimination(result, data)
        self.factors = self._compute_factor_diagnostics(
            result, data, categorical_factors, continuous_factors
        )
        self.interaction_candidates = self._detect_interactions(result, data)
        self.warnings = self._generate_warnings()

    def to_json(self) -> str:
        """Export as compact JSON for LLM consumption."""
        return json.dumps({
            'model_summary': self.model_summary,
            'fit_statistics': self.fit_statistics,
            'calibration': self.calibration,
            'discrimination': self.discrimination,
            'factors': [f.to_dict() for f in self.factors],
            'interaction_candidates': self.interaction_candidates,
            'warnings': self.warnings,
        }, indent=2)

Usage

result = rs.glm("y ~ x1 + C(region)", data, family="poisson").fit()

diagnostics = result.diagnostics(
    data=data,
    categorical_factors=["region", "brand"],
    continuous_factors=["age", "income"],
)

# View summary
print(diagnostics.to_json())

# Check specific metrics
print(f"Gini: {diagnostics.discrimination['gini_coefficient']:.3f}")
print(f"A/E: {diagnostics.calibration['overall_ae']:.3f}")

# Check for issues
for warning in diagnostics.warnings:
    print(f"⚠️ {warning['message']}")

Warning Generation

pub fn generate_warnings(diagnostics: &Diagnostics) -> Vec<Warning> {
    let mut warnings = Vec::new();

    // Check dispersion
    if diagnostics.dispersion_pearson > 2.0 {
        warnings.push(Warning {
            warning_type: "overdispersion".into(),
            message: format!(
                "High dispersion ({:.2}). Consider QuasiPoisson or NegBinomial.",
                diagnostics.dispersion_pearson
            ),
        });
    }

    // Check calibration
    if (diagnostics.overall_ae - 1.0).abs() > 0.05 {
        warnings.push(Warning {
            warning_type: "calibration".into(),
            message: format!(
                "A/E ratio is {:.3}. Model {} overall.",
                diagnostics.overall_ae,
                if diagnostics.overall_ae > 1.0 { "underpredicts" } else { "overpredicts" }
            ),
        });
    }

    // Check for non-fitted factors with signal
    for factor in &diagnostics.factors {
        if !factor.in_model && factor.residual_correlation.abs() > 0.05 {
            warnings.push(Warning {
                warning_type: "missing_factor".into(),
                message: format!(
                    "Factor '{}' not in model but explains {:.1}% of residual variance.",
                    factor.name,
                    factor.residual_correlation.abs() * 100.0
                ),
            });
        }
    }

    warnings
}

Testing

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_gini_perfect_model() {
        // Perfect predictions should give Gini = 1
        let y = array![0.0, 0.0, 1.0, 1.0];
        let mu = array![0.1, 0.2, 0.8, 0.9];  // Perfect ranking

        let gini = compute_gini(&y, &mu, None);
        assert!((gini - 1.0).abs() < 0.01);
    }

    #[test]
    fn test_gini_random_model() {
        // Random predictions should give Gini ≈ 0
        let y = array![0.0, 1.0, 0.0, 1.0];
        let mu = array![0.5, 0.5, 0.5, 0.5];  // No discrimination

        let gini = compute_gini(&y, &mu, None);
        assert!(gini.abs() < 0.01);
    }

    #[test]
    fn test_ae_ratio() {
        let y = array![1.0, 2.0, 3.0];
        let mu = array![1.0, 2.0, 3.0];  // Perfect predictions

        let cal = compute_calibration_curve(&y, &mu, None, 3);
        assert!((cal.overall_ae - 1.0).abs() < 1e-10);
    }
}