Summary
All three temporal transformers stack their output in block-major order (all features
for lag 1, then all features for lag 2, ...) but report feature names in feature-major
order (all lags of feature A, then all lags of feature B). For any input with more than one
column, get_feature_names_out() mislabels every column except the first and last.
Affects LagFeatureTransformer, RollingStatsTransformer, and CyclicalTimeTransformer.
Reproduction
import numpy as np
from pretab.transformers import (
CyclicalTimeTransformer,
LagFeatureTransformer,
RollingStatsTransformer,
)
# two features that are trivially distinguishable: B is always 100x A
X = np.column_stack([np.arange(8.0), np.arange(8.0) * 100])
t = LagFeatureTransformer(n_lags=2).fit(X)
print("Lag names:", list(t.get_feature_names_out(["A", "B"])))
print("Lag row 0:", t.transform(X)[0], "<- actual order lag1_A, lag1_B, lag2_A, lag2_B")
t = RollingStatsTransformer(window_size=3, stats=("mean", "max")).fit(X)
print("Roll names:", list(t.get_feature_names_out(["A", "B"])))
print("Roll row 0:", t.transform(X)[0], "<- actual order mean_A, mean_B, max_A, max_B")
X2 = np.column_stack([np.arange(8.0), np.arange(8.0)])
t = CyclicalTimeTransformer(period=8).fit(X2)
print("Cyc names:", list(t.get_feature_names_out(["A", "B"])),
"<- actual order sin_A, sin_B, cos_A, cos_B")
Lag names: ['A_lag0', 'A_lag1', 'B_lag0', 'B_lag1']
Lag row 0: [ 1. 100. 0. 0.] <- actual order lag1_A, lag1_B, lag2_A, lag2_B
Roll names: ['A_roll0', 'A_roll1', 'B_roll0', 'B_roll1']
Roll row 0: [ 1. 100. 2. 200.] <- actual order mean_A, mean_B, max_A, max_B
Cyc names: ['A_cyclic0', 'A_cyclic1', 'B_cyclic0', 'B_cyclic1'] <- actual order sin_A, sin_B, cos_A, cos_B
Column 1 of the lag output holds 100.0, which is feature B's lag-1 value, but is named
A_lag1. Likewise column 1 of the rolling output is mean(B) but is named A_roll1.
Expected
get_feature_names_out()[j] describes the data actually in column j.
Actual
Names and data disagree for every multi-column input. Single-column input happens to be
correct, which is presumably why the existing tests pass.
Root cause
The transforms stack block-major:
pretab/transformers/temporal/lag.py:61-62
lagged = [X[self.n_lags - i: -i or None] for i in range(1, self.n_lags + 1)] then
np.hstack(lagged) — each element is (rows, n_features), so columns run
lag1_f0, lag1_f1, ..., lag2_f0, ...
pretab/transformers/temporal/rolling_stats.py:64-83 — results holds one
(rows, n_features) block per stat, hstacked in stat order.
pretab/transformers/temporal/cyclic.py:62-64 — np.hstack([sin, cos]), each
(rows, n_features).
But _output_sizes returns a per-feature count (lag.py:64-65,
rolling_stats.py:85-86, cyclic.py:66-67), and
BasePreTabTransformer.get_feature_names_out (pretab/core/base.py:48-58) walks
zip(input_features, self._output_sizes()) emitting {feature}_{suffix}{j} — i.e.
feature-major.
Suggested fix
Either reorder the data to feature-major, or override get_feature_names_out to match the
block-major layout. Overriding the names is the smaller, non-breaking change:
# LagFeatureTransformer
def get_feature_names_out(self, input_features=None):
check_is_fitted(self, "n_features_in_")
if input_features is None:
input_features = [f"x{i}" for i in range(self.n_features_in_)]
return np.asarray(
[f"{feat}_lag{lag}" for lag in range(1, self.n_lags + 1) for feat in input_features],
dtype=object,
)
with the analogous version for RollingStatsTransformer (outer loop over self.stats,
naming {feat}_{stat}) and CyclicalTimeTransformer (outer loop over ("sin", "cos")).
Naming the rolling columns after the statistic and the cyclic columns sin/cos would also
be more informative than the current roll0 / cyclic0 suffixes.
A regression test asserting the name/data correspondence on a 2-column input (e.g. the
B = 100 * A trick above) would cover all three.
Note these three transformers are explicitly documented as standalone utilities that are not
wired into Preprocessor, so the blast radius is limited to direct users — but
get_feature_names_out is exactly what those users call to interpret the output.
Impact
LagFeatureTransformer, RollingStatsTransformer, CyclicalTimeTransformer with two or
more input columns. All three are public and listed in docs/api/index.rst. The
Preprocessor is not affected.
Environment
- pretab 0.1.0 (
main @ 51c3043)
- Python 3.11.15, numpy 2.4.6, pandas 2.3.3, scikit-learn 1.9.0, scipy 1.17.1
- macOS (darwin 25.5.0)
Summary
All three temporal transformers stack their output in block-major order (all features
for lag 1, then all features for lag 2, ...) but report feature names in feature-major
order (all lags of feature A, then all lags of feature B). For any input with more than one
column,
get_feature_names_out()mislabels every column except the first and last.Affects
LagFeatureTransformer,RollingStatsTransformer, andCyclicalTimeTransformer.Reproduction
Column 1 of the lag output holds
100.0, which is feature B's lag-1 value, but is namedA_lag1. Likewise column 1 of the rolling output ismean(B)but is namedA_roll1.Expected
get_feature_names_out()[j]describes the data actually in columnj.Actual
Names and data disagree for every multi-column input. Single-column input happens to be
correct, which is presumably why the existing tests pass.
Root cause
The transforms stack block-major:
pretab/transformers/temporal/lag.py:61-62lagged = [X[self.n_lags - i: -i or None] for i in range(1, self.n_lags + 1)]thennp.hstack(lagged)— each element is(rows, n_features), so columns runlag1_f0, lag1_f1, ..., lag2_f0, ...pretab/transformers/temporal/rolling_stats.py:64-83—resultsholds one(rows, n_features)block per stat, hstacked in stat order.pretab/transformers/temporal/cyclic.py:62-64—np.hstack([sin, cos]), each(rows, n_features).But
_output_sizesreturns a per-feature count (lag.py:64-65,rolling_stats.py:85-86,cyclic.py:66-67), andBasePreTabTransformer.get_feature_names_out(pretab/core/base.py:48-58) walkszip(input_features, self._output_sizes())emitting{feature}_{suffix}{j}— i.e.feature-major.
Suggested fix
Either reorder the data to feature-major, or override
get_feature_names_outto match theblock-major layout. Overriding the names is the smaller, non-breaking change:
with the analogous version for
RollingStatsTransformer(outer loop overself.stats,naming
{feat}_{stat}) andCyclicalTimeTransformer(outer loop over("sin", "cos")).Naming the rolling columns after the statistic and the cyclic columns
sin/coswould alsobe more informative than the current
roll0/cyclic0suffixes.A regression test asserting the name/data correspondence on a 2-column input (e.g. the
B = 100 * Atrick above) would cover all three.Note these three transformers are explicitly documented as standalone utilities that are not
wired into
Preprocessor, so the blast radius is limited to direct users — butget_feature_names_outis exactly what those users call to interpret the output.Impact
LagFeatureTransformer,RollingStatsTransformer,CyclicalTimeTransformerwith two ormore input columns. All three are public and listed in
docs/api/index.rst. ThePreprocessoris not affected.Environment
main@ 51c3043)