Skip to content

Commit 7cfca7e

Browse files
committed
feat(feature_transform): add SoftClip — smooth Huber-log winsor (identity core + unbounded tail)
f(x) = x for |x|≤knee, else sign(x)·(knee + soft·ln(1+(|x|−knee)/soft)). C¹-matched at the knee (slope 1 both sides) — smooth everywhere, no kink/flat/ step. The frontier clean-model transform for zensim: unlike SoftSign the tail is UNBOUNDED (preserves the corruption negative-tail ORDER — a pathological feature stays strictly greater than any honest one, so score(corruption)<score(q20) holds); unlike WinsorP99 it has no 0-derivative flat (runtime diffmap s_k stays clean); unlike SignedCbrt the honest in-range bulk passes through undistorted (identity core → better FR discrimination). Measured motivation: SoftSign, being bounded, craters the corruption gate (19.5%/11% at p95/p99.5 knees vs signed_cbrt's 63%) because extreme corruption saturates to the same value as large honest features. SoftClip's unbounded log tail is the fix. - #[non_exhaustive] variant SoftClip; optional params [knee, soft] (default 1,1; each ≤0 falls back to 1) - apply / apply_with_params / from_token / as_token wired - all_variants()/sample_params() updated; 3 tests (identity core, odd, unbounded, strictly-monotone-across-knee, C¹-at-knee, param fallbacks). 145 tests green.
1 parent 46d0fc9 commit 7cfca7e

1 file changed

Lines changed: 124 additions & 1 deletion

File tree

zenpredict/src/feature_transform.rs

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,17 @@ pub enum FeatureTransform {
203203
/// `scale` (saturation onset); `params = []` uses `scale = 1`.
204204
/// Added 2026-07-24 for the smooth-saturating "ideal" zensim model.
205205
SoftSign,
206+
/// Smooth soft-clip (Huber-log winsor): IDENTITY for `|x| ≤ knee`, then a
207+
/// concave, strictly-monotone, UNBOUNDED log tail — `C¹`-matched at the
208+
/// knee (slope 1 on both sides), so no kink/flat/step. Unlike `SoftSign`
209+
/// the tail is UNBOUNDED, so it preserves the negative-tail ORDER the
210+
/// corruption gate needs (a pathological feature stays > any honest one);
211+
/// unlike `WinsorP99` it is smooth (no 0-derivative flat), so the runtime
212+
/// diffmap `s_k` stays clean; unlike `SignedCbrt` the honest in-range bulk
213+
/// passes through undistorted (identity core → better FR discrimination).
214+
/// Two optional params `[knee, soft]` (defaults `1, 1`); each `≤ 0` falls
215+
/// back to 1. Added 2026-07-24 as the frontier clean-model transform.
216+
SoftClip,
206217
}
207218

208219
/// Two-pi constant for sinusoidal embedding. Mirrors `core::f32::consts::TAU`
@@ -258,6 +269,42 @@ fn soft_sign(x: f32, scale: f32) -> f32 {
258269
x / (s + ax)
259270
}
260271

272+
/// Soft-clip (smooth "Huber-log winsor"): IDENTITY inside `|x| ≤ knee`, then a
273+
/// concave, strictly-monotone, UNBOUNDED log tail outside it —
274+
/// `f(x) = x` for `|x| ≤ knee`
275+
/// `f(x) = sign(x)·(knee + soft·ln(1 + (|x|−knee)/soft))` for `|x| > knee`
276+
/// The tail is `C¹`-matched at the knee (both sides have slope 1), so `f` is
277+
/// smooth everywhere (continuous derivative — no kink, no flat, no step). Two
278+
/// properties the corruption/diffmap gauntlet needs, that neither `winsor`
279+
/// (flat tail → 0-derivative → breaks the diffmap `s_k`) nor `soft_sign`
280+
/// (BOUNDED tail → saturates corruption to the same value as large honest
281+
/// features → destroys the negative-tail ORDER) provides:
282+
/// 1. **identity core** — honest in-range features pass through undistorted,
283+
/// so honest FR discrimination is preserved (better than `signed_cbrt`,
284+
/// which compresses even the bulk);
285+
/// 2. **unbounded monotone tail** — a pathological (corruption) feature stays
286+
/// strictly larger than any honest one, so `score(corruption) < score(q20)`
287+
/// holds; the log just shrinks the margin, never the order.
288+
/// `knee ≤ 0` falls back to `knee = 1`; `soft ≤ 0` falls back to `soft = 1`.
289+
fn soft_clip(x: f32, knee: f32, soft: f32) -> f32 {
290+
let k = if knee > 0.0 { knee } else { 1.0 };
291+
let s = if soft > 0.0 { soft } else { 1.0 };
292+
let sign = if x >= 0.0 { 1.0 } else { -1.0 };
293+
#[cfg(feature = "std")]
294+
let ax = x.abs();
295+
#[cfg(not(feature = "std"))]
296+
let ax = libm::fabsf(x);
297+
if ax <= k {
298+
x
299+
} else {
300+
#[cfg(feature = "std")]
301+
let tail = s * ((ax - k) / s).ln_1p();
302+
#[cfg(not(feature = "std"))]
303+
let tail = s * libm::log1pf((ax - k) / s);
304+
sign * (k + tail)
305+
}
306+
}
307+
261308
/// Yeo-Johnson power transform. Smooth across the entire real line;
262309
/// `λ = 1` reduces to a constant-shifted identity (`y = x`), `λ = 0`
263310
/// falls to `ln(1 + x)` for non-negative input, `λ = 2` falls to
@@ -452,6 +499,7 @@ impl FeatureTransform {
452499
Self::YeoJohnson => x,
453500
Self::WinsorP99 | Self::QuantileBins => x,
454501
Self::SoftSign => soft_sign(x, 1.0),
502+
Self::SoftClip => soft_clip(x, 1.0, 1.0),
455503
// Scalar `apply` cannot represent an expander variant. The
456504
// bake-load + runtime paths route through
457505
// `apply_expanding` / `apply_feature_pipeline_expanding` /
@@ -611,6 +659,11 @@ impl FeatureTransform {
611659
yeo_johnson(x, lambda)
612660
}
613661
Self::SoftSign => soft_sign(x, params.first().copied().unwrap_or(1.0)),
662+
Self::SoftClip => soft_clip(
663+
x,
664+
params.first().copied().unwrap_or(1.0),
665+
params.get(1).copied().unwrap_or(1.0),
666+
),
614667
// Sinusoidal is an expander — see `apply` for the rationale
615668
// on why this panics instead of returning a degenerate scalar.
616669
Self::Sinusoidal => panic!(
@@ -749,6 +802,7 @@ impl FeatureTransform {
749802
"yeo_johnson" => Ok(Self::YeoJohnson),
750803
"sinusoidal" => Ok(Self::Sinusoidal),
751804
"soft_sign" => Ok(Self::SoftSign),
805+
"soft_clip" => Ok(Self::SoftClip),
752806
_ => Err(PredictError::UnknownFeatureTransform),
753807
}
754808
}
@@ -773,6 +827,7 @@ impl FeatureTransform {
773827
Self::YeoJohnson => "yeo_johnson",
774828
Self::Sinusoidal => "sinusoidal",
775829
Self::SoftSign => "soft_sign",
830+
Self::SoftClip => "soft_clip",
776831
}
777832
}
778833
}
@@ -1461,7 +1516,7 @@ mod tests {
14611516
/// Returns every FeatureTransform variant currently in the enum.
14621517
/// Keep this in sync with the enum when adding new variants —
14631518
/// the universal tests below iterate this list.
1464-
fn all_variants() -> [FeatureTransform; 16] {
1519+
fn all_variants() -> [FeatureTransform; 17] {
14651520
[
14661521
FeatureTransform::Identity,
14671522
FeatureTransform::Log,
@@ -1479,6 +1534,7 @@ mod tests {
14791534
FeatureTransform::ClipThenLog1pThenWinsor,
14801535
FeatureTransform::YeoJohnson,
14811536
FeatureTransform::SoftSign,
1537+
FeatureTransform::SoftClip,
14821538
]
14831539
}
14841540

@@ -1506,6 +1562,10 @@ mod tests {
15061562
// fallback (scale = 1) is exercised by all_variants(), so a
15071563
// non-trivial scale here checks apply_with_params too.
15081564
FeatureTransform::SoftSign => &[2.0_f32],
1565+
// SoftClip takes optional [knee, soft]; non-trivial values here
1566+
// check apply_with_params (the no-param [1,1] path is in
1567+
// all_variants()).
1568+
FeatureTransform::SoftClip => &[2.0_f32, 1.5],
15091569
// Sinusoidal is an expander (panics under scalar apply),
15101570
// so it is excluded from `all_variants()` and never reaches
15111571
// these scalar NaN-safety helpers. This arm only keeps the
@@ -1693,6 +1753,69 @@ mod tests {
16931753
assert!((s0 - s1).abs() < 1e-6);
16941754
}
16951755

1756+
// -----------------------------------------------------------------
1757+
// SoftClip (smooth Huber-log winsor — identity core + unbounded tail)
1758+
// -----------------------------------------------------------------
1759+
1760+
#[test]
1761+
fn soft_clip_token_round_trip_and_flags() {
1762+
let t = FeatureTransform::from_token("soft_clip").expect("parse");
1763+
assert_eq!(t, FeatureTransform::SoftClip);
1764+
assert_eq!(t.as_token(), "soft_clip");
1765+
assert!(!t.requires_params(), "knee/soft are optional (default 1,1)");
1766+
assert!(!t.is_expander());
1767+
assert_eq!(t.output_arity(&[]), 1);
1768+
assert_eq!(t.output_arity(&[2.0, 1.5]), 1);
1769+
}
1770+
1771+
#[test]
1772+
fn soft_clip_is_identity_in_core_odd_monotone_unbounded_and_C1() {
1773+
// knee=2, soft=1
1774+
let f = |x: f32| FeatureTransform::SoftClip.apply_with_params(x, &[2.0, 1.0]);
1775+
// Identity inside the core |x| ≤ 2.
1776+
for &x in &[-2.0_f32, -1.3, -0.1, 0.0, 0.1, 1.3, 2.0] {
1777+
assert!((f(x) - x).abs() < 1e-6, "core not identity at {x}: {}", f(x));
1778+
}
1779+
// Odd.
1780+
for &x in &[0.5_f32, 2.0, 5.0, 100.0] {
1781+
assert!((f(-x) + f(x)).abs() < 1e-5, "not odd at {x}");
1782+
}
1783+
// Just past the knee: f(2+δ) = 2 + ln(1+δ). Reference formula.
1784+
for &a in &[2.5_f32, 4.0, 10.0, 1000.0] {
1785+
let want = 2.0 + (a - 2.0).ln_1p();
1786+
assert!((f(a) - want).abs() < 1e-4, "tail wrong at {a}: {} vs {want}", f(a));
1787+
}
1788+
// UNBOUNDED (unlike soft_sign): grows without a ceiling — this is what
1789+
// keeps the corruption-tail ORDER. f(1e6) ≫ f(100) ≫ knee.
1790+
assert!(f(1e6) > f(100.0) && f(100.0) > f(10.0) && f(10.0) > 2.0);
1791+
assert!(f(1e6) > 10.0, "log tail still climbs at extreme x");
1792+
// Strictly monotone across the knee (no flat, no step).
1793+
let mut prev = f(-1000.0);
1794+
for i in -2000..=2000 {
1795+
let cur = f(i as f32 * 0.02);
1796+
assert!(cur > prev, "not strictly monotone near {}", i as f32 * 0.02);
1797+
prev = cur;
1798+
}
1799+
// C¹ at the knee: numerical slope just inside ≈ just outside ≈ 1.
1800+
let eps = 1e-3_f32;
1801+
let din = (f(2.0) - f(2.0 - eps)) / eps;
1802+
let dout = (f(2.0 + eps) - f(2.0)) / eps;
1803+
assert!((din - 1.0).abs() < 1e-2 && (dout - 1.0).abs() < 1e-2,
1804+
"slope discontinuity at knee: in={din} out={dout}");
1805+
}
1806+
1807+
#[test]
1808+
fn soft_clip_param_fallbacks_guard_garbage_screen_entries() {
1809+
// No params → knee=1, soft=1: identity up to 1, log tail after.
1810+
let d = FeatureTransform::SoftClip.apply(0.5);
1811+
assert!((d - 0.5).abs() < 1e-6);
1812+
let d2 = FeatureTransform::SoftClip.apply(3.0); // 1 + ln(1+2)
1813+
assert!((d2 - (1.0 + 2.0_f32.ln_1p())).abs() < 1e-5);
1814+
// Non-positive knee/soft fall back to 1.
1815+
let g = FeatureTransform::SoftClip.apply_with_params(3.0, &[0.0, -5.0]);
1816+
assert!((g - d2).abs() < 1e-5, "knee≤0/soft≤0 should fall back to 1,1");
1817+
}
1818+
16961819
// -----------------------------------------------------------------
16971820
// Sinusoidal embedding (expander variant)
16981821
// -----------------------------------------------------------------

0 commit comments

Comments
 (0)