apex_solver/core/corrector.rs
1//! Corrector algorithm for applying robust loss functions in optimization.
2//!
3//! The Corrector implements the algorithm from Ceres Solver for transforming a robust loss
4//! problem into an equivalent reweighted least squares problem. Instead of modifying the
5//! solver internals, the corrector adjusts the residuals and Jacobians before they are
6//! passed to the linear solver.
7//!
8//! # Algorithm Overview
9//!
10//! Given a residual vector `r` and a robust loss function ρ(s) where `s = ||r||²`, the
11//! corrector computes modified residuals and Jacobians such that:
12//!
13//! ```text
14//! minimize Σ ρ(||r_i||²) ≡ minimize Σ ||r̃_i||²
15//! ```
16//!
17//! where `r̃` are the corrected residuals and the Jacobian is similarly adjusted.
18//!
19//! # Mathematical Formulation
20//!
21//! For a residual `r` with Jacobian `J = ∂r/∂x`, the corrector computes:
22//!
23//! 1. **Square norm**: `s = ||r||² = r^T r`
24//! 2. **Loss evaluation**: `[ρ(s), ρ'(s), ρ''(s)]` from the loss function
25//! 3. **Scaling factors**:
26//! ```text
27//! √ρ₁ = √(ρ'(s)) (residual scaling)
28//! α² = ρ''(s) / ρ'(s) (Jacobian correction factor)
29//! ```
30//!
31//! 4. **Corrected residuals**: `r̃ = √ρ₁ · r`
32//! 5. **Corrected Jacobian**:
33//! ```text
34//! J̃ = √ρ₁ · J + α · (J^T r) · r^T / ||r||
35//! ```
36//!
37//! This ensures that `||r̃||² ≈ ρ(||r||²)` and the gradient is correct.
38//!
39//! # Reference
40//!
41//! Based on Ceres Solver implementation:
42//! <https://github.com/ceres-solver/ceres-solver/blob/master/internal/ceres/corrector.cc>
43//!
44//! See also:
45//! - Triggs et al., "Bundle Adjustment — A Modern Synthesis" (1999)
46//! - Agarwal et al., "Ceres Solver" (<http://ceres-solver.org/>)
47//!
48//! # Example
49//!
50//! ```
51//! use apex_solver::core::corrector::Corrector;
52//! use apex_solver::core::loss_functions::{LossFunction, HuberLoss};
53//! use nalgebra::{DVector, DMatrix};
54//! # use apex_solver::error::ApexSolverResult;
55//! # fn example() -> ApexSolverResult<()> {
56//!
57//! // Create a robust loss function
58//! let loss = HuberLoss::new(1.0)?;
59//!
60//! // Original residual and Jacobian
61//! let residual = DVector::from_vec(vec![2.0, 3.0, 1.0]); // Large residual (outlier)
62//! let jacobian = DMatrix::from_row_slice(3, 2, &[
63//! 1.0, 0.0,
64//! 0.0, 1.0,
65//! 1.0, 1.0,
66//! ]);
67//!
68//! // Compute squared norm
69//! let squared_norm = residual.dot(&residual);
70//!
71//! // Create corrector
72//! let corrector = Corrector::new(&loss, squared_norm);
73//!
74//! // Apply corrections
75//! let mut corrected_jacobian = jacobian.clone();
76//! let mut corrected_residual = residual.clone();
77//!
78//! corrector.correct_jacobian(&residual, &mut corrected_jacobian);
79//! corrector.correct_residuals(&mut corrected_residual);
80//!
81//! // The corrected values now account for the robust loss function
82//! // Outliers have been downweighted appropriately
83//! # Ok(())
84//! # }
85//! # example().unwrap();
86//! ```
87
88use crate::core::loss_functions::LossFunction;
89use nalgebra::{DMatrix, DVector};
90
91/// Corrector for applying robust loss functions via residual and Jacobian adjustment.
92///
93/// This struct holds the precomputed scaling factors needed to transform a robust loss
94/// problem into an equivalent reweighted least squares problem. It is instantiated once
95/// per residual block during each iteration of the optimizer.
96///
97/// # Fields
98///
99/// - `sqrt_rho1`: √(ρ'(s)) - Square root of the first derivative, used for residual scaling
100/// - `residual_scaling`: √(ρ'(s)) - Same as sqrt_rho1, stored separately for clarity
101/// - `alpha_sq_norm`: α² = ρ''(s) / ρ'(s) - Ratio of second to first derivative,
102/// used for Jacobian correction
103///
104/// where `s = ||r||²` is the squared norm of the residual.
105#[derive(Debug, Clone)]
106pub struct Corrector {
107 sqrt_rho1: f64,
108 residual_scaling: f64,
109 alpha_sq_norm: f64,
110}
111
112impl Corrector {
113 /// Create a new Corrector by evaluating the loss function at the given squared norm.
114 ///
115 /// # Arguments
116 ///
117 /// * `loss_function` - The robust loss function ρ(s)
118 /// * `sq_norm` - The squared norm of the residual: `s = ||r||²`
119 ///
120 /// # Returns
121 ///
122 /// A `Corrector` instance with precomputed scaling factors
123 ///
124 /// # Example
125 ///
126 /// ```
127 /// use apex_solver::core::corrector::Corrector;
128 /// use apex_solver::core::loss_functions::{LossFunction, HuberLoss};
129 /// use nalgebra::DVector;
130 /// # use apex_solver::error::ApexSolverResult;
131 /// # fn example() -> ApexSolverResult<()> {
132 ///
133 /// let loss = HuberLoss::new(1.0)?;
134 /// let residual = DVector::from_vec(vec![1.0, 2.0, 3.0]);
135 /// let squared_norm = residual.dot(&residual); // 14.0
136 ///
137 /// let corrector = Corrector::new(&loss, squared_norm);
138 /// // corrector is now ready to apply corrections
139 /// # Ok(())
140 /// # }
141 /// # example().unwrap();
142 /// ```
143 pub fn new(loss_function: &dyn LossFunction, sq_norm: f64) -> Self {
144 // Evaluate loss function: [ρ(s), ρ'(s), ρ''(s)]
145 let rho = loss_function.evaluate(sq_norm);
146
147 // Extract derivatives
148 let rho_1 = rho[1]; // ρ'(s)
149 let rho_2 = rho[2]; // ρ''(s)
150
151 // Compute scaling factors
152 let sqrt_rho1 = rho_1.sqrt(); // √(ρ'(s))
153
154 // Handle special cases (common case: rho[2] <= 0)
155 // This occurs when the loss function has no curvature correction needed
156 if sq_norm == 0.0 || rho_2 <= 0.0 {
157 return Self {
158 sqrt_rho1,
159 residual_scaling: sqrt_rho1,
160 alpha_sq_norm: 0.0,
161 };
162 }
163
164 // Compute alpha by solving the quadratic equation:
165 // 0.5·α² - α - (ρ''/ρ')·s = 0
166 //
167 // This gives: α = 1 - √(1 + 2·s·ρ''/ρ')
168 //
169 // Reference: Ceres Solver corrector.cc
170 // https://github.com/ceres-solver/ceres-solver/blob/master/internal/ceres/corrector.cc
171 let d = 1.0 + 2.0 * sq_norm * rho_2 / rho_1;
172 let alpha = 1.0 - d.sqrt();
173
174 Self {
175 sqrt_rho1,
176 residual_scaling: sqrt_rho1 / (1.0 - alpha),
177 alpha_sq_norm: alpha / sq_norm,
178 }
179 }
180
181 /// Apply correction to the Jacobian matrix.
182 ///
183 /// Transforms the Jacobian `J` into `J̃` according to the Ceres Solver corrector algorithm:
184 ///
185 /// ```text
186 /// J̃ = √(ρ'(s)) · (J - α²·r·r^T·J)
187 /// ```
188 ///
189 /// where:
190 /// - `√(ρ'(s))` scales the Jacobian by the loss function weight
191 /// - `α` is computed by solving the quadratic equation: 0.5·α² - α - (ρ''/ρ')·s = 0
192 /// - The subtractive term `α²·r·r^T·J` is a rank-1 curvature correction
193 ///
194 /// # Arguments
195 ///
196 /// * `residual` - The original residual vector `r`
197 /// * `jacobian` - Mutable reference to the Jacobian matrix (modified in-place)
198 ///
199 /// # Implementation Notes
200 ///
201 /// The correction is applied in-place for efficiency. The algorithm:
202 /// 1. Scales all Jacobian entries by `√(ρ'(s))`
203 /// 2. Adds the outer product correction: `α · (J^T r) · r^T / ||r||`
204 ///
205 /// # Example
206 ///
207 /// ```
208 /// use apex_solver::core::corrector::Corrector;
209 /// use apex_solver::core::loss_functions::{LossFunction, HuberLoss};
210 /// use nalgebra::{DVector, DMatrix};
211 /// # use apex_solver::error::ApexSolverResult;
212 /// # fn example() -> ApexSolverResult<()> {
213 ///
214 /// let loss = HuberLoss::new(1.0)?;
215 /// let residual = DVector::from_vec(vec![2.0, 1.0]);
216 /// let squared_norm = residual.dot(&residual);
217 ///
218 /// let corrector = Corrector::new(&loss, squared_norm);
219 ///
220 /// let mut jacobian = DMatrix::from_row_slice(2, 3, &[
221 /// 1.0, 0.0, 1.0,
222 /// 0.0, 1.0, 1.0,
223 /// ]);
224 ///
225 /// corrector.correct_jacobian(&residual, &mut jacobian);
226 /// // jacobian is now corrected to account for the robust loss
227 /// # Ok(())
228 /// # }
229 /// # example().unwrap();
230 /// ```
231 pub fn correct_jacobian(&self, residual: &DVector<f64>, jacobian: &mut DMatrix<f64>) {
232 // Common case (rho[2] <= 0): only apply first-order correction
233 // This is the most common scenario for well-behaved loss functions
234 if self.alpha_sq_norm == 0.0 {
235 *jacobian *= self.sqrt_rho1;
236 return;
237 }
238
239 // Full correction with curvature term:
240 // J̃ = √ρ₁ · (J - α²·r·r^T·J)
241 //
242 // This is the correct Ceres Solver algorithm:
243 // 1. Compute r·r^T·J (outer product of residual with Jacobian)
244 // 2. Subtract α²·r·r^T·J from J
245 // 3. Scale result by √ρ₁
246 //
247 // Reference: Ceres Solver corrector.cc
248 // https://github.com/ceres-solver/ceres-solver/blob/master/internal/ceres/corrector.cc
249
250 let r_rtj = residual * residual.transpose() * jacobian.clone();
251 *jacobian = (jacobian.clone() - r_rtj * self.alpha_sq_norm) * self.sqrt_rho1;
252 }
253
254 /// Apply correction to the residual vector.
255 ///
256 /// Transforms the residual `r` into `r̃` by scaling:
257 ///
258 /// ```text
259 /// r̃ = √(ρ'(s)) · r
260 /// ```
261 ///
262 /// This ensures that `||r̃||² ≈ ρ(||r||²)`, i.e., the squared norm of the corrected
263 /// residual approximates the robust cost.
264 ///
265 /// # Arguments
266 ///
267 /// * `residual` - Mutable reference to the residual vector (modified in-place)
268 ///
269 /// # Example
270 ///
271 /// ```
272 /// use apex_solver::core::corrector::Corrector;
273 /// use apex_solver::core::loss_functions::{LossFunction, HuberLoss};
274 /// use nalgebra::DVector;
275 /// # use apex_solver::error::ApexSolverResult;
276 /// # fn example() -> ApexSolverResult<()> {
277 ///
278 /// let loss = HuberLoss::new(1.0)?;
279 /// let mut residual = DVector::from_vec(vec![2.0, 3.0, 1.0]);
280 /// let squared_norm = residual.dot(&residual);
281 ///
282 /// let corrector = Corrector::new(&loss, squared_norm);
283 ///
284 /// corrector.correct_residuals(&mut residual);
285 /// // Outlier residuals are scaled down
286 /// # Ok(())
287 /// # }
288 /// # example().unwrap();
289 /// ```
290 pub fn correct_residuals(&self, residual: &mut DVector<f64>) {
291 // Simple scaling: r̃ = √(ρ'(s)) · r
292 //
293 // This downweights outliers (where ρ'(s) < 1) and leaves inliers
294 // approximately unchanged (where ρ'(s) ≈ 1)
295 *residual *= self.residual_scaling;
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::core::loss_functions::{CauchyLoss, HuberLoss};
303
304 type TestResult = Result<(), Box<dyn std::error::Error>>;
305
306 #[test]
307 fn test_corrector_huber_inlier() -> TestResult {
308 // Test corrector behavior for an inlier (small residual)
309 let loss = HuberLoss::new(1.0)?;
310 let residual = DVector::from_vec(vec![0.1, 0.2, 0.1]); // Small residual
311 let squared_norm = residual.dot(&residual); // 0.06
312
313 let corrector = Corrector::new(&loss, squared_norm);
314
315 // For inliers, ρ'(s) ≈ 1, so scaling should be near 1
316 assert!((corrector.sqrt_rho1 - 1.0).abs() < 1e-10);
317 assert!((corrector.alpha_sq_norm).abs() < 1e-10); // ρ''(s) ≈ 0 for inliers
318
319 // Corrected residual should be nearly unchanged
320 let mut corrected_residual = residual.clone();
321 corrector.correct_residuals(&mut corrected_residual);
322 assert!((corrected_residual - residual).norm() < 1e-10);
323
324 Ok(())
325 }
326
327 #[test]
328 fn test_corrector_huber_outlier() -> TestResult {
329 // Test corrector behavior for an outlier (large residual)
330 let loss = HuberLoss::new(1.0)?;
331 let residual = DVector::from_vec(vec![5.0, 5.0, 5.0]); // Large residual
332 let squared_norm = residual.dot(&residual); // 75.0
333
334 let corrector = Corrector::new(&loss, squared_norm);
335
336 // For outliers, ρ'(s) < 1, so scaling should be < 1
337 assert!(corrector.sqrt_rho1 < 1.0);
338 assert!(corrector.sqrt_rho1 > 0.0);
339
340 // Corrected residual should be downweighted
341 let mut corrected_residual = residual.clone();
342 corrector.correct_residuals(&mut corrected_residual);
343 assert!(corrected_residual.norm() < residual.norm());
344
345 Ok(())
346 }
347
348 #[test]
349 fn test_corrector_cauchy() -> TestResult {
350 // Test corrector with Cauchy loss
351 let loss = CauchyLoss::new(1.0)?;
352 let residual = DVector::from_vec(vec![2.0, 3.0]);
353 let squared_norm = residual.dot(&residual); // 13.0
354
355 let corrector = Corrector::new(&loss, squared_norm);
356
357 // Cauchy loss should heavily downweight large residuals
358 assert!(corrector.sqrt_rho1 < 1.0);
359 assert!(corrector.sqrt_rho1 > 0.0);
360
361 let mut corrected_residual = residual.clone();
362 corrector.correct_residuals(&mut corrected_residual);
363 assert!(corrected_residual.norm() < residual.norm());
364
365 Ok(())
366 }
367
368 #[test]
369 fn test_corrector_jacobian() -> TestResult {
370 // Test Jacobian correction
371 let loss = HuberLoss::new(1.0)?;
372 let residual = DVector::from_vec(vec![2.0, 1.0]);
373 let squared_norm = residual.dot(&residual);
374
375 let corrector = Corrector::new(&loss, squared_norm);
376
377 let mut jacobian = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
378
379 let original_jacobian = jacobian.clone();
380 corrector.correct_jacobian(&residual, &mut jacobian);
381
382 // Jacobian should be modified
383 assert!(jacobian != original_jacobian);
384
385 // Each element should be scaled and corrected
386 // (Exact values depend on loss function derivatives)
387
388 Ok(())
389 }
390}