Skip to content

Commit 46d0fc9

Browse files
committed
feat(feature_transform): add SoftSign — smooth saturating transform for coherent diffmaps
f(x) = x/(scale + |x|). Bounded, monotone, odd, C^inf with a continuous derivative everywhere — unlike winsor/quantile_bins (step transforms with 0-a.e. derivatives) that make the central-diff diffmap sensitivities garbage and flip diffmap coherence (M3) negative. SoftSign saturates heavy tails like winsor (corruption/FR-rank robustness) while keeping s_k clean like raw (diffmap stays coherent) — the lever to break the rank-vs-diffmap tradeoff in the zensim closed-loop dial. - new #[non_exhaustive] enum variant SoftSign (additive; scale param optional, default 1, non-positive falls back to 1) - apply / apply_with_params / from_token / as_token wired - all_variants()/sample_params() updated; 3 focused tests (odd, monotone, saturating, matches formula, scale widens linear zone). 142 tests green.
1 parent fc7bc05 commit 46d0fc9

1 file changed

Lines changed: 92 additions & 1 deletion

File tree

zenpredict/src/feature_transform.rs

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ pub enum FeatureTransform {
195195
/// needed). Added 2026-05-18 for Gain-MLP-style per-pixel MLPs
196196
/// (Canham et al. 2025).
197197
Sinusoidal,
198+
/// `x / (scale + |x|)` — smooth, strictly monotone, *saturating* soft-sign.
199+
/// Tames heavy-tail/corruption extremes like a winsor clip but with a
200+
/// bounded, CONTINUOUS derivative (`scale/(scale+|x|)²`), so a model's
201+
/// gradient through it stays clean for the runtime diffmap (winsor's
202+
/// clip and `quantile_bins`' step do not). One optional parameter, the
203+
/// `scale` (saturation onset); `params = []` uses `scale = 1`.
204+
/// Added 2026-07-24 for the smooth-saturating "ideal" zensim model.
205+
SoftSign,
198206
}
199207

200208
/// Two-pi constant for sinusoidal embedding. Mirrors `core::f32::consts::TAU`
@@ -232,6 +240,24 @@ fn signed_cbrt(x: f32) -> f32 {
232240
}
233241
}
234242

243+
/// Soft-sign `x / (scale + |x|)` — smooth, strictly monotone, and *saturating*
244+
/// (→ ±1). `scale > 0` sets the saturation onset: for `|x| ≪ scale` it is
245+
/// ~linear (`x/scale`), for `|x| ≫ scale` it flattens toward ±1. Its
246+
/// derivative `scale / (scale + |x|)²` is bounded and CONTINUOUS everywhere —
247+
/// unlike `winsor`'s clip (0-a.e. with a kink) or `quantile_bins`' step — so a
248+
/// model's central-difference gradient through it stays clean. That is what
249+
/// lets it tame corruption/heavy-tail extremes (like winsor) WITHOUT breaking
250+
/// the diffmap `s_k` (like raw): the smooth-saturating middle the ideal
251+
/// zensim model needs. `scale ≤ 0` falls back to `scale = 1`.
252+
fn soft_sign(x: f32, scale: f32) -> f32 {
253+
let s = if scale > 0.0 { scale } else { 1.0 };
254+
#[cfg(feature = "std")]
255+
let ax = x.abs();
256+
#[cfg(not(feature = "std"))]
257+
let ax = libm::fabsf(x);
258+
x / (s + ax)
259+
}
260+
235261
/// Yeo-Johnson power transform. Smooth across the entire real line;
236262
/// `λ = 1` reduces to a constant-shifted identity (`y = x`), `λ = 0`
237263
/// falls to `ln(1 + x)` for non-negative input, `λ = 2` falls to
@@ -425,6 +451,7 @@ impl FeatureTransform {
425451
// invariant other variants follow).
426452
Self::YeoJohnson => x,
427453
Self::WinsorP99 | Self::QuantileBins => x,
454+
Self::SoftSign => soft_sign(x, 1.0),
428455
// Scalar `apply` cannot represent an expander variant. The
429456
// bake-load + runtime paths route through
430457
// `apply_expanding` / `apply_feature_pipeline_expanding` /
@@ -583,6 +610,7 @@ impl FeatureTransform {
583610
};
584611
yeo_johnson(x, lambda)
585612
}
613+
Self::SoftSign => soft_sign(x, params.first().copied().unwrap_or(1.0)),
586614
// Sinusoidal is an expander — see `apply` for the rationale
587615
// on why this panics instead of returning a degenerate scalar.
588616
Self::Sinusoidal => panic!(
@@ -720,6 +748,7 @@ impl FeatureTransform {
720748
"clip_then_log1p_then_winsor" => Ok(Self::ClipThenLog1pThenWinsor),
721749
"yeo_johnson" => Ok(Self::YeoJohnson),
722750
"sinusoidal" => Ok(Self::Sinusoidal),
751+
"soft_sign" => Ok(Self::SoftSign),
723752
_ => Err(PredictError::UnknownFeatureTransform),
724753
}
725754
}
@@ -743,6 +772,7 @@ impl FeatureTransform {
743772
Self::ClipThenLog1pThenWinsor => "clip_then_log1p_then_winsor",
744773
Self::YeoJohnson => "yeo_johnson",
745774
Self::Sinusoidal => "sinusoidal",
775+
Self::SoftSign => "soft_sign",
746776
}
747777
}
748778
}
@@ -1431,7 +1461,7 @@ mod tests {
14311461
/// Returns every FeatureTransform variant currently in the enum.
14321462
/// Keep this in sync with the enum when adding new variants —
14331463
/// the universal tests below iterate this list.
1434-
fn all_variants() -> [FeatureTransform; 15] {
1464+
fn all_variants() -> [FeatureTransform; 16] {
14351465
[
14361466
FeatureTransform::Identity,
14371467
FeatureTransform::Log,
@@ -1448,6 +1478,7 @@ mod tests {
14481478
FeatureTransform::SignedCbrtThenWinsor,
14491479
FeatureTransform::ClipThenLog1pThenWinsor,
14501480
FeatureTransform::YeoJohnson,
1481+
FeatureTransform::SoftSign,
14511482
]
14521483
}
14531484

@@ -1471,6 +1502,10 @@ mod tests {
14711502
FeatureTransform::SignedCbrtThenWinsor => &[-2.0_f32, 2.0],
14721503
FeatureTransform::ClipThenLog1pThenWinsor => &[0.1_f32, 0.0, 3.0],
14731504
FeatureTransform::YeoJohnson => &[-50.0_f32],
1505+
// SoftSign takes an optional scale param; the no-param
1506+
// fallback (scale = 1) is exercised by all_variants(), so a
1507+
// non-trivial scale here checks apply_with_params too.
1508+
FeatureTransform::SoftSign => &[2.0_f32],
14741509
// Sinusoidal is an expander (panics under scalar apply),
14751510
// so it is excluded from `all_variants()` and never reaches
14761511
// these scalar NaN-safety helpers. This arm only keeps the
@@ -1602,6 +1637,62 @@ mod tests {
16021637
}
16031638
}
16041639

1640+
// -----------------------------------------------------------------
1641+
// SoftSign (smooth saturating transform for coherent diffmaps)
1642+
// -----------------------------------------------------------------
1643+
1644+
#[test]
1645+
fn soft_sign_token_round_trip_and_flags() {
1646+
let t = FeatureTransform::from_token("soft_sign").expect("parse");
1647+
assert_eq!(t, FeatureTransform::SoftSign);
1648+
assert_eq!(t.as_token(), "soft_sign");
1649+
assert!(!t.requires_params(), "scale is optional (default 1)");
1650+
assert!(!t.is_expander());
1651+
assert_eq!(t.output_arity(&[]), 1);
1652+
assert_eq!(t.output_arity(&[2.0]), 1);
1653+
}
1654+
1655+
#[test]
1656+
fn soft_sign_is_odd_monotone_saturating_and_matches_formula() {
1657+
let f = |x: f32| FeatureTransform::SoftSign.apply(x);
1658+
// Reference: x / (1 + |x|).
1659+
for &x in &[-1000.0_f32, -3.5, -1.0, -0.1, 0.0, 0.1, 1.0, 3.5, 1000.0] {
1660+
let want = x / (1.0 + x.abs());
1661+
assert!((f(x) - want).abs() < 1e-6, "soft_sign({x})={} want {want}", f(x));
1662+
}
1663+
// Odd: f(-x) = -f(x).
1664+
for &x in &[0.3_f32, 1.7, 42.0] {
1665+
assert!((f(-x) + f(x)).abs() < 1e-6);
1666+
}
1667+
// Strictly monotone increasing.
1668+
let mut prev = f(-1e6);
1669+
for i in -1000..=1000 {
1670+
let cur = f(i as f32 * 0.05);
1671+
assert!(cur > prev, "not monotone at {i}");
1672+
prev = cur;
1673+
}
1674+
// Bounded in [-1, 1] — the saturation that tames heavy tails.
1675+
// (At x≫1/eps the f32 `+1` is lost and it saturates to exactly 1.)
1676+
assert!(f(1000.0) < 1.0 && f(1000.0) > 0.999);
1677+
assert!(f(-1000.0) > -1.0 && f(-1000.0) < -0.999);
1678+
assert!(f(1e9) <= 1.0 && f(-1e9) >= -1.0);
1679+
assert_eq!(f(0.0), 0.0);
1680+
}
1681+
1682+
#[test]
1683+
fn soft_sign_scale_param_widens_the_linear_zone() {
1684+
// Larger scale → gentler saturation: |soft_sign_s(x)| < |soft_sign_1(x)|
1685+
// for the same x, and the derivative at 0 is 1/scale.
1686+
let s1 = FeatureTransform::SoftSign.apply_with_params(2.0, &[]);
1687+
let s4 = FeatureTransform::SoftSign.apply_with_params(2.0, &[4.0]);
1688+
assert!((s1 - 2.0 / 3.0).abs() < 1e-6); // 2/(1+2)
1689+
assert!((s4 - 2.0 / 6.0).abs() < 1e-6); // 2/(4+2)
1690+
assert!(s4.abs() < s1.abs());
1691+
// Non-positive scale falls back to 1 (guards a garbage screen entry).
1692+
let s0 = FeatureTransform::SoftSign.apply_with_params(2.0, &[0.0]);
1693+
assert!((s0 - s1).abs() < 1e-6);
1694+
}
1695+
16051696
// -----------------------------------------------------------------
16061697
// Sinusoidal embedding (expander variant)
16071698
// -----------------------------------------------------------------

0 commit comments

Comments
 (0)