Skip to content

Add Time Series Feature Engineering Support to BigFeat - #4

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

Add Time Series Feature Engineering Support to BigFeat#4
MohannadAK wants to merge 74 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 14 commits May 6, 2026 12:45
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 15 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>
Two documents held locally for review, now published together.

docs/PIPELINE_FIXES_SPEC.md -- implementation spec for the five fixes
from PIPELINE_GAPS.md: the ACF first-significant-peak rule with
multiple-verification, the period-consensus stage between extraction and
laddering, lags derived from detected fundamentals, DFT top-k peaks, and
fundamental-protection in the ladder. Each fix carries file locations,
concrete design, and acceptance tests against the shared 7+30-day
planted-period fixture (expected end state: lag_periods [1,4,14] ->
[1,7,30]; ensemble windows must bracket both periods with nothing >60d).
Every test must be seen failing against pre-fix code before landing.

docs/PIPELINE_STAGE_REVIEW.md -- design review of every remaining stage
with measured or cited verdicts. Highest-value finding: the stationarity
gate (lag-1 autocorr > 0.85) misclassifies in BOTH directions -- it
fires on a stationary smooth 30-day seasonal (0.97), stripping rolling
operators from exactly the data the subsystem exists for, while the
covid-style random walk (0.725) slips under; ADF classifies all six test
series correctly. Also: the single-detector confidence override (>0.7
across incommensurable scales) can overrule two explicit no-votes; the
diversity penalty is a cliff at 50% share capping any operator near 2x
uniform regardless of merit; and the correlation acceptance gate is
BETTER than earlier claimed (composed interaction features pass at 0.99;
investigation-plan D3 is read down accordingly -- the gate's only real
cost is burning retries on autocorrelated targets).

The review also records what was designed right and should be defended
(the ensemble concept, Lomb-Scargle inclusion, Timedelta windows,
recipes-not-values, block downsampling, the instinct to decline), and a
ranked redesign section R1-R8. Verified while writing it: detection is
target-blind (y is accepted and never used) and no cyclical sin/cos
operator exists -- R1 and R2 address these as the two cheapest
high-power changes. Literature grounding: AUTOPERIOD's propose/verify
pattern, RobustPeriod, tsfresh/FRESH FDR selection, fpp3 Fourier terms,
successive halving/Hyperband; sources mapped per-section in the doc.

Documentation only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements PIPELINE_FIXES_SPEC.md Fix 1. _find_acf_peaks previously
sorted peaks by height, but for multi-period signals the ACF at common
multiples exceeds the fundamentals (every component realigns there:
ACF(210)=0.997 vs ACF(7)=0.652 on a 7+30d signal), so the top-3 were
harmonics -- 210/91/301 on the fixture, 360/120/330 on planted [30].

Now candidates are walked in ascending-lag order (the fundamental is the
first peak clearing the floor), each accepted lag's harmonic train is
masked among later candidates (integer multiples within max(2, 0.15L)),
and every candidate is verified by requiring an ACF echo at a small
multiple -- an isolated noise spike has no echo and is rejected.

Two findings from implementation, recorded as spec amendments:

1. The echo rule is ANY-of {2L, 3L}, not 2L-and-3L as first specced. In
   a multi-period signal the other component can sit near anti-phase at
   exactly 2L and cancel the echo: planted {12,52} gives ACF(24)=0.018
   because cos(2*pi*24/52) ~ -0.97, while 3L=36 shows 0.322. The
   original rule rejected true fundamentals.

2. ACF standalone cannot recover the SECOND period, by physics rather
   than by bug: the slow fundamental is not an ACF local maximum at all
   -- the fast component's comb tooth beside it towers over it (tooth 28
   vs period 30; teeth 48/60 vs period 52). The lag-domain contract is
   therefore: shortest fundamental first, harmonics masked, spikes
   rejected. Recovering BOTH periods is the ensemble's job, which
   reorders the fix series: Fix 4 (DFT top-k spectral peaks) must land
   before Fix 2's fixture acceptance can pass.

Measured on the fixtures: planted [30] now returns 30 first (was 360);
planted [7,30] returns 7 first with nothing above 60 (was 210/91/301);
planted [12,52] leads with 12 and no multiple of 12 survives (was
156/312/360); an isolated ACF spike with no echo is rejected.

Adds 4 acceptance tests, each confirmed failing against the pre-fix
code. Full suite 95 passed; goldens unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements PIPELINE_FIXES_SPEC.md Fixes 4 and 5, landed together because
4 is invisible without 5: the second period DFT now detects was being
truncated away by the very ladder built from it.

