Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 31 additions & 26 deletions pretab/encoding/numerical/ple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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_))
Expand Down Expand Up @@ -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``.
Expand All @@ -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)

Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/encoding/numerical/test_ple_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion tests/regression/_golden/ple_supervised.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@
"cat_cat_int_3",
"cat_cat_int_4"
]
}
}
Binary file modified tests/regression/_golden/ple_supervised.npz
Binary file not shown.
Loading