Skip to content

Add Time Series Feature Engineering Support to BigFeat - #4

Open
MohannadAK wants to merge 64 commits into
masterfrom
feature/time-series-ops
Open

Add Time Series Feature Engineering Support to BigFeat#4
MohannadAK wants to merge 64 commits into
masterfrom
feature/time-series-ops

Conversation

@MohannadAK

Copy link
Copy Markdown
Collaborator

Summary

This PR adds comprehensive time series feature engineering capabilities to BigFeat while maintaining 100% backward compatibility with the existing implementation. When time series features are disabled (default), the library behaves identically to the original version.

Motivation

  • Gap in Time Series Support: Original BigFeat lacked temporal awareness for time series data
  • Growing Demand: Time series feature engineering is crucial for financial, IoT, and forecasting applications
  • Preserve Existing Functionality: Ensure zero breaking changes for current users
  • Extend Operator Set: Add powerful temporal operators while maintaining BigFeat's core philosophy

Key Features Added

Time Series Operators (15 New)

  • Rolling Operations: rolling_mean, rolling_std, rolling_min/max, rolling_median, rolling_sum
  • Temporal Transforms: lag_feature, diff_feature, pct_change, momentum
  • Advanced Analytics: ewm, seasonal_decompose, trend_feature
  • Cyclical Patterns: weekday_mean, month_mean

DateTime-Aware Processing

  • Automatic detection and handling of datetime columns
  • Time-based window operations using pandas Timedelta
  • Support for grouped time series (multiple entities in one dataset)
  • Flexible time period parsing ('7D', '30D', '3M', '1Y', etc.)

Robust Implementation

  • Intelligent fallback mechanisms for all operators
  • Comprehensive error handling and data validation
  • Memory-efficient processing for large time series
  • Clean separation between time series and standard operations

Technical Implementation

New Parameters

BigFeat(
    task_type='classification',          # Original parameter
    enable_time_series=False,            # Enable time series features
    window_sizes=['7D', '30D', '90D'],   # Rolling window sizes
    lag_periods=['1D', '7D', '14D'],     # Lag periods
    datetime_col='timestamp',            # DateTime column name
    groupby_cols=['entity_id'],          # Grouping columns
    verbose=True                         # Progress reporting
)

Smart DataFrame Handling

  • Automatically excludes datetime columns from feature engineering
  • Preserves temporal information for time-based operations
  • Falls back to numpy arrays when DataFrames not provided
  • Handles mixed data types gracefully

Backward Compatibility

Zero Breaking Changes

  • Default behavior: enable_time_series=False
  • All existing methods have identical signatures
  • Same output format and data types
  • Identical results for same inputs with same random seeds

Before/After Comparison

# Original usage (unchanged)
bf = BigFeat(task_type='classification')
features = bf.fit(X, y)

# Enhanced usage (new capabilities)
bf = BigFeat(
    task_type='classification',
    enable_time_series=True,
    datetime_col='timestamp'
)
features = bf.fit(df_with_datetime, y)

Testing Strategy

Regression Testing

  • All original test cases pass unchanged
  • Same random seeds produce identical results (time series disabled)
  • Performance benchmarks maintained for standard usage
  • Memory usage comparable for non-time-series operations

New Feature Testing

  • Time series operators with various window sizes
  • Grouped time series processing
  • Edge cases (missing data, irregular intervals)
  • DataFrame vs numpy array input handling
  • Error handling and fallback mechanisms

Performance Impact

Standard Operations

  • No performance degradation when enable_time_series=False
  • Identical memory usage for existing workflows
  • Same computational complexity for original operators

Time Series Operations

  • Efficient pandas-based rolling operations
  • Vectorized computations where possible
  • Lazy evaluation to minimize memory usage
  • Intelligent caching for group operations

Usage Examples

Basic Time Series Enhancement

import pandas as pd

# Time series data
df = pd.DataFrame({
    'timestamp': pd.date_range('2020-01-01', periods=1000),
    'sales': np.random.randn(1000).cumsum(),
    'price': np.random.randn(1000) + 100
})

# Enhanced BigFeat
bf = BigFeat(
    enable_time_series=True,
    datetime_col='timestamp',
    window_sizes=['7D', '30D', '90D']
)

features = bf.fit(df, target)

Multi-Entity Time Series

# Multiple time series in one dataset
df = pd.DataFrame({
    'timestamp': pd.date_range('2020-01-01', periods=1000).repeat(3),
    'entity_id': ['A', 'B', 'C'] * 1000,
    'value': np.random.randn(3000)
})

bf = BigFeat(
    enable_time_series=True,
    datetime_col='timestamp',
    groupby_cols=['entity_id']
)