Fix 4 -- _top_spectral_periods replaces the single np.argmax at both
detection sites. Peaks above a noise floor of 3x the spectrum median
(the same peak-to-background logic as _compute_confidence), then
DEDUPLICATED IN PERIOD SPACE before taking the k tallest: when the true
frequency falls between FFT bins, spectral leakage splits one peak
across adjacent bins -- on a 1000-sample series with an 11-day period
the three tallest bins are all lobes of the same peak (~10.9/11.1/10.9),
and without dedup a genuine second period never made top-3. Height
sorting is CORRECT in the frequency domain (fundamentals exceed their
harmonics), the exact opposite of the lag domain fixed in Fix 1 -- the
asymmetry is documented at both sites.

Fix 5 -- _generate_multiscale_windows previously truncated the combined
ladder ascending (`sorted(all)[:n_windows]`), which dropped DETECTED
periods in favour of their own derived sub-harmonics: with detected
{8, 11, 30} the ladder [4,5,8,11,15,16,22,30,...] cut at slot six,
losing 30 to the derived 4 and 5. Detected periods now fill slots
first; derived harmonics take the remaining room. Same
shortest-first-truncation defect family as the ensemble pooling fix
(86f5a79), one layer further down.

Acceptance fixture hardened during implementation: the spec's [11, 45]
pair passes on UNFIXED code because 4*11=44 lands inside the 45-day
tolerance -- the ladder fakes the recovery. Replaced with [11, 31],
chosen so neither period's ladder ({P/2, 2P, 4P}) reaches the other's
window. Confirmed failing pre-fix ([5,7,11,14,15,22] -- pure 11-ladder),
passing post-fix with both periods bracketed.

Adds 3 tests (non-multiple pair recovery, noise non-regression,
fundamental-protection). Full suite 98 passed; goldens unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements PIPELINE_FIXES_SPEC.md Fix 3. Lags were positional picks from
the pooled window ladder (windows[0], windows[1], windows[mid]); on the
7+30-day fixture generation received lags [1,3,7] -- the 30-day
fundamental absent and 7 present only by accident of position. A lag
should EQUAL a detected cycle ("same point one cycle ago"); a window
merely spans one.

Each detector now stashes last_detected_periods -- its raw
(period_days, confidence) fundamentals -- during detect_optimal_windows
(in LS this required moving the confidence computation above the stash,
where it previously sat AFTER the point of use). Both setup paths derive
lags via _derive_lag_periods_from_fundamentals: greedy +-15% clustering,
confidence-weighted cluster means, clusters ranked by summed confidence
so a period seen by several detectors outranks one detector's stray
peak. Result: [1d, P1, P2]. The positional rule remains as fallback when
nothing was detected, and user_provided_lags still bypasses everything.

On the fixture, lag_periods goes [1,3,7] -> [1,30,7]. The end state
matches the spec table on every row.

Fix 2's consensus machinery is DEFERRED, recorded as a spec amendment:
after Fixes 1+4+5 the fixture's window acceptance already passes
end-to-end ([1,3,5,7,28,41]) -- the pooling pollution was garbage-in,
cured at the source. What survives of Fix 2 is the stash (landed here)
and an end-to-end regression test pinning the window set. No machinery
without a measured failure it would fix.

Both TS goldens regenerate: the lag change alters which time-series
features exist to be selected, which is the intended effect. Two recipe
tests needed a larger generation budget (gen 5 x it 3): with the
corrected lags the old small budget happened to select no TS operator at
all on that seed.

Adds 2 end-to-end tests (window bracketing pin, fundamental lags), the
lag one confirmed failing pre-fix. Full suite 100 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements PIPELINE_STAGE_REVIEW.md section 2, the highest-value finding
of the stage review. The gate routed data to restricted mode (lag/diff
operators only) when mean |lag-1 autocorrelation| over the first five
feature columns exceeded 0.85. Lag-1 autocorrelation measures
SMOOTHNESS, not non-stationarity, and misclassified in both directions:

* False positive: a clean STATIONARY 30-day seasonal has lag-1 ~ 0.97.
  The gate fired and stripped rolling/seasonal operators from exactly
  the data the subsystem exists for.
* False negative: a random walk observed with measurement noise sits
  well under 0.85 (covid_deaths: 0.725; synthetic fixture: 0.757). The
  gate waved it through to the full pool, where a smoothing operator was
  selected on trending data at a 5x MASE cost -- the one regression
  found in the benchmark A/B.

Measured against ADF/KPSS on six canonical series (white noise, two
seasonals, random walk, trend+seasonal, AR(1)), ADF classified all six
correctly while the lag-1 rule got two wrong.

_assess_stationarity now runs an Augmented Dickey-Fuller test per
sampled column and declares non-stationary when the median p-value fails
to reject the unit root (p > 0.05). statsmodels is an optional
dependency (`bigfeat[stationarity]` extra); without it the previous
lag-1 heuristic remains as an explicit fallback, and avg_lag1 is still
computed and exposed either way since the benchmark harness reads it.

The trend-mode trigger's secondary condition (avg_lag1 > 0.8) is folded
into the same verdict -- it was the same smoothness proxy under a
different threshold.

Adds 2 acceptance tests, one per failure direction, each with a
fixture-drift guard asserting the fixture genuinely defeats the OLD
gate, and each confirmed failing pre-fix. Full suite 102 passed; goldens
unchanged -- ADF routes both golden fixtures identically, i.e. the fix
changes routing only on the previously misclassified cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements PIPELINE_STAGE_REVIEW.md R1, flagged there as the single
biggest available win. _setup_time_series(X, y) accepted y and never
used it -- the only reference was the docstring -- so detection was
entirely target-blind. Yet the periodicity that matters for prediction
is the TARGET's: a feature column can carry a 5-day cycle while y
follows an 11-day one, and windows tuned to the features' rhythm miss
the signal being predicted. Measured on exactly that fixture, the old
behaviour produced lags [1,5] and windows [1,2,5,10,20] -- the target's
11-day period nowhere.

In 'auto' mode the detectors now analyse a local detection frame that
includes y as a synthetic '__bigfeat_target__' column, placed FIRST in
the column list so its periods lead the fundamentals stash that Fix 3's
lag derivation consumes. The stationarity gate, the ensemble vote and
the window pooling all see it. The column exists only in that local
frame -- self.feature_columns and self.original_data are untouched, so
it cannot leak into feature generation or transform(). Guarded for
length mismatch, non-numeric and all-NaN targets, falling back to
feature-only detection.

Acceptance fixture: features with a 5-day cycle, target with an 11-day
cycle absent from every feature, periods chosen so neither ladder
({2.5,10,20} vs {5.5,22,44}) can fake the other. Confirmed failing
pre-fix; the 11-day period now appears in the derived lags.

Full suite 103 passed; goldens unchanged -- on the golden fixtures y is
a linear combination of the periodic features, so it carries the same
rhythms and detection routes identically. The change is visible only
where the target has structure the features lack, which is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements PIPELINE_STAGE_REVIEW.md R2. Verified during the review: no
sin/cos operator existed anywhere in the pool (the two occurrences were
fallback shims inside weekday/month_mean). Yet sin(2*pi*t/P) and
cos(2*pi*t/P) at each detected period P is the standard regression
representation of seasonality (Hyndman & Athanasopoulos, fpp3 sec 7.4)
-- smooth, and it hands tree models the PHASE information that raw lags
express only indirectly.

Two new operators, _safe_cyclical_sin and _safe_cyclical_cos, sharing
one _cyclical_phase body. The phase is a pure function of the TIMESTAMP
against a fixed epoch (2000-01-01): the same date yields the same value
at fit and transform time, for any row order and any entity, which makes
the encoding leak-proof by construction -- it reads the clock, not the
data. Verified: byte-exact match to sin(2*pi*days_since_epoch/P), and
invariance to row shuffling.

Their period parameter is drawn from the detected fundamentals, which
this commit promotes from a Fix-3 internal into a stored attribute:
_consense_fundamental_days (the +-15% confidence-weighted clustering) is
split out of the lag deriver and its result kept as
self.detected_fundamentals, reset per fit, feeding both lag derivation
and the cyclical encodings. Detection output now has three consumers --
windows, lags, phase encodings -- tripling the value of getting P right.

Pool grows 15 -> 17 in full mode only; restricted and trend modes are
unchanged, and exactly one golden moved (reg_ts_periodic, the full-pool
fixture) while the restricted-mode golden did not -- the expected
signature of a pool-size change. Recipe replay handles the new operators
with no changes: params carry window_size and feature_index like every
other TS op, and all fit==transform invariants pass.

