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 99c8b17..913f440 100644 Binary files a/tests/regression/_golden/ple_supervised.npz and b/tests/regression/_golden/ple_supervised.npz differ