Skip to content

fix(temporal): output column names do not match column order #15

Description

@ChrisW09

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-83results holds one
    (rows, n_features) block per stat, hstacked in stat order.
  • pretab/transformers/temporal/cyclic.py:62-64np.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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions