optirs_core/utils/mod.rs
1// Utility functions for machine learning optimization
2//
3// This module provides utility functions and helpers for optimization
4// tasks in machine learning.
5
6use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
7use scirs2_core::numeric::{Float, ToPrimitive};
8use std::fmt::Debug;
9
10use crate::error::{OptimError, Result};
11
12/// Convert an `f64` value into the generic float type `A`, returning an honest
13/// error when `A` cannot represent it.
14///
15/// Use this at call sites that already return [`Result`]: it replaces the
16/// `A::from(x).expect("unwrap failed")` pattern with real error propagation, so
17/// a value outside `A`'s range surfaces as an `Err` instead of a panic.
18///
19/// For the infallible counterpart — constructors, `Default` impls and struct
20/// literals, which cannot propagate an error — use [`scalar_or`].
21///
22/// # Examples
23///
24/// ```
25/// use optirs_core::utils::try_scalar;
26///
27/// let half: f32 = try_scalar(0.5).expect("0.5 is representable as f32");
28/// assert_eq!(half, 0.5f32);
29///
30/// // Note what the error path does *not* cover: a float-to-float conversion
31/// // saturates rather than failing, so an out-of-range `f64` becomes an
32/// // infinity in `f32`, not an `Err`. The `Err` arm is a defensive guard for
33/// // conversions that genuinely have no image, not an `f32` range check.
34/// assert_eq!(
35/// try_scalar::<f32, _>(f64::MAX).expect("f64 -> f32 saturates instead of failing"),
36/// f32::INFINITY
37/// );
38/// ```
39#[inline]
40pub fn try_scalar<A: Float, V: ToPrimitive + Copy>(value: V) -> Result<A> {
41 A::from(value).ok_or_else(|| OptimError::InvalidParameter(unrepresentable(value)))
42}
43
44/// Convert a generic float `A` down into `f64`, returning an honest error when
45/// the value has no `f64` representation.
46///
47/// This is the mirror image of [`try_scalar`]: `try_scalar` widens a concrete
48/// literal into the generic parameter type, `try_f64` narrows a generic value
49/// back to `f64` for accumulators, metrics and reporting that are natively
50/// `f64`. It replaces the `x.to_f64().expect("unwrap failed")` pattern.
51///
52/// # Examples
53///
54/// ```
55/// use optirs_core::utils::try_f64;
56///
57/// assert_eq!(try_f64(0.5f32).expect("f32 always fits in f64"), 0.5);
58/// assert_eq!(try_f64(f64::INFINITY).expect("infinity is an f64"), f64::INFINITY);
59/// ```
60#[inline]
61pub fn try_f64<A: Float>(value: A) -> Result<f64> {
62 value.to_f64().ok_or_else(|| {
63 OptimError::InvalidParameter(
64 "value is not representable as f64, so the metric cannot be computed".to_string(),
65 )
66 })
67}
68
69/// Error text for a value that the target float type cannot represent.
70fn unrepresentable<V: ToPrimitive>(value: V) -> String {
71 match value.to_f64() {
72 Some(shown) => {
73 format!("value {shown} is not representable in the target floating-point type")
74 }
75 None => "value is not representable in the target floating-point type".to_string(),
76 }
77}
78
79/// [`try_scalar`] for the call sites whose error type is `String`.
80///
81/// Several streaming modules return `Result<T, String>` rather than
82/// [`OptimError`]; this keeps their conversions honest without forcing a
83/// `.map_err(..)` at every site.
84///
85/// # Examples
86///
87/// ```
88/// use optirs_core::utils::try_scalar_str;
89///
90/// let half: f32 = try_scalar_str(0.5).expect("0.5 is representable as f32");
91/// assert_eq!(half, 0.5f32);
92/// // As with [`try_scalar`], `f64 -> f32` saturates rather than failing.
93/// assert_eq!(
94/// try_scalar_str::<f32, _>(f64::MAX).expect("saturates"),
95/// f32::INFINITY
96/// );
97/// ```
98#[inline]
99pub fn try_scalar_str<A: Float, V: ToPrimitive + Copy>(value: V) -> std::result::Result<A, String> {
100 A::from(value).ok_or_else(|| unrepresentable(value))
101}
102
103/// Convert an `f64` value into the generic float type `A`, falling back to
104/// `fallback` when `A` cannot represent it.
105///
106/// This is the infallible counterpart to [`try_scalar`], for the call sites
107/// that structurally cannot return an error: `Default` impls, constructors and
108/// struct literals. For the `f32`/`f64` types this crate targets, conversion of
109/// the numeric literals used in those positions always succeeds, so the
110/// fallback is defensive rather than a papered-over failure — but pick a
111/// fallback that is safe in context (for example `Float::one` for a
112/// multiplicative factor or a divisor, so a failed conversion can never
113/// introduce a division by zero).
114///
115/// # Examples
116///
117/// ```
118/// use optirs_core::utils::scalar_or;
119///
120/// assert_eq!(scalar_or::<f64, _>(0.9, 1.0), 0.9);
121/// // The fallback covers conversions with no image at all. A float-to-float
122/// // conversion is not one of them: it saturates, so `f64::MAX` reaches `f32`
123/// // as an infinity rather than falling back.
124/// assert_eq!(scalar_or::<f32, _>(f64::MAX, 1.0), f32::INFINITY);
125/// ```
126#[inline]
127pub fn scalar_or<A: Float, V: ToPrimitive>(value: V, fallback: A) -> A {
128 A::from(value).unwrap_or(fallback)
129}
130
131/// Convert a value into the generic float type `A`, or `None` when `A` cannot
132/// represent it.
133///
134/// For call sites that already work in `Option` — typically a configuration
135/// lookup whose miss falls through to a default — an unrepresentable value is
136/// naturally "absent", so this keeps the existing fallback path instead of
137/// inventing an error or a magic number.
138///
139/// # Examples
140///
141/// ```
142/// use optirs_core::utils::scalar_opt;
143///
144/// assert_eq!(scalar_opt::<f32, _>(0.25), Some(0.25f32));
145/// // Float-to-float conversion saturates rather than returning `None`.
146/// assert_eq!(scalar_opt::<f32, _>(f64::MAX), Some(f32::INFINITY));
147/// ```
148#[inline]
149pub fn scalar_opt<A: Float, V: ToPrimitive>(value: V) -> Option<A> {
150 A::from(value)
151}
152
153/// Total ordering for floating-point values that never panics.
154///
155/// `f64::total_cmp`/`f32::total_cmp` are inherent methods and therefore
156/// unavailable behind a generic `A: Float` bound, so this reproduces the same
157/// contract: a genuine total order in which `NaN` sorts after every real number
158/// (and equals itself). Use it instead of
159/// `partial_cmp(..).expect("unwrap failed")`, which panics the moment a `NaN`
160/// reaches the comparator, in `sort_by`/`min_by`/`max_by`/`select_nth`.
161///
162/// # Examples
163///
164/// ```
165/// use optirs_core::utils::total_order;
166///
167/// let mut values = vec![2.0, f64::NAN, 1.0];
168/// values.sort_by(total_order);
169/// assert_eq!(values[0], 1.0);
170/// assert_eq!(values[1], 2.0);
171/// assert!(values[2].is_nan(), "NaN sorts last");
172/// ```
173pub fn total_order<A: Float>(a: &A, b: &A) -> std::cmp::Ordering {
174 use std::cmp::Ordering;
175 match a.partial_cmp(b) {
176 Some(ordering) => ordering,
177 None => match (a.is_nan(), b.is_nan()) {
178 (true, true) => Ordering::Equal,
179 (true, false) => Ordering::Greater,
180 (false, true) => Ordering::Less,
181 // `partial_cmp` only returns `None` when at least one side is
182 // `NaN`, so this arm is genuinely unreachable; treating it as
183 // `Equal` keeps the comparator total regardless.
184 (false, false) => Ordering::Equal,
185 },
186 }
187}
188
189/// Clip gradient values to a specified range
190///
191/// # Arguments
192///
193/// * `gradients` - The gradients to clip
194/// * `min_value` - Minimum allowed value
195/// * `max_value` - Maximum allowed value
196///
197/// # Returns
198///
199/// The clipped gradients (in-place modification)
200///
201/// # Examples
202///
203/// ```
204/// use scirs2_core::ndarray::Array1;
205/// use optirs_core::utils::clip_gradients;
206///
207/// let mut gradients = Array1::from_vec(vec![-10.0, 0.5, 8.0, -0.2]);
208/// clip_gradients(&mut gradients, -5.0, 5.0);
209/// assert_eq!(gradients, Array1::from_vec(vec![-5.0, 0.5, 5.0, -0.2]));
210/// ```
211pub fn clip_gradients<A, D>(
212 gradients: &mut Array<A, D>,
213 min_value: A,
214 max_value: A,
215) -> &mut Array<A, D>
216where
217 A: Float + ScalarOperand + Debug,
218 D: Dimension,
219{
220 for grad in gradients.iter_mut() {
221 *grad = if *grad < min_value {
222 min_value
223 } else if *grad > max_value {
224 max_value
225 } else {
226 *grad
227 };
228 }
229 gradients
230}
231
232/// Clip gradient norm (global gradient clipping)
233///
234/// # Arguments
235///
236/// * `gradients` - The gradients to clip
237/// * `max_norm` - Maximum allowed L2 norm
238///
239/// # Returns
240///
241/// The clipped gradients (in-place modification)
242///
243/// # Examples
244///
245/// ```
246/// use scirs2_core::ndarray::Array1;
247/// use optirs_core::utils::clip_gradient_norm;
248///
249/// let mut gradients = Array1::<f64>::from_vec(vec![3.0, 4.0]); // L2 norm = 5.0
250/// clip_gradient_norm(&mut gradients, 1.0f64).expect("a finite max-norm is valid");
251/// // After clipping, L2 norm = 1.0
252/// let diff0 = (gradients[0] - 0.6f64).abs();
253/// let diff1 = (gradients[1] - 0.8f64).abs();
254/// assert!(diff0 < 1e-5);
255/// assert!(diff1 < 1e-5);
256/// ```
257pub fn clip_gradient_norm<A, D>(
258 gradients: &mut Array<A, D>,
259 max_norm: A,
260) -> Result<&mut Array<A, D>>
261where
262 A: Float + ScalarOperand + Debug,
263 D: Dimension,
264{
265 if max_norm <= A::zero() {
266 return Err(OptimError::InvalidConfig(
267 "max_norm must be positive".to_string(),
268 ));
269 }
270
271 // Calculate current L2 _norm
272 let _norm = gradients
273 .iter()
274 .fold(A::zero(), |acc, &x| acc + x * x)
275 .sqrt();
276
277 // If _norm exceeds max_norm, scale gradients
278 if _norm > max_norm {
279 let scale = max_norm / _norm;
280 for grad in gradients.iter_mut() {
281 *grad = *grad * scale;
282 }
283 }
284
285 Ok(gradients)
286}
287
288/// Compute gradient centralization
289///
290/// Gradient Centralization is a technique that improves training stability
291/// by removing the mean from each gradient tensor.
292///
293/// # Arguments
294///
295/// * `gradients` - The gradients to centralize
296///
297/// # Returns
298///
299/// The centralized gradients (in-place modification)
300///
301/// # Examples
302///
303/// ```
304/// use scirs2_core::ndarray::Array1;
305/// use optirs_core::utils::gradient_centralization;
306///
307/// let mut gradients = Array1::from_vec(vec![1.0, 2.0, 3.0, 2.0]);
308/// gradient_centralization(&mut gradients);
309/// assert_eq!(gradients, Array1::from_vec(vec![-1.0, 0.0, 1.0, 0.0]));
310/// ```
311pub fn gradient_centralization<A, D>(gradients: &mut Array<A, D>) -> &mut Array<A, D>
312where
313 A: Float + ScalarOperand + Debug,
314 D: Dimension,
315{
316 // Calculate mean
317 let sum = gradients.iter().fold(A::zero(), |acc, &x| acc + x);
318 let mean = sum / A::from(gradients.len()).unwrap_or(A::one());
319
320 // Subtract mean from each element
321 for grad in gradients.iter_mut() {
322 *grad = *grad - mean;
323 }
324
325 gradients
326}
327
328/// Zero out small gradient values
329///
330/// # Arguments
331///
332/// * `gradients` - The gradients to process
333/// * `threshold` - Threshold below which gradients are set to zero
334///
335/// # Returns
336///
337/// The processed gradients (in-place modification)
338///
339/// # Examples
340///
341/// ```
342/// use scirs2_core::ndarray::Array1;
343/// use optirs_core::utils::zero_small_gradients;
344///
345/// let mut gradients = Array1::from_vec(vec![0.001, 0.02, -0.005, 0.3]);
346/// zero_small_gradients(&mut gradients, 0.01);
347/// assert_eq!(gradients, Array1::from_vec(vec![0.0, 0.02, 0.0, 0.3]));
348/// ```
349pub fn zero_small_gradients<A, D>(gradients: &mut Array<A, D>, threshold: A) -> &mut Array<A, D>
350where
351 A: Float + ScalarOperand + Debug,
352 D: Dimension,
353{
354 let abs_threshold = threshold.abs();
355
356 for grad in gradients.iter_mut() {
357 if grad.abs() < abs_threshold {
358 *grad = A::zero();
359 }
360 }
361
362 gradients
363}