Time Series Feature Engineering in Python
Time-series data has a temporal structure: values depend on the past. Stock prices don't jump randomly; they trend. Website traffic spikes on weekends. Machine learning on time-series data requires engineering features that capture temporal patterns: lagged values (yesterday's price), rolling statistics (7-day average), and seasonal components (day-of-week effects). Without these features, algorithms see only isolated points, not trends or seasonality.
Why Time-Series Features Matter
Raw timestamps contain little predictive information. The date "2026-06-02" tells the model nothing. But extracted features—is it a weekend? (yes, high weight), month-of-year (June, moderate weight), days-since-event (X), and lagged target values (yesterday's stock price, strong weight)—capture temporal structure. Time-series models significantly outperform non-temporal models when engineered features are included; studies show 20-40% improvement in forecasting error (Kaggle time-series competitions, 2025).
Lag Features
Lag features shift the target or other features backward in time. If predicting tomorrow's stock price, lagged features are yesterday's price, two-days-ago price, etc.
import pandas as pd
import numpy as np
# Example: daily stock prices
dates = pd.date_range('2026-01-01', periods=10)
prices = np.array([100, 102, 101, 103, 105, 104, 106, 108, 107, 109])
df = pd.DataFrame({'date': dates, 'price': prices})
# Create lag features: previous 1, 2, 3 days' prices
df['price_lag_1'] = df['price'].shift(1)
df['price_lag_2'] = df['price'].shift(2)
df['price_lag_3'] = df['price'].shift(3)
print(df)
# date price price_lag_1 price_lag_2 price_lag_3
# 2026-01-01 100 NaN NaN NaN
# 2026-01-02 102 100 NaN NaN
# 2026-01-03 101 102 100 NaN
# ...
Lag features are essential for autoregressive models (AR, ARIMA, LSTM). They capture momentum and mean reversion.
Rolling Statistics
Rolling windows compute statistics over a sliding window of past values: mean (smoothing), std (volatility), max (peaks), min (troughs).
# Rolling mean (moving average)
df['price_ma_7'] = df['price'].rolling(window=7).mean()
df['price_std_7'] = df['price'].rolling(window=7).std()
# Min and max over last 7 days
df['price_min_7'] = df['price'].rolling(window=7).min()
df['price_max_7'] = df['price'].rolling(window=7).max()
# Custom rolling statistic (e.g., range = max - min)
df['price_range_7'] = df['price_max_7'] - df['price_min_7']
print(df[['price', 'price_ma_7', 'price_std_7', 'price_range_7']])
Rolling statistics capture trends and volatility. A 7-day moving average smooths noise and highlights trends; a 7-day standard deviation measures volatility (high std = high risk).
Expanding Windows
Expanding windows include all past data up to the current point. Useful for cumulative statistics.
# Expanding mean (cumulative average up to current row)
df['price_cum_mean'] = df['price'].expanding().mean()
# Expanding max (highest price up to current row)
df['price_cum_max'] = df['price'].expanding().max()
print(df[['price', 'price_cum_mean', 'price_cum_max']])
Expanding features capture long-term trends and lifetime maximums.
Date-Based Features
Extract temporal components from timestamps.
# Extract date components
df['year'] = df['date'].dt.year
df['month'] = df['date'].dt.month
df['quarter'] = df['date'].dt.quarter
df['day'] = df['date'].dt.day
df['day_of_week'] = df['date'].dt.dayofweek # 0=Mon, 6=Sun
df['day_of_year'] = df['date'].dt.dayofyear
df['week_of_year'] = df['date'].dt.isocalendar().week
# Boolean flags
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
df['is_month_start'] = df['date'].dt.is_month_start.astype(int)
df['is_month_end'] = df['date'].dt.is_month_end.astype(int)
df['is_quarter_start'] = df['date'].dt.is_quarter_start.astype(int)
print(df[['date', 'day_of_week', 'is_weekend', 'is_month_start']])
Date features capture daily and seasonal patterns. Weekend effects (different demand) and month-end effects (payment cycles) are common in real-world data.
Seasonal Decomposition
Decompose a time series into trend, seasonality, and residual components.
from statsmodels.tsa.seasonal import seasonal_decompose
# Decompose prices into trend, seasonal, residual
decomposition = seasonal_decompose(df['price'], model='additive', period=7)
df['price_trend'] = decomposition.trend
df['price_seasonal'] = decomposition.seasonal
df['price_residual'] = decomposition.resid
# Plot for visualization
import matplotlib.pyplot as plt
decomposition.plot()
plt.show()
Seasonal decomposition reveals the underlying structure. The trend component captures long-term changes; the seasonal component repeats (e.g., weekly patterns); the residual is noise. Use trend and seasonal components as features.
Fourier Features
For complex seasonality (multiple periods), Fourier features (sine/cosine waves at different frequencies) capture seasonality better than simple day-of-week dummies.
import numpy as np
# Fourier features for 7-day seasonality (weekly)
t = np.arange(len(df))
period = 7
for k in range(1, 3): # Use 2 pairs of sine/cosine
df[f'sin_week_{k}'] = np.sin(2 * np.pi * k * t / period)
df[f'cos_week_{k}'] = np.cos(2 * np.pi * k * t / period)
# Fourier features for annual seasonality (365-day period)
period = 365
for k in range(1, 3):
df[f'sin_year_{k}'] = np.sin(2 * np.pi * k * t / period)
df[f'cos_year_{k}'] = np.cos(2 * np.pi * k * t / period)
print(df[['sin_week_1', 'cos_week_1', 'sin_year_1', 'cos_year_1']].head())
Fourier features are mathematically elegant and powerful. They capture periodic patterns without explicit categorical variables. A model can learn smooth transitions between seasons instead of abrupt day-of-week jumps.
Differencing
Differencing removes trend by computing the difference between consecutive points. Useful for making non-stationary series stationary.
# First-order difference
df['price_diff_1'] = df['price'].diff()
# Second-order difference (difference of differences)
df['price_diff_2'] = df['price'].diff().diff()
# Seasonal differencing (difference from same day last week)
df['price_diff_seasonal'] = df['price'].diff(7)
print(df[['price', 'price_diff_1', 'price_diff_2', 'price_diff_seasonal']])
Differencing converts non-stationary series (mean changes over time) to stationary series (constant mean). Stationary series are easier for ARIMA and other models to fit.
Cumulative and Shift Features
Cumulative sums and shifts capture growth and momentum.
# Cumulative sum (total sales up to date)
df['cumulative_sales'] = df['price'].cumsum()
# Change from previous period
df['price_change'] = df['price'].diff()
# Percentage change
df['price_pct_change'] = df['price'].pct_change()
# Momentum (change over 7 days)
df['momentum_7'] = df['price'] - df['price'].shift(7)
print(df[['price', 'cumulative_sales', 'price_change', 'momentum_7']])
These features capture direction and magnitude of change. Positive momentum indicates uptrend; negative indicates downtrend.
Grouped Time-Series Features
For multiple time series (e.g., sales per store), group by entity and compute features per group.
# Example: sales by store over time
df = pd.DataFrame({
'date': [pd.Timestamp('2026-01-01')] * 3 + [pd.Timestamp('2026-01-02')] * 3,
'store': [1, 2, 3, 1, 2, 3],
'sales': [100, 150, 120, 110, 160, 130]
})
# Compute lag features per store
df['sales_lag_1'] = df.groupby('store')['sales'].shift(1)
# Rolling mean per store
df['sales_ma_2'] = df.groupby('store')['sales'].rolling(window=2).mean().reset_index(0, drop=True)
print(df)
Group-level features prevent leakage when time series have independent entities (stores, customers).
Complete Example: Stock Price Features
import pandas as pd
import numpy as np
# Create sample data
dates = pd.date_range('2026-01-01', periods=100)
prices = np.cumsum(np.random.randn(100)) + 100
df = pd.DataFrame({'date': dates, 'price': prices})
# Lag features
for i in [1, 5, 20]:
df[f'price_lag_{i}'] = df['price'].shift(i)
# Rolling statistics
df['ma_7'] = df['price'].rolling(7).mean()
df['ma_30'] = df['price'].rolling(30).mean()
df['volatility_7'] = df['price'].rolling(7).std()
# Date features
df['day_of_week'] = df['date'].dt.dayofweek
df['month'] = df['date'].dt.month
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
# Fourier features
t = np.arange(len(df))
df['sin_week'] = np.sin(2 * np.pi * t / 7)
df['cos_week'] = np.cos(2 * np.pi * t / 7)
# Momentum
df['momentum'] = df['price'] - df['price'].shift(5)
# Drop NaN rows (from shifts and rolling)
df = df.dropna()
print(df.head())
This example combines multiple feature types to create a rich feature space for time-series prediction.
Key Takeaways
- Lag features capture autoregressive patterns; include lags of 1, recent lags (7, 30), and longer lags (365 for annual patterns).
- Rolling statistics (mean, std, min, max) detect trends and volatility.
- Date-based features (day of week, month, holidays) capture seasonality.
- Fourier features elegantly encode periodic patterns without categorical explosion.
- Differencing removes trends and makes non-stationary series stationary.
- Always drop NaN values from lagged and rolling features before training.
- For multiple time series, group by entity to prevent cross-series leakage.
Frequently Asked Questions
How many lags should I include?
Start with lags 1, 7, 30, 365 for daily data (previous day, week, month, year). Use feature selection (covered earlier) to prune. Too many lags increase dimensionality; too few miss patterns.
Should I include both trend and lag features?
Yes, they capture different patterns. Trend captures long-term direction; lags capture short-term autocorrelation. Include both and let feature selection choose.
How do I handle multiple seasonalities (weekly and annual)?
Use Fourier features at multiple periods: weekly (period=7) and annual (period=365). Alternatively, include both day_of_week and month as categorical features.
What if my time series has missing dates?
Reindex to a complete date range and fill missing rows via interpolation or forward-fill. Then compute lag and rolling features. Gaps in time series break assumptions about consecutive data.