Adds 3 tests (pool membership, timestamp-purity + row-order invariance
with exact-value pin, fundamentals attribute), each confirmed failing
pre-fix. Docs updated 15 -> 17. Full suite 106 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First run of BENCHMARK_DESIGN.md Experiment A -- planted-period ground
truth, the measurement the improvement series actually earns. (A Monash
A/B was considered and rejected: that suite is univariate with no
covariates, so a flat result there would be uninformative about these
changes -- the same reasoning error the benchmark redesign exists to
avoid.)

Grid: {7, 30, 91, 7+30, 11+31} x 3 SNR levels x {3,5,10} cycles x 2
seeds = 78 planted cases + 6 pure-noise controls, scored identically on
PRE (b05b103, before the series) and POST (4388af0) code. Tolerance
+-15%.

  windows recover all planted periods:  69% -> 92%
  lags contain all planted periods:     46% -> 77%
  false positives on pure noise:        0/6 -> 0/6 (unchanged)

Attribution is clean against the commits: period PAIRS 5/24 -> 22/24
(Fixes 1+4+5, the multi-period blind spot), single-period lags 36/54 ->
54/54 perfect (Fix 3), low-SNR 16/26 -> 25/26 (Fix 1's echo
verification). Sensitivity was not traded for it: noise rejection is
identical.

The study also exposes two honest gaps. First, in 6 remaining cases the
fundamental survives detection (the lags prove it) but the ensemble's
pooled-window quantile subsample drops it -- Fix 2's consensus stage was
deferred "pending a measured failure it would fix", and this is that
failure; the deferral is lifted for the fundamental-protection slice,
recorded as a spec amendment. Second, pair LAGS remain weak (6/24): the
lag list holds top-2 clusters while pairs plus residual harmonics need
the same protected 3-fundamental list the windows use.

Adds testing/Benchmarking/synthetic_study.py (reusable: takes an import
root, so any two checkouts can be compared on identical data; also the
calibration-corpus scaffold for R5) and the raw results under
benchmark_results_synthetic/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The study exposed two gaps; both are closed and the study re-run.

Gap 1 -- the pooled-window quantile subsample could drop a detected
fundamental (6/78 cases: single_30 at 10 cycles gave windows
[1,3,5,20,59,120] while the lags proved 30 was detected). The pooled
path now protects fundamentals exactly as the ladder does since Fix 5:
the consensus fundamentals fill window slots first, the quantile spread
fills the remainder. This is the narrow slice of deferred Fix 2 whose
measured failure the study provided.

Gap 2 -- pair lags recovered only 6/24 because the lag list re-clustered
the raw pairs to top-2, and with two true periods plus harmonic residue
the second fundamental ranked third. Lags now consume the SAME protected
3-fundamental list as the windows and the cyclical encodings: one
consensus (_consense_fundamental_days), three consumers, computed once
before window finalisation. _derive_lag_periods_from_fundamentals is
removed -- superseded within its own series, zero callers.

Study re-run on the identical 78+6 grid:

  windows:          69% (pre) -> 92% (series) -> 100% (this commit)
  lags:             46%       -> 77%          -> 100%
  noise rejection:   0/6 at every stage

The study doc records the caveat in the same breath: a perfect score is
a statement about THIS grid (regular sampling, sinusoids, +-15%
tolerance) -- the next move is extending the grid, not celebrating it.

Adds 2 acceptance tests reproducing the study's exact failing cases
(single_30/10c/s0 and pair_7_30/10c/s0), both confirmed failing
pre-fix. Both TS goldens regenerate (windows and lags changed on the TS
fixtures, the intended effect). Full suite 108 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/PIPELINE_IMPROVEMENTS.md: the single record of the eight-commit
detection->consumption series (a9ff2b9..9633dd0), complementing
CORRECTNESS_FIXES.md the way the work itself split -- that round fixed
code that was WRONG; this series improved code that worked as written
but was designed poorly.

Contents: a before/after pipeline diagram (three independent voters with
harmonic-picking peaks, a smoothness gate and a target-blind view ->
one consensus feeding three consumers, echo-verified fundamentals, an
ADF gate, and the target in the detection frame); one section per change
with the measured failure that motivated it, what implementation taught
us (recorded as spec amendments -- the ANY-of echo rule, the lag-domain
physics limit, the [11,45] fixture that passed on broken code); the
ground-truth arc 69% -> 92% -> 100% window recovery and 46% -> 77% ->
100% lag recovery with per-commit attribution and unchanged 0/6 noise
rejection; the running 7+30d fixture across the series; method notes
(test-first with pre-fix failures, deferral-with-tripwire, goldens as
diagnostics); and the deliberately-not-changed list with reasons.

All eight cited hashes verified against the repository. Documentation
only.

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