features = bf.fit(df, target)

Code Quality

Architecture

  • Clean separation of concerns between time series and standard operations
  • Modular design with dedicated time series utility methods
  • Consistent error handling patterns across all new methods
  • Comprehensive documentation and type hints

Error Handling

  • All time series operations wrapped in try-catch blocks
  • Graceful fallbacks to standard operations when time series fails
  • Data validation at multiple stages
  • Informative warning messages for debugging

Documentation

  • Comprehensive docstrings for all new methods
  • Updated README with time series examples
  • Migration guide for existing users
  • API reference for new parameters

Benefits

For Existing Users

  • Zero disruption: Continue using BigFeat exactly as before
  • Optional upgrade path: Enable time series when needed
  • Same performance: No overhead when time series disabled

For Time Series Users

  • Powerful operators: 15 new temporal feature engineering operators
  • Production ready: Robust error handling and performance optimization
  • Flexible configuration: Customizable windows and lag periods
  • Multi-entity support: Handle complex time series datasets

For the Ecosystem

  • Expanded use cases: BigFeat now applicable to time series domains
  • Maintained philosophy: Automatic feature engineering with temporal awareness
  • Research potential: New opportunities for time series feature discovery

Future Enhancements

This implementation provides a solid foundation for future time series enhancements:

  • Seasonal decomposition algorithms
  • Frequency domain features (FFT-based)
  • Advanced trend detection methods
  • Cross-series relationship features

Checklist

  • Code implementation complete
  • All existing tests pass
  • New functionality tested
  • Documentation updated
  • Performance benchmarked
  • Backward compatibility verified
  • Error handling implemented
  • Code reviewed internally

Review Focus Areas

  1. Backward Compatibility: Verify existing functionality unchanged
  2. Time Series Logic: Review temporal operation implementations
  3. Error Handling: Check robustness of fallback mechanisms
  4. Performance: Ensure no regression for standard operations
  5. Documentation: Confirm clarity of new parameters and usage

This PR transforms BigFeat into a comprehensive feature engineering tool that handles both traditional and time series data while preserving the simplicity and power of the original design.

MohannadAK and others added 23 commits August 15, 2025 17:00
requirements.txt pinned an unrelated dependency set (gplaycli,
matlink-gpapi, pyaxmlparser and friends -- an Android APK downloader),
with no numpy or pandas at all. Anyone following the README's install
instructions got a broken environment. Replace it with the library's
actual runtime dependencies, and split the benchmark-only heavyweights
(gluonts, openfe, stumpy, tsfresh, yfinance, ...) into a separate
requirements-benchmark.txt.

setup.py omitted scipy and psutil, both of which are hard imports
(bigfeat_base.py imports psutil at module scope; the window detectors
import scipy). A clean `pip install .` therefore produced an
ImportError. Note statsmodels is NOT added: it is imported only by the
benchmarking suite, not by the library.

Also untrack openfe_tmp_data.feather (20MB of OpenFE scratch data) and
ignore *.feather going forward, add a pyproject.toml declaring the build
backend and pytest config, and ignore build/ and .pytest_cache/.

The pre-existing *debug*/*reproduce*/*verify* ignore rules would shadow
a tests/ directory, so add a negation to keep the suite tracked.

Verified: `pip install .` into a fresh venv, then import and fit from a
directory containing no source, succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_feature_importances drew its row sample from the global numpy RNG
rather than self.rng, so fit() was not reproducible from random_state.

The effect was worse than simple nondeterminism: because the global RNG
dominated the sampling, random_state was very nearly inert. Measured on
the non-TS path before this change, five runs at random_state=0 produced
four distinct outputs, while five *different* seeds (0/1/2/5/1234)
produced byte-identical output. Callers who set random_state and
expected reproducibility got neither reproducibility nor seed control.

Draw from self.rng instead. self.rng is created at fit() line 2304,
before the first get_feature_importances call at 2333, so the ordering
is safe. The TS path was already deterministic (it uses TimeSeriesSplit
folds rather than random sampling) and is unaffected.

Verified on both paths: with the global RNG deliberately perturbed
between runs, a fixed random_state now yields identical output, and
each of 0/1/2/5/1234 yields different output.

This changes generated features, so any previously captured baseline is
invalid. It lands before the characterization suite for that reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Establishes the safety net for the remaining refactor. 51 tests, ~85s
full / ~58s with -m 'not slow'.

Four fixtures, each pinned to a specific _setup_time_series branch
(no-TS, pooled_ensemble, stationarity-gated restricted mode), with
test_fixtures.py asserting they still reach those branches so that
detector drift fails loudly instead of silently changing what the
behavioural tests cover.

The suite is deliberately invariant-first rather than golden-first, so
it survives intentional behaviour changes while still catching state
leaks and replay bugs. Golden digests back it up for drift detection and
can be regenerated with --regen-golden.

One test fails on purpose and is left red:

    test_transform_is_invariant_to_input_row_order[reg_ts]

It catches the _is_sorted state leak (25% of elements differ, max
absolute difference 2.06, confined to the TS-generated column). Worth
recording why the obvious formulation does NOT catch it: asserting that
two consecutive transform() calls agree passes, because _is_sorted stays
True for every call, so all of them take the early-return and are
*consistently* wrong. Row-order invariance is the observable that
actually breaks, since the early-return skips the datetime sort and
makes a row's features depend on its array position rather than its
timestamp.

Similarly, the default fit parameters leave the bug latent: a plain run
selects only one weak TS operator. The reg_heavy_ts and reg_ts_fanova
cases raise ts_operation_weight_multiplier and set selection='fAnova'
specifically to reach the affected code paths.

Phase 3 turns this test green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three coupled defects that all had to be fixed together, because each
one masked the next.

1. _prepare_time_series_data overwrote the datetime and groupby columns
   from self.original_data unconditionally, aligning them *positionally*
   onto whatever rows were passed in. Positional alignment against the
   training frame is only meaningful when the incoming rows really are
   the training rows in training order, which transform() cannot assume.
   For any other row order this paired each row with someone else's
   timestamp, so the sort produced a plausible but wrong ordering and
   _original_index no longer identified which input row an output row
   came from. Measured on shuffled input: the first chronological row
   was input row 195, but _original_index claimed row 0.

   Now these columns are only filled in where the caller's frame does
   not already supply them, and inferring them from training data
   requires a matching row count rather than silently truncating.

2. A self._is_sorted flag short-circuited the whole function. It was set
   on first use during fit() and reset only at the top of fit(), so
   every subsequent transform() returned X untouched -- skipping the
   datetime sort, the dtype coercion, and the _original_index
   bookkeeping the rest of transform() depends on. Sortedness is a
   property of the data passed in, not of the estimator, so it cannot be
   cached on self; the flag is removed rather than reset.

3. The fAnova branch sat after transform()'s time-series early-return,
   making it unreachable whenever time series was enabled: fit() applied
   SelectKBest and returned k columns while transform() returned the
   full unselected width. Column selection and row reordering are
   independent, so the selection now runs first and both paths share it.

Symptom before the fix, on a 300-row periodic series: shuffling the
input rows changed 25% of output elements (max absolute difference
2.06), and features were inconsistent with their own timestamps. With
selection='fAnova' and time series enabled, fit() returned 3 columns and
transform() returned 6.

Turns tests/test_invariants.py::test_transform_is_invariant_to_input_
row_order green; full suite 51 passed. The golden digests are unchanged,
which is the expected result: this corrects the reordered-input path
without disturbing behaviour for input already in training order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three independent correctness fixes.

1. Look-ahead leakage in weekday_mean / month_mean.

   Both operators used groupby(calendar_key).transform('mean'), which
   averages the entire column within each weekday or month. A row's
   feature value therefore depended on rows dated after it -- at
   transform() time, on rows from the future relative to the point being
   predicted. This inflated apparent performance for exactly the
   seasonal signal these operators exist to capture.

   Replaced with shift(1).expanding().mean() within each calendar group,
   so a row sees only earlier rows in its group and never itself. Rows
   with no prior observation in their group are left at 0.

   The leaky implementation existed in TWO places: the grouped path in
   _apply_time_based_operation and, for the no-groupby case, the
   datetime-indexed path in _apply_single_group_operation. The latter is
   reached by falling through the no-groups branch, which has no
   weekday/month case of its own. Fixing only the first left the default
   configuration untouched. Both are fixed here; the grouped version now
   also respects entity/block boundaries so one series cannot borrow
   calendar history from another.

2. Degenerate input crashed fit().

   ig_vector and split_vec were normalized with a bare `v /= v.sum()`.
   When every feature has zero importance -- which happens for constant
   or all-zero columns -- the sum is 0, the division yields NaN, and
   fit() died inside rng.choice with "probabilities contain NaN".
   Reproduced on 12 of 30 degenerate-input combinations.

   Added _normalize_to_distribution, which scrubs NaN/inf, clips
   negatives, and falls back to a uniform distribution when there is no
   signal, so generation proceeds instead of crashing.

3. get_paths dropped its first path.

   The dedup loop compared path_list[i] against path_list[i - 1], which
   at i == 0 wraps to the LAST element. Whenever a tree's first and last
   root-to-leaf paths matched, the first was silently discarded and
   never counted toward the split-frequency vector.

Adds tests/test_correctness.py (11 tests). Full suite 62 passed.

The golden digests are unchanged: none of the four pinned scenarios
happens to select a calendar-mean operator. That is a coverage gap in
the goldens rather than evidence the fix is inert -- the dedicated tests
verify zero leaking rows across all four operator code paths, where the
previous implementation leaked on every row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three coupled defects in the "Pre-Flight Detection Cross-Validation"
block, which exists to catch hallucinated seasonality in noisy data.

1. It never ran. The check reads self.feature_columns, but that
   attribute was not assigned until AFTER the block. On every fit it
   raised TypeError immediately and the bare `except` swallowed it,
   printing "CV Check skipped" only in verbose mode. The guard has
   therefore never executed on any dataset. Feature-column
   identification now happens before the check.

2. Its test features were computed leakily. Each side rolled
   independently, so the window restarted at the beginning of the test
   slice and test rows saw no real history -- the code carried a comment
   acknowledging this ("Rolling on test is leaky") and did it anyway.
   The rolling statistics are now computed once over the full ordered
   series and sliced, which is both correct and causal: pandas' rolling
   only looks backwards, so test rows draw on training history without
   any test row influencing an earlier one.

3. Its penalty compounded. On weak improvement it overwrote
   self.ts_operation_weight_multiplier, the constructor argument, so
   repeated fits on one estimator kept halving it -- two fits left it at
   0.25 of what the caller asked for with no way to recover short of
   rebuilding the object. The penalty is now held in a separate per-fit
   attribute behind an effective_ts_weight_multiplier property.

Also adds _reset_fit_state(), called at the top of fit(). self.operators
and self.unary_operators were extended in place with the time-series
operators (and filtered in restricted mode), guarded by a
_ts_operators_added flag that was never cleared, so a second fit
inherited the first fit's operator pool even when the periodicity
verdict differed. The pool is now rebuilt from the base definitions on
every fit.

One golden changed: reg_ts_periodic. This is expected and is the point
of the first fix. With the check finally executing it measures -0.3%
improvement from TS features on that fixture and halves the TS operator
weight, which is exactly its documented job. Shape is unchanged; only
the selected features differ. The other three goldens are untouched.

Full suite 66 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Time-series operators resolved their source column by reading
self._current_feature_index, an attribute assigned as each leaf of the
expression tree was resolved. But the operator reads it later, when its
parent node fires. Inside a binary node with two different leaves the
second leaf's index had already overwritten the first's, so both
branches operated on the same -- often wrong -- column, and which column
that was depended on evaluation order rather than on the recipe.

This was reachable in practice, not theoretical: a depth-3 recipe
mixing binary and time-series operators applied TS ops against two
different source columns in the same expression.

The consumed column is now recorded in the operator's params dict at
generation time, which is already the channel that persists into
transform(), so the recipe fully describes itself. All 15 _safe_*
operators take the index explicitly via a shared _resolve_feature_col
helper, replacing 15 copies of the same lookup. The helper still falls
back to the old attribute for the non-TS paths and for recipes stored
before the index was recorded.

For a unary operator over a deeper subtree there is no single source
column; the first leaf of that subtree is used, which matches what the
old code did when it happened to be correct and is now stated
explicitly rather than being an accident of traversal order.

Verified: transform() output is byte-identical after corrupting
_current_feature_index to a nonsense value and after deleting the
attribute entirely. Goldens unchanged.

Full suite 68 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ounts

Window sizes are detected as pd.Timedelta, but every live rolling path
converted them to a row count via _estimate_window_rows, which used
self.time_step (default 'D'). A 90-day window therefore became 90 ROWS
regardless of how the data was actually sampled.

Measured against genuine time-based rolling on the Monash benchmark
data, with the grouped path the benchmarks use (groupby_cols=item_id),
100% of rows were wrong on every dataset tested:

  dataset                     freq  mean|err|   rows a 90D window should span
  m1_monthly                    M     72056.6   3
  tourism_quarterly             Q    110185.4   1
  m1_yearly                     Y   1463464.9   1
  electricity_weekly            W     48889.4   12
  nn5_daily_without_missing     D        16.7   40.5

In every case the approximation used 90 rows. On monthly data that
averages the entire series rather than one quarter. 13 of the 25
benchmark datasets are monthly, quarterly or yearly, where calendar
units are not fixed durations (28-31 day months, 90-92 day quarters,
365-366 day years) -- exactly where a row count cannot be correct.

Rolling now passes the Timedelta to pandas, which selects rows by
timestamp. All five frequencies above now agree exactly with true
time-based rolling (mean|err| 0.0000, 0% of rows wrong).

This also fixes a second defect in the same path: it rolled GLOBALLY
across the whole frame and masked each group's first rows afterwards,
so those rows averaged in the preceding entity's values before being
zeroed. Grouping now happens before rolling, so values never cross an
entity or block boundary.

_estimate_window_rows survives for the operations that still need an
integer (EWM spans, unqualified lag periods) but now derives the row
count from the data's own median timestamp spacing rather than from a
nominal setting.

Performance: ~5x slower on small frames (1.3ms -> 6.8ms at ~900 rows)
but per-row cost falls with size, since the overhead is per-group rather
than per-row: 1.62 us/row at 8k rows, 0.20 us/row at 200k rows
(200,000 rows in 39.6ms). This should not affect the scalability
results.

_vectorized_rolling, _vectorized_rolling_global and _apply_group_mask
are now unreferenced; they are left in place for the Phase 5 dead-code
pass rather than mixing deletion into a behavioural change.

Adds 6 tests covering daily/weekly/monthly/quarterly frequencies, entity
boundaries, and genuinely irregular timestamps -- the last of which no
benchmark dataset exercises, since the Monash format stores only a start
timestamp plus a dense array and so is uniform by construction. All 6
fail against the previous implementation and pass against this one.

Full suite 74 passed. Goldens unchanged: the existing fixtures use
ungrouped daily data, which routed to a path that was already correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default 'auto' mode enables time-series features by an ensemble vote
of three detectors. Two of the three were badly wrong, in opposite
directions, and on data where the answer is unambiguous.

1. ACF reported white noise as periodic (confidence 0.973).

   _compute_acf ran up to lag len(series)-1. At lag 363 of a 365-sample
   series only 2 points overlap, and np.corrcoef of two points is always
   exactly +/-1. Measured on white noise: max |ACF| was 0.158 for lags
   <= 180 (correct) but 1.000 for lags > 300. Those spurious unit
   correlations sorted to the top by height and became both the reported
   period and the confidence. Now requires a minimum overlap of 30
   samples and bounds max_lag to n/2.

2. DFT reported a clean 7-day sine wave as NOT periodic (0.244).

   Its confidence was `1 - sorted[1]/sorted[0]`, close to the opposite
   of what it claimed: np.sort places the two largest bins adjacent, and
   for a real peak those are neighbouring bins of the SAME peak split by
   spectral leakage, so strong periodicity drove the score toward zero.
   On noise it scored 0.075 -- only 0.17 away, with both below the 0.3
   threshold. Replaced with a peak-to-background ratio against the
   median of the spectrum, which is robust to the few bins carrying
   signal. Periodic now scores 0.628 vs 0.263 for noise.

3. ACF conflated days with samples. max_window_days, a DURATION, was
   used directly as a lag COUNT, so at hourly sampling a 365-day ceiling
   became 365 hours (~15 days). Now converted through the sampling rate.

The end-to-end effect, which is what this phase set out to fix: on pure
white noise the ensemble previously ENABLED time-series features and
selected windows of 163-363 days -- exactly the hallucinated seasonality
the consensus vote exists to prevent. It now correctly disables them,
while still enabling on genuinely periodic data.

Also extracts bigfeat/window_detector_base.py. The three detectors had
~145 lines of exact duplication and, worse, methods that shared a name
while diverging in behaviour: assess_periodicity required a 50%
feature-level consensus in DFT but used the average alone in ACF and
Lomb-Scargle, so one periodic column among many noisy ones enabled TS
features for the whole frame. The base class settles these on the
stricter DFT semantics and holds detect_datetime_column,
_convert_to_days, _get_default_windows, _generate_multiscale_windows,
assess_periodicity and smart_window_selection. Subclasses now implement
only _preprocess_signal and detect_optimal_windows.

The shared _convert_to_days also fixes a silent failure: the rate table
had 'H' but not pandas' modern lowercase 'h', and unknown codes fell
back to "one sample == one day" with no warning, inflating hourly
periods 24x. Unrecognised codes are now parsed by pandas and warn before
falling back.

Consolidation improved accuracy further: all three detectors now recover
the true 7/14/28-day periods on periodic data, where ACF previously
returned 120/164/180 and DFT returned 3/6/12/15.

Detector code: 1566 -> 1256 lines. Adds tests/test_detectors.py (11
tests). Full suite 85 passed.

One golden changed (reg_ts_nonstationary): same shape and strategy,
different windows now flowing through to feature generation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects found by benchmarking against the real Monash datasets. Both
made time-series windows far too short on anything not sampled daily.

1. The ensemble never passed a sampling rate.

   Detectors work in SAMPLES and convert to days via sampling_rate. The
   'yes' path passed self.time_step, but the ensemble path used by the
   default 'auto' mode passed nothing at all, so it defaulted to 'D'.
   Monthly and quarterly observations were treated as one-day samples and
   every detected period came out 30-90x too small. The base class now
   measures the median timestamp spacing and snaps it to a frequency
   alias; verified to recover M/Q/Y/D correctly on the Monash data.

2. Pooled windows were truncated to the n SHORTEST.

   The ensemble sorted pooled candidates ascending and took the first
   n_windows, which discards every long window. Pooling three detectors
   reliably produces more than n_windows candidates, so the long scales
   were dropped every time. Now samples at even quantiles, keeping the
   shortest and longest detected scales plus a spread between.

Together these were still producing 1-6 day windows for monthly series
even after the detectors themselves were fixed. On m1_monthly the
selected windows go from [1,2,3,4,5,6] to [1,3,5,90,182,365] days.

Adds 2 detector tests. Full suite 87 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deletes 158 lines of unreachable code, verified dead by instrumenting
every operator across both the grouped and ungrouped paths rather than
by reading:

  _vectorized_rolling            43 lines, only ever called itself
  _vectorized_rolling_global     34 lines, definition only
  _apply_group_mask              44 lines, definition only
  get_weighted_feature_importances  29 lines, definition only
  get_combos                      6 lines, its only caller was comb_mat

The first three were orphaned by the switch to time-based rolling. Also
removes the write-only self.comb_mat and self.gen_steps attributes.

_apply_time_based_operation_loop and _apply_single_group_operation were
NOT removed despite earlier analysis calling them dead: instrumentation
shows they are called 4 times across the operator set, serving the
calendar and seasonal operators on the no-groups branch. Deleting them
would have broken weekday_mean, month_mean, seasonal_decompose and
trend for the default configuration.

Separately, fit() silently log-transforms strictly positive regression
targets with skew > 2 before scoring feature importances. The flag
recording this was private and never read anywhere in the codebase --
including the benchmark harness -- so a caller training their own model
had no way to learn that feature selection had been scored against a
log-scaled target. The flag is now public (target_log_transformed) and
paired with inverse_transform_target(), which is a no-op when the
transform did not fire and so is always safe to call. Note the transform
rebinds a local only, so the caller's y array was never modified.

original_feat is documented rather than fixed: it sits in
unary_operators but not operators, and feat_with_depth samples only from
operators, so it can never be selected. Adding it would change which
features get generated with no evidence that an identity operator helps.

bigfeat_base.py: 3690 -> 3534 lines. Adds 2 tests. Full suite 89 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fit() carried ~150 lines of memory-aware block sampling inline, between
feature extraction and estimator setup, with no test coverage. Moved
verbatim into _apply_block_downsampling(), which returns
(X_for_fit, y_for_fit) and sets _was_downsampled.

Verified behaviour-preserving by running the same downsampling scenario
against the pre-extraction commit and diffing the output: identical for
both the enabled and disabled paths.

Adds 2 tests covering the previously untested path: that discovery may
sample while the returned features still cover every input row, and that
sampling stays off by default.

fit(): 814 -> 664 lines. Full suite 91 passed.

The remaining large methods (fit at 664 lines, _setup_time_series at 360)
are left intact. Splitting them further is a mechanical change with real
regression risk and no behavioural benefit, and is better done against a
specific need than speculatively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds docs/CORRECTNESS_FIXES.md: a full record of the time-series
correctness review -- each defect's observable symptom, its mechanism,
how the fix was verified, and the measured before/after numbers. Opens
with why results collected before the review need re-checking, since
three defects (inert random_state, row-count time windows, a seasonality
guard that never executed) mean earlier runs measured different
behaviour than their configuration describes.

It also records the two claims that did NOT survive verification: the
np.subtract replay "bug" that turned out to be a load-bearing correct
implementation (the proposed fix breaks 1070 of 1500 trees), and
_apply_single_group_operation, which static analysis called dead but
instrumentation showed serving four live operators. Both would have
caused harm if acted on.

Adds tests/README.md: suite layout, the rationale for leading with
invariants rather than goldens, and the known coverage gaps. Includes
the two near-misses from the review -- a frequency test that passed
against the buggy code because it exercised the wrong code path, and an
A/B that silently tested new-against-new -- as concrete guidance for
writing regression tests here.

README.md: the install section listed a package set that did not match
requirements.txt at all; replaced with the real two-file split. Adds a
Time Series section, which the README omitted entirely despite it being
most of the codebase, covering window detection, the causal guarantee,
the parameter table, and the target log-transform that callers need to
know about. Every example and parameter default was verified against
running code.

testing/Benchmarking/README.MD: appends measured runtime (~172 h for a
full run, per-method breakdown), the sampling-regularity analysis of all
25 datasets, and the note that Lomb-Scargle has no dataset here that
exercises its advantage since the GluonTS format cannot represent
irregular sampling.

Documentation only; no behavioural change. Full suite 91 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MohannadAK
MohannadAK force-pushed the feature/time-series-ops branch from 799f21b to 0fe5223 Compare August 6, 2026 12:45
MohannadAK and others added 5 commits August 6, 2026 15:56
The earlier documentation commit covered what changed (CORRECTNESS_FIXES),
how to test (tests/README) and how to benchmark, but never explained how
BigFeat actually works. A reader could learn which bugs were fixed
without learning what fit() does, so anyone modifying the code would
still have to reverse-engineer it.

docs/ARCHITECTURE.md covers:

- The recipe representation -- fit() stores operators, source-column
  indices and parameters rather than feature values, and transform()
  replays them. This is the concept the rest of the design hangs off.
- The generation loop: a weighted hill-climb with elitism, not a genetic
  algorithm (no crossover, no mutation of survivors). Documents the
  actual constants -- 20% elitism, 3 retries, 0.8 weight decay, the
  50%-share diversity penalty at x0.1, geometric depth weights.
- The time-series subsystem: the three enable modes, the ensemble vote,
  the detector base-class split, why windows are real time spans, the
  operator dispatch tree, and the restricted/trend modes.
- Block downsampling: why contiguous blocks rather than random rows,
  and the padding/block_id mechanics.
- Two non-obvious invariants that look like bugs and must not be
  "cleaned up": the mirrored operand swap in feat_with_depth_gen, and
  the _apply_single_group_operation path that static analysis calls dead
  but instrumentation shows serving four live operators.
- Known rough edges, including the ~690 unreferenced lines in
  local_utils.py and the broad exception handling that let the
  pre-flight bug hide for the entire life of the feature.

Every structural claim was verified against the code: the constants, the
fAnova/row-order ordering, the sklearn incompatibility, and the exact
set of methods each detector subclass still owns after the base-class
extraction. Also notes DFT's _apply_seasonal_bias as a deliberate
remaining asymmetry between detectors.

Documentation only; no behavioural change. Full suite 91 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Analysis of the committed benchmark_results/ (24 datasets, 8 methods,
already measured) produced a finding that reframes the next phase of
work: no automated feature-engineering method beats the no-FE baseline
at any conventional significance level.

  method              geo-ratio vs baseline   wins    sign-test p
  openfe                       1.001          12/22      0.42
  no_standard (BigFeat)        1.112          10/24      0.85
  yes_dft                      1.121          11/24      0.73
  auto_ensemble                1.201           8/24      0.97
  tsfresh                      1.455           8/18      0.76

Ratios above 1.0 are worse than baseline. This holds across all 24
datasets, in every frequency subgroup (D 1.10, M 1.19, Q 1.16, W 1.18,
Y 1.00, h 1.07), and no dataset characteristic predicts success
(corr(log n_series, log ratio) = -0.09). average_rankings.csv agrees:
yes_dft 3.69 vs baseline 3.88, a 0.19-rank gap across 8 methods.

OpenFE tying baseline at 1.001 is the important detail -- it suggests
the ceiling is a property of the benchmark rather than of BigFeat.

The plan is therefore built to explain the result rather than to escape
it. Five phases: establish a real baseline on the fixed code with
multiple seeds (blocking, ~90 h); three cheap parallel hypotheses for
why the correctness fixes did not move accuracy; the stationarity-gate
analysis, which is the one place with concrete evidence of a fixable
defect; capability work conditional on that diagnosis; and a write-up
that leads with the reproducibility finding.

Each phase fixes its analysis plan before looking at outcomes, and the
document states in advance what would change the conclusion. Phases B
and C need no re-run and can start immediately.

Documentation only. Every figure quoted was computed from the committed
results or verified against the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan opened by presenting the committed benchmark_results/ as
current evidence that no AutoFE method beats baseline. That was wrong
for the BigFeat rows: those runs are dated 2026-01-24 to 2026-02-12,
six months before the twelve correctness fixes landed on 2026-08-04.
Every BigFeat number there was produced by code with rolling windows
wrong on 100% of rows, a seasonality guard that never executed, and an
inert random_state.

Corrected to separate what survives from what does not:

VALID -- openfe (1.001) and tsfresh (1.455) never call into BigFeat, so
the fixes cannot have changed them. Both were measured against the same
baseline, datasets and harness. That a mature independent AutoFE tool
ties baseline exactly, at p=0.42, remains the single most important
input to the plan: it is weak evidence that the ceiling belongs to the
benchmark rather than to any one tool.

STALE -- every BigFeat row, plus the two sub-analyses derived from them
(no winning frequency subgroup; no dataset characteristic predicting
success). Both are retained struck-through so the post-fix run can be
compared against them: reproducing the pattern would mean the fixes were
accuracy-neutral, and not reproducing it would be attributable.

The honest position is now stated plainly: BigFeat's accuracy relative
to baseline is UNMEASURED on the fixed code. The only post-fix evidence
is the 12-dataset A/B in CORRECTNESS_FIXES.md section 4, which is
underpowered at <=25 series, <=120 rows and a single seed.

Also corrects the title and subtitle, which asserted the fixes did not
materially change accuracy -- a claim resting on that same underpowered
sample -- and the Phase E framing, which cited the stale BigFeat numbers
as a finding to report.

Adds benchmark_results/STALE.md so the warning lives next to the data,
with a per-file breakdown of what may and may not be cited. The derived
figures are flagged specifically: detector_confidence_impact and
stationarity_impact plot quantities the fixes changed substantially.

Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Monash suite cannot demonstrate what the time-series subsystem does,
and this documents why with measurements rather than assertion.

Three structural findings:

1. No covariates. Every record is {target, start, item_id} -- one numeric
   channel per series, with feat_static_cat being a constant identifier
   rather than a time-varying signal. BigFeat's central mechanism is
   composing operators ACROSS columns; with one column there is nothing
   to compose with, so the search collapses to unary transforms.

2. Three datasets contain barely two seasonal cycles per series
   (nn5_weekly 2.0, web_traffic_weekly 2.0, electricity_weekly 2.8,
   against the ~3 that detection requires). A benchmark including them
   measures the detector's failure mode, not its capability.

3. OpenFE -- independent of our code, and therefore unaffected by the
   correctness fixes -- ties the no-FE baseline at geo-ratio 1.0008,
   p=0.42, with ratios clustered in [0.99, 1.03].

It also records what is NOT the explanation. An earlier hypothesis held
that a naive last-value forecast leaves no headroom. Measured across all
25 datasets that is too simple: median naive MASE is 2.25 and only 7 of
25 fall below 1.5. There is real headroom; the feature engineering
simply has nothing to work with. That distinction changes the fix from
"find a harder benchmark" to "find one with covariates".

Recommends three experiments: a synthetic study with planted periods
(the only setting where detection can be scored against ground truth,
including the false-positive rate on noise controls), multivariate UCI
regression sets where cross-column composition can operate, and a Monash
subset retained and reported explicitly as the declared hard case.

Includes the baseline reviewers will ask for: a hand-crafted lag/rolling
feature set. Beating "no features" is weak; beating what a competent
engineer writes by hand is the actual claim.

Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Instrumented walk through detection -> sanitisation -> ladder -> pooling
-> lags, on signals with KNOWN planted periods. Five findings, each
measured rather than inferred:

1. ACF selects harmonics over fundamentals. Peaks are sorted by height,
   but for multi-period signals the ACF at common multiples exceeds the
   fundamentals (ACF(210)=0.997 vs ACF(7)=0.652 on a 7+30d signal).
   Misses the fundamental in 3 of 5 planted cases, returning 360=12x30,
   210=7x30, 156=12x13 instead.

2. Pooling ignores confidence. Windows from a detector at 0.31 count the
   same as one at 0.99, and quantile sampling preserves extremes -- so
   one bad detector's junk is guaranteed representation. End-to-end on
   the 7+30d signal the pooled set is [1,4,7,14,105,210]: the 30-day
   period is lost and two slots carry ACF harmonics.

3. Lags never see the detected periods. lag_periods is positional
   (windows[0], windows[1], windows[mid]); on the test signal the lags
   were [1,4,14] -- lag-7, the most valuable feature for weekly data, is
   absent even though 7 was detected.

4. DFT keeps one peak (argmax) where ACF/LS keep three. Masked today by
   the harmonic ladder reconstructing near-multiples by accident.

5. The ladder's derived harmonics can outvote detected fundamentals in
   the quantile subsample.

The direct answer to whether more processing is needed between period
extraction and window pooling: yes -- a period-consensus stage that
clusters periods across detectors (+-15%), suppresses near-multiples,
and weights by confidence, before any laddering. Consensus currently
applies only to the binary periodic/not vote, never to the periods.

Also records the seams checked and found sound: rate inference, rolling
correctness, the binary vote, causality/entity isolation.

Documentation only; no behaviour change. The 7+30d two-period case is
flagged as the regression fixture for the eventual fixes (current
recovery 7d=yes/30d=no; must become yes/yes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants