From 79fcab8f8fb38264d6779e4672584cf1141d7b51 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 12:19:11 +0200 Subject: [PATCH] fix(ple): remove boundary-bin discontinuity in PLETransformer --- pretab/encoding/numerical/ple.py | 57 ++++++++++-------- .../numerical/test_ple_transformer.py | 56 +++++++++++++++++ tests/regression/_golden/ple_supervised.json | 2 +- tests/regression/_golden/ple_supervised.npz | Bin 4583 -> 4624 bytes 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/pretab/encoding/numerical/ple.py b/pretab/encoding/numerical/ple.py index 5831d60..81a639c 100644 --- a/pretab/encoding/numerical/ple.py +++ b/pretab/encoding/numerical/ple.py @@ -70,6 +70,10 @@ class PLETransformer( ---------- thresholds_ : list of ndarray Sorted threshold values for each feature. + edges_ : list of ndarray + Full per-feature bin-edge vector, ``[x_min, *thresholds, x_max]`` from the + training data, used to normalize every bin (including the first and last) + to ``[0, 1]``. n_features_in_ : int Number of features seen during ``fit``. n_bins_per_feature_ : list of int @@ -170,6 +174,7 @@ def fit(self, X, y=None): self.n_features_in_ = X.shape[1] self.thresholds_ = [] + self.edges_ = [] self.n_bins_per_feature_ = [] n_bins = self._resolve_param("output_dim", default=6) @@ -203,6 +208,7 @@ def fit(self, X, y=None): thresholds = adapter.get_thresholds(X[:, i], y, min_thresholds, max_thresholds) self.thresholds_.append(thresholds) + self.edges_.append(np.concatenate(([X[:, i].min()], thresholds, [X[:, i].max()]))) self.n_bins_per_feature_.append(len(thresholds) + 1) self.total_output_dim_ = int(sum(self.n_bins_per_feature_)) @@ -242,20 +248,24 @@ def transform(self, X): for col in range(X.shape[1]): feature = X[:, col].copy() thresholds = self.thresholds_[col] + edges = self.edges_[col] - ple_encoded = self._apply_piecewise_linear_vectorized(feature, thresholds) + ple_encoded = self._apply_piecewise_linear_vectorized(feature, thresholds, edges) all_transformed.append(ple_encoded) return np.hstack(all_transformed).astype(np.float32) - def _apply_piecewise_linear_vectorized(self, feature: np.ndarray, thresholds: np.ndarray) -> np.ndarray: + def _apply_piecewise_linear_vectorized( + self, feature: np.ndarray, thresholds: np.ndarray, edges: np.ndarray + ) -> np.ndarray: """Apply the vectorized piecewise linear encoding for one feature. - The encoding for each sample works as follows: - - - First bin (below ``thresholds[0]``): the raw value. - - Middle bins: the value normalized to ``[0, 1]`` within the bin. - - Last bin (above ``thresholds[-1]``): the raw value. + Every bin, including the first and last, is normalized to ``[0, 1]`` against + its own ``[lower, upper)`` edge (the fitted training range stands in for the + missing outer threshold on the two boundary bins), so the encoding is + continuous at every threshold, not just the interior ones. Values outside + the fitted ``[edges[0], edges[-1]]`` range are clipped into ``[0, 1]`` + rather than left unbounded. Every bin below the active bin is filled with ``1.0``; bins above it stay ``0.0``. @@ -264,7 +274,13 @@ def _apply_piecewise_linear_vectorized(self, feature: np.ndarray, thresholds: np n_bins = len(thresholds) + 1 if len(thresholds) == 0: - return feature.reshape(-1, 1).astype(np.float32) + lower, upper = edges[0], edges[-1] + width = upper - lower + if width > 1e-10: + values = np.clip((feature - lower) / width, 0.0, 1.0) + else: + values = np.full(n_samples, 0.5) + return values.reshape(-1, 1).astype(np.float32) ple_encoded = np.zeros((n_samples, n_bins), dtype=np.float32) @@ -277,27 +293,16 @@ def _apply_piecewise_linear_vectorized(self, feature: np.ndarray, thresholds: np continue values = feature[mask] + lower_edge = edges[bin_idx] + upper_edge = edges[bin_idx + 1] + bin_width = upper_edge - lower_edge - if bin_idx == 0: - # First bin: raw value, no lower bins to fill. - ple_encoded[mask, bin_idx] = values - - elif bin_idx == n_bins - 1: - # Last bin: raw value, all lower bins set to 1. - ple_encoded[mask, bin_idx] = values - ple_encoded[mask, :bin_idx] = 1.0 - + if bin_width > 1e-10: + ple_encoded[mask, bin_idx] = np.clip((values - lower_edge) / bin_width, 0.0, 1.0) else: - # Middle bin: normalize the value to [0, 1] within the bin. - lower_threshold = thresholds[bin_idx - 1] - upper_threshold = thresholds[bin_idx] - bin_width = upper_threshold - lower_threshold - - if bin_width > 1e-10: - ple_encoded[mask, bin_idx] = (values - lower_threshold) / bin_width - else: - ple_encoded[mask, bin_idx] = 0.5 + ple_encoded[mask, bin_idx] = 0.5 + if bin_idx > 0: ple_encoded[mask, :bin_idx] = 1.0 return ple_encoded diff --git a/tests/encoding/numerical/test_ple_transformer.py b/tests/encoding/numerical/test_ple_transformer.py index 0570df4..e5c7cfa 100644 --- a/tests/encoding/numerical/test_ple_transformer.py +++ b/tests/encoding/numerical/test_ple_transformer.py @@ -142,3 +142,59 @@ def test_ple_feature_names_out(): assert len(names) == transformer.get_n_features_out() assert all("_ple" in name for name in names) assert names[0].startswith("age") + + +def test_ple_is_bounded_in_zero_one(): + # Every column, including the first/last (boundary) bins, must stay in + # [0, 1]: no more raw, unbounded feature values leaking into the encoding. + rng = np.random.RandomState(11) + X = rng.uniform(-50.0, 500.0, size=(200, 1)) + y = rng.rand(200) + + transformer = PLETransformer(output_dim=5).fit(X, y) + Xt = transformer.transform(X) + + assert Xt.min() >= 0.0 + assert Xt.max() <= 1.0 + + +def test_ple_is_continuous_at_every_threshold(): + # Regression test: rc3 had a large discontinuity right at each learned + # threshold, because the first/last bins held the raw feature value while + # the middle bins were normalized to [0, 1]. Sweeping a fine grid across + # every threshold must never show a jump bigger than a couple of grid + # steps' worth of change. + rng = np.random.RandomState(12) + X = np.linspace(0.0, 100.0, 4000).reshape(-1, 1) + y = X.ravel() + rng.normal(0, 0.5, size=4000) + + transformer = PLETransformer(output_dim=5).fit(X, y) + Xt = transformer.transform(X) + + step = X[1, 0] - X[0, 0] + jumps = np.abs(np.diff(Xt, axis=0)).max(axis=1) + # A continuous, piecewise-linear ramp changes by roughly step / bin_width + # per sample; allow a generous multiple of the grid step as the ceiling so + # this only fails on a real discontinuity, not normal ramp slope. + assert jumps.max() < 50 * step, f"largest consecutive jump was {jumps.max()!r}" + + +def test_ple_boundary_bins_ramp_like_middle_bins(): + # The first and last bins must use the same [0, 1] ramp formula as the + # middle bins (against the training [x_min, x_max] edge), not a raw value. + X = np.linspace(0.0, 30.0, 3000).reshape(-1, 1) + y = X.ravel() + + transformer = PLETransformer(output_dim=3, task="regression").fit(X, y) + thresholds = transformer.thresholds_[0] + assert len(thresholds) >= 1 + + first_threshold = thresholds[0] + just_below = np.array([[first_threshold - 1e-3]]) + encoded = transformer.transform(just_below) + # Approaching the first threshold from below, the first column should be + # close to 1.0 (the top of its own ramp). A tight tolerance matters here: + # the old, buggy raw-value encoding would also happen to exceed a loose + # bound like "> 0.9" for a threshold this large, without actually being + # close to 1.0. + assert encoded[0, 0] == pytest.approx(1.0, abs=1e-2) diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json index e74feb2..c5abed1 100644 --- a/tests/regression/_golden/ple_supervised.json +++ b/tests/regression/_golden/ple_supervised.json @@ -28,4 +28,4 @@ "cat_cat_int_3", "cat_cat_int_4" ] -} +} \ No newline at end of file diff --git a/tests/regression/_golden/ple_supervised.npz b/tests/regression/_golden/ple_supervised.npz index 99c8b1721611b253270001905f36a719793b5eee..913f440439f68f965dbe3709c59363e0a95632b0 100644 GIT binary patch delta 4588 zcmV@EdT%j2mk;8AppW7%Q}$~B7cSv00000006yRdwdU97XP7L ztw+_fL9-p!dQ@73rEDJ3Z(RO*hn7{<-oGta>9cFhv9xuUu^yzJJ;e-1^8>->~Y7^P-*EBKT4T2`IY ztoBNy;8P_zAKj!)sQZo7cqg4c%X;5de5-?k{R*|S>3f$KI#HLl#POr^px%&+9`TdM zJAdrVZjpY?14Wlb=+xR+JJ(PC=O%kze&=btCtW+j9Pj&GawPd48~2Sxdu4E&H98z- z;;C4EyX5?Y@{|3Lc4p#}{}TCsdP3SBFXPFpS9@h1@I}to?v;Va&snLwem&u~`QRZ> z^u6ZBc@ljq{llSr9Lfig_F85#jZSI(6Mu6nsWDSO(U*)|C^$da<876O>YHRaU(k6m zi=XJL-(KXAUWo0rr{eM;_bH3l75VXAl%zsjACw#Kuro(J;i(vm^BI)>LyUPo(K#yT z-|dkvgIm9sP1CO^`oM%se#%W-ULN-{-p2ANOG)FcX7;1$UC{oSN#EJLKuDfE+kcHm zS13Iv_r~=Ayd5+l?QJ=KQUtg1QwmSMI_+wn1j%6^HhkR*m@Ony~xAk z=0=mUdmg^lHz$7Yk-pjb;P7`v-+w1rKdC?V95TLj8}P;yYIlV;ms?22EE(ycq@{OT`arsT=H&)bDKj}u>-4)mS zdHtgKwjpT2ykjC{n~X}@m_vV$2huL!L#l#Cisy9^N* z;TlQydPmfLhsv93)0U8)rvwiY(vJnoA0(|us_6Vd>80T|oX*jCdt%0ilKnH2y-wrV zDQW#W{O{(x&c(L*2pwG;eKh(nTM~HggHDp?}>lAxgLLTnG9|v^eM94{>J;f{Y5GH zj+aLABPk+qt8od2RvR*Y~A-=!=f4S7LMAtj+wUt`H^e<4Jjun%ugQr2| z6Eu-=-$;Ybe^mah$;R#eD4o>*o@Wn}<>L9Y}A*vqL)L z7aG4$=Kon0?-1vK08U!H6dwKYI9z{6>Evo2O;48PBS^U(GJgoP|3S_3!j3(Btu$^c zYgZHdKYencegNpY;FgZ>53%)_?Q<4m<)e3zL@5dd?eP}d=R!y@3|f%_skd4LH1Joz5twl96XUR(EW7s`~SSATj+={!F&9!}zYTKqu}ZsYn%kiM&S{u|HVk$9g#y(@P>^G&H5 z!L!4Y>3?YBBGc#j>6E9pg4%n@bWrw}d<5tCzd-#Jc=(t&Y|igq^D_H9<;mwC;RLTYvXM6A`x_GDvDYV0C{6w_iHrp!J6O znR=2tW_S&dpR@8}49H$#q>X|4eIWM=3(W$ewg*f#1N)`fm(N>ko?fbcM;vshn6GtR%!vyF9My9o!imY(d2QS zw0{zM9#5!T$xMRU!N~c_^n6ymSv3#WH%0XkV!4(Z4w5J4;47eUQ@?n_>%KgUz6)-E zaMN;a#0AiKZZ)_C-p&(l>qZ(#@35ZlC$yfkUfv?>ug+_SJV!~0`b1>DDb+^c`WTA) zZf)B7$QSnqOx34gk@)(<)W)H}EfNAb?(qxtogM1N$XYgwrN z#Y?>R;>@p}J&AS${pcDYd8ysrx=B($k)ji8-Ei9TocbXZMB@%P_w{HQRucT%&D9Dy zymdD!PtH#x`pp=B?Z`XV>5KkdoVVS}J~zJO2x$MxXwwjSUXO>1*WMYw;rTQ_7k{Qa zQR>BQM6O3Cm6SgL5syBTda$Xz=UXmK;;^zk?(AKlgd^sI()|9&cz2y8>fdgz%2K*wg{ zo1$?FX#ESdxA_TeUxSI;*fw(+^M949M6=KM!Ut_VRtUHD*4MdgSZGc$NTMAlx?pX2-ePT`g0n zKNojgsS@!kaC3XT!^Hgvo)asG>v=ra`hO*RZU7|bwfuE7`!6~V>U@_$YNuxnmyM4j z+7882IEuF4P3CWNYZ2q&nGsugaT9%&JGQ)}?RPWhrFA(1*>A8)g@1XCS1C3x>HFOd z9sPV0X8*o>wWRFcc)2q#E95Y7({kf_=!}Q2%{PAM*S;$VhrOOB%en8i37y|j-<`_i zmoz-0<<7X(i(tw(E4U&r{*r^c@;`Xz(rCD(#@%dMfqiZmZ+||Ew!i1#Z+6RTyd2PY z2iops;#PXS2s#g{mVem=^{;^@CjV9;iZl|A>I=`XkejF=!A1{&TTb&6>toi2V%Ar??dVj2b|1sVk?j+?8Cr-RPR1>RnU@6(d!!C{Rj&uG`QM5dD!S-4)r9`79s zjw1hi+78)xkAG5l-Q3JNBI-|!^s5g1;8)d8it{Zual5ti9$Ng7sh?Wam&u+Z5v?cF z^ zi2qO9-Vy4r3B?D(trum3&V%Y(576Qepzp8I4CcNl0DmV4w-L7o*Pqz(vcvMPiRDRC zQhlZmCLZ%<0e=3~!7(mV%HO^?e=ZS^Gv_OPTceAZ`+JYHe{;*XNcl6vpmA?S>4Jv` zu{T)Td&$KKuTd2gFGHYT@^f7N9sUGv-y^p>bzvr?omN>9^;?pnZ)L*MxZQ(EC&toa z*$-cI%2!=RlR<{Mh^EAbwF_l)K`CD?l+jjg1nyV z2H|yct!pFNJ`zeFH9te-s_|pE=zasOUe;?X?WZcuc6XUUH3GtrJ^9H^=4*kxaoL+ULFEb7=o~viO?Io34mB;hG z&n7zV?0FV6kQpBY&$W@c8LWE!_IBDh$o>y9?!H6u^)327uC&1Q5@vsHuHIQh`;MY7 zbbk_<7e_pAp|3wqJ14{JPw!ZbCii`n^MRA2ar*~({7#j|?XGNk>c8haRt`W@)Qo_G7p z{S&$FnIU%$xld!SdWsnTKE4?73$$jP+b4uO|F^L3ZuyK`k5l%u>fPL2+vNzepMUjN zXmJWf6NnF5x2_`b>#Tw4Lg(zwPV0Zw^}L&_V>cawv{QQYwUF`c7wOmci>2nJQD!po z{?#=KXkHpEYI{A$$g5|MK$X%tF1P|;>UiL z4~;YC3vMgr8fq8OEKoel;`_gN2Y>2^%qwR;2hY`-TTig+%`%mR?6xJ1w;0#_#FNPX z_#j3o&Y3!?CiGmUulh-#$_k75KJG>0@{l|quik*hTkvJv%|j_ny}CHR^l=KpXZH8; z>pO2kc->r|6@QvlZ&?MzeG+wh6X?D&6sHh=rEk<3=KHvmj@uW||5g4r(|^xQ0xt-+ z^=y|1c6hzkQBU8sGmAcg;`fEVjgv2;-?hBEmgz^YbxafW7>D#LRP(56b|y6FzXzq$sbXE z2;zgj}db{NVp!^e7!$L~q*s6P*SFuvAL2}0kUv-Bp= z@9*+(O3}Bq{#D|4j2>yXX9*r}-ziTRBD>Gv3AYg+`Y%?!zGy=V)GrV%2M91I+y^_kRIUO928c11$gm00;m8 W03iUvBg;C5ldcgn2Eq{l0001qeu}pM delta 4547 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