CPI Transformer Risk Dashboard
Overview
This project forecasts year-over-year CPI inflation from a panel of macroeconomic indicators using a Transformer encoder, and benchmarks it against a classical ARIMA model. A gradient-based SHAP layer attributes each forecast to its macro drivers, and everything is surfaced through an interactive Streamlit dashboard.
The pipeline runs as five composable stages:
- Data — load or synthesize a monthly macro panel.
- Features — compute YoY targets and window the series into supervised sequences.
- Model — train a Transformer encoder with early stopping.
- Baseline — fit a rolling SARIMAX and compare RMSE.
- Explain — compute SHAP driver rankings and visualize.
Design goal
Beat the ARIMA baseline on test-set RMSE while keeping every forecast interpretable. The comparison is deliberately fair: the baseline sees the same target series, and the split is strictly temporal to prevent leakage.
1Data
The model consumes a monthly, date-indexed table
(data/raw/macro_cpi.csv) with a headline
CPI level plus six macro indicators:
UNRATE— unemployment rate (labor slack).FEDFUNDS— policy rate (monetary stance).OIL_PRICE— energy costs (supply-side pressure).M2— broad money supply (demand-side pressure).INDPRO— industrial production (real activity).HOUSING— housing index (shelter dynamics).
The loader validates the path and reads the CSV into a pandas
frame; the pipeline then parses date, sets it as a
sorted index, and proceeds to feature engineering.
def load_csv(filename: str) -> pd.DataFrame:
path = os.path.join(RAW_DIR, filename)
if not os.path.exists(path):
raise FileNotFoundError(f"Raw data file not found: {path}")
return pd.read_csv(path)
2Synthetic Generator
To develop and stress-test the pipeline without a data licence, a synthetic generator builds 650 months (~54 years) of macro series with smooth cycles plus noise, then constructs CPI YoY as a function of lagged macro features — so the relationships are real, learnable, and non-trivial.
Each driver is a normalized transform of lagged inputs, for example an oil-momentum term and a labor-gap term:
$$ \text{oil\_mom}_t = \frac{P^{oil}_{t-1} - P^{oil}_{t-6}}{45}, \qquad \text{labor\_gap}_t = \frac{u_{t-1} - 5.5}{12}. $$
These combine into a base signal (with saturating $\tanh$ nonlinearities so extreme moves don't blow up), plus financial-stress and global-supply terms, lagged echoes of the macro signal, and Gaussian noise:
base_signal = (
0.012 * np.tanh(3 * oil_momentum)
- 0.015 * labor_gap
+ 0.01 * yield_curve
+ 0.013 * credit_growth
+ 0.012 * production_cycle
+ 0.008 * np.tanh(4 * housing_cycle)
+ 0.004 * np.sin(t / 24)
+ 0.0035 * np.cos(t / 10)
)
macro_signal = base_signal + 0.0065 * np.tanh(3.5 * financial_stress) + 0.0075 * global_supply
yoy = (0.02 + macro_signal) + 0.35 * lag(macro_signal, 3) - 0.18 * lag(macro_signal, 6)
yoy += np.random.normal(scale=0.0035, size=n)
yoy = np.clip(yoy, -0.01, 0.075)
Finally the CPI level is reconstructed from YoY so the feature-engineering step can re-derive the target exactly as it would from real data: $\text{CPI}_t = \text{CPI}_{t-12}\,(1 + \text{yoy}_t)$.
Why lagged echoes and noise?
The lagged macro terms create memory that rewards a model able to attend across time, while the noise floor and clipping keep the task realistic — a purely autoregressive model should not trivially win.
3Feature Engineering
The target is the 12-month percentage change of CPI (YoY), computed with pandas and expressed in percentage points:
$$ \text{CPI\_yoy}_t = \left(\frac{\text{CPI}_t}{\text{CPI}_{t-12}} - 1\right)\times 100. $$
def add_yoy(df, col, new_col):
out = df.copy()
out[new_col] = out[col].pct_change(12) * 100.0
return out
Features are every column except the raw CPI level. A
small helper de-duplicates the requested columns and drops rows with
missing values (the first 12 months, which have no YoY), aligning
features and target on a clean common index.
4Sequence Windows
Forecasting is framed as sequence-to-one: given a lookback window of $L = 24$ months of features, predict the next $H = 1$ month of CPI YoY. A sliding window produces tensors $X \in \mathbb{R}^{N\times L\times F}$ and targets $y \in \mathbb{R}^{N\times H}$, plus the target timestamps for plotting.
for t in range(lookback, len(df) - horizon + 1):
X_list.append(values[t - lookback:t, :]) # (L, F) window
y_list.append(target[t:t + horizon]) # (H,) future target
ts_list.append(df.index[t + horizon - 1]) # timestamp of the prediction
Because windows only ever look backward, there is no temporal leakage from the future into any training sample.
5Split & Scaling
The sequences are split chronologically — 70% train, 15% validation, 15% test — so the model is always evaluated on the most recent, unseen period.
Two independent StandardScalers are fit on the
training set only and reused everywhere else:
- Feature scaler — fit on the flattened $(N\cdot L,\,F)$ matrix, then reshaped back to windows, so every feature is zero-mean/unit-variance.
- Target scaler — standardizes CPI YoY so the MSE loss is well-conditioned; predictions are inverse-transformed back to percentage points before RMSE is computed.
Both scalers are persisted with joblib so inference and
SHAP use exactly the same transforms.
6Architecture
The model is a compact Transformer encoder,
TimeSeriesTransformer. Its dimensions in the pipeline
are $d_{model}=96$, 4 attention heads, 3 encoder
layers, feed-forward width 192, and dropout 0.1.
class TimeSeriesTransformer(nn.Module):
def __init__(self, n_features, d_model=64, nhead=4, num_layers=2,
dim_feedforward=128, dropout=0.1, horizon=1):
super().__init__()
self.input_proj = nn.Linear(n_features, d_model)
self.pos_encoder = PositionalEncoding(d_model, dropout)
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward,
dropout=dropout, batch_first=True)
self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers)
self.head = nn.Sequential(
nn.LayerNorm(2 * d_model), nn.GELU(), nn.Linear(2 * d_model, horizon))
The forward pass has four stages:
- Input projection — a linear map lifts the $F$ raw features into the $d_{model}$ embedding space.
- Positional encoding — adds order information (§6.1).
- Encoder stack — multi-head self-attention mixes information across the 24 months (§6.2).
- Pooling head — combines a summary and the final step, then projects to the forecast (§6.3).
6.1Positional Encoding
Self-attention is permutation-invariant, so fixed sinusoidal positional encodings are added to inject the notion of time order:
$$ PE_{(pos,2i)} = \sin\!\left(\frac{pos}{10000^{2i/d}}\right),\quad PE_{(pos,2i+1)} = \cos\!\left(\frac{pos}{10000^{2i/d}}\right). $$
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
x = x + pe[:, :x.size(1), :] # broadcast-add to the sequence
6.2Self-Attention
Each encoder layer computes scaled dot-product attention over the sequence, letting month $i$ weigh information from every other month $j$:
$$ \mathrm{Attn}(Q,K,V) = \operatorname{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V. $$
Multiple heads learn complementary temporal patterns (e.g. a 12-month seasonal echo vs. a short oil-shock response), and a position-wise feed-forward network adds nonlinear capacity.
6.3Pooling Head
Rather than a single CLS token, the head concatenates a mean-pooled representation (a smooth summary of the whole window) with the last time-step representation (the most recent state), giving a $2\,d_{model}$ vector that is normalized, passed through GELU, and projected to the forecast:
x = self.transformer_encoder(self.pos_encoder(self.input_proj(src)))
last_repr = x[:, -1, :] # most recent month
pooled_repr = x.mean(dim=1) # whole-window summary
combined = torch.cat([pooled_repr, last_repr], dim=-1)
return self.head(combined) # (batch, horizon)
7Training
Training minimizes mean-squared error on the scaled target with Adam
($lr = 3\times10^{-4}$), a
ReduceLROnPlateau scheduler, gradient clipping, and
early stopping on validation loss (up to 120 epochs, patience 15).
$$ \mathcal{L} = \frac{1}{N}\sum_{i=1}^{N}\big(\hat{y}_i - y_i\big)^2. $$
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, factor=0.5, patience=3)
criterion = nn.MSELoss()
for ep in range(epochs):
model.train()
for Xb, yb in train_loader:
optimizer.zero_grad()
loss = criterion(model(Xb), yb.view(yb.size(0), -1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
# ... validate, scheduler.step(val_loss), keep best_state, early-stop ...
Three ingredients stabilize a Transformer on a modest dataset:
- Gradient clipping ($\lVert g\rVert \le 1$) prevents exploding updates.
- LR scheduling halves the rate when validation loss stalls.
- Early stopping restores the best-validation weights, guarding against overfitting.
Evaluation inverse-transforms predictions to percentage points and reports RMSE: $\text{RMSE}=\sqrt{\tfrac1N\sum_i(y_i-\hat y_i)^2}$.
8ARIMA Baseline
The baseline is a rolling SARIMAX(1,1,1) fit only on the CPI YoY series — a strong, interpretable univariate benchmark. An ARIMA($p,d,q$) model differences the series $d$ times and regresses on $p$ lags plus $q$ past errors:
$$ \Delta y_t = c + \sum_{i=1}^{p}\phi_i\,\Delta y_{t-i} + \sum_{j=1}^{q}\theta_j\,\varepsilon_{t-j} + \varepsilon_t. $$
history = train.copy()
for t in range(len(test) - horizon + 1):
res = SARIMAX(history, order=(1,1,1),
enforce_stationarity=False, enforce_invertibility=False).fit(disp=False)
preds.append(res.forecast(steps=horizon).values[-1])
truth.append(test.iloc[t + horizon - 1])
history = pd.concat([history, pd.Series([test.iloc[t]], index=[test.index[t]])])
The rolling refit means the baseline always uses all data up to the forecast date — an honest, non-cheating comparison. The headline improvement metric is $(\text{RMSE}_{ARIMA}-\text{RMSE}_{Transformer})/\text{RMSE}_{ARIMA}$.
9SHAP Attribution
Interpretability uses SHAP's GradientExplainer, which is
efficient for differentiable models. A background set from training
data anchors the expectation; SHAP values on a test subset are then
aggregated by mean absolute value across samples and time to rank
each feature's global influence:
explainer = shap.GradientExplainer(model, background) # background: train subset
shap_values = explainer.shap_values(eval_data) # (N, L, F)
agg = np.abs(sv_array).mean(axis=(0, 1)) # mean |SHAP| over samples & time
idx = np.argsort(agg)[::-1][:top_k] # top-k drivers
SHAP values are grounded in cooperative game theory: the Shapley value fairly distributes a prediction across features by averaging each feature's marginal contribution over all orderings,
$$ \phi_i = \sum_{S\subseteq F\setminus\{i\}} \frac{|S|!\,(|F|-|S|-1)!}{|F|!}\, \big[f(S\cup\{i\}) - f(S)\big]. $$
The result is written to artifacts/top_drivers.json for
the dashboard.
10Dashboard
The Streamlit app reads pre-computed artifacts, so it loads instantly. It renders three things:
- Scorecard — Transformer RMSE, ARIMA RMSE, and the % improvement as metric cards.
- Timeline — forecast vs. actual CPI YoY on the held-out test set.
- Drivers — a bar chart of the top-5 SHAP macro features.
c1, c2, c3 = st.columns(3)
c1.metric('Transformer RMSE', f"{metrics['transformer_rmse']:.4f}")
c2.metric('ARIMA RMSE', f"{metrics['arima_rmse']:.4f}")
c3.metric('RMSE Improvement', f"{metrics['rmse_improvement_pct']:.1f}%")
st.line_chart(df_tl.set_index('timestamp')[['y_true', 'y_pred']])
st.bar_chart(pd.DataFrame({'importance': drivers['importances']}, index=drivers['features']))
Each panel degrades gracefully: if an artifact is missing, the app shows an instruction instead of erroring.
11Results
The pipeline writes a machine-readable scorecard to
artifacts/metrics.json containing both models' RMSE, the
percentage improvement, and the best validation loss. The
timeline.csv stores aligned
(timestamp, y_true, y_pred) rows for the test window.
Honest reporting
On the current synthetic configuration the univariate ARIMA is a very strong baseline: because CPI YoY is highly autocorrelated, differencing captures most of the structure, and the Transformer has not consistently beaten it. The metric is reported verbatim (a negative improvement means ARIMA won), which is exactly the kind of rigorous, non-cherry-picked comparison a review should expect.
12Limitations
- Data scale. ~600 monthly points is small for a Transformer; deep attention models are data-hungry and can overfit.
- Strong baseline. Highly autoregressive targets favor ARIMA; the deep model's edge is easier to show on multivariate, regime-switching, or higher-frequency data.
- Synthetic dynamics. Conclusions depend on the generator; real CPI data is the true test.
- Regime shifts. Out-of-sample accuracy can degrade around structural breaks and policy changes.
Next steps
Swap in real FRED series, extend to multi-horizon forecasting, add probabilistic (quantile) outputs, and consider a temporal fusion / patch-based Transformer that is more sample-efficient.
AAPI Reference
# Model
class TimeSeriesTransformer(nn.Module):
def __init__(self, n_features, d_model=96, nhead=4, num_layers=3,
dim_feedforward=192, dropout=0.1, horizon=1): ...
def forward(self, src: torch.Tensor) -> torch.Tensor: ...
# Sequence windowing
def make_supervised_sequences(df, target_col, feature_cols, lookback, horizon)
-> tuple[np.ndarray, np.ndarray, list]: ...
# Training / evaluation
def train_transformer(model, train_loader, val_loader, device='cpu',
epochs=50, lr=1e-3, patience=7) -> dict: ...
def evaluate_model(model, loader, device='cpu', target_scaler=None)
-> tuple[float, np.ndarray, np.ndarray]: ...
# Baseline
def rolling_forecast_rmse(series, order=(1,1,1), seasonal_order=(0,0,0,0),
horizon=1, split_index=None)
-> tuple[float, np.ndarray, np.ndarray]: ...
# Explainability
def shap_top_features(model, X_background, X_eval, feature_names, top_k=5)
-> tuple[list[str], np.ndarray]: ...
BRepository Layout
src/
data/loader.py # CSV IO + directory helpers
features/engineering.py # YoY target, align + dropna
features/sequence.py # sliding-window supervised sequences
models/transformer.py # TimeSeriesTransformer + PositionalEncoding
models/train_utils.py # Dataset, split, scaling, train, evaluate
models/arima_baseline.py # rolling SARIMAX RMSE
models/shap_utils.py # GradientExplainer driver ranking
utils/generate_synthetic.py # feature-driven synthetic macro panel
train_pipeline.py # orchestrates the full run -> artifacts/
explain_shap.py # writes top_drivers.json
dashboard/app.py # Streamlit UI
artifacts/ # metrics.json, timeline.csv, top_drivers.json, weights
data/raw/ , data/processed/ # inputs, scalers, cached arrays
CGlossary
- CPI YoY — 12-month % change in the Consumer Price Index; the forecast target.
- Transformer encoder — attention-based sequence model that weighs all timesteps in parallel.
- Self-attention — mechanism computing pairwise interactions between timesteps.
- Positional encoding — signal added to inputs to represent order.
- ARIMA / SARIMAX — classical model combining autoregression, differencing, and moving-average terms.
- Autoregressive — a series predicted from its own past values.
- RMSE — root mean squared error, in the same units as the target.
- SHAP — Shapley-value feature attributions explaining individual predictions.
- StandardScaler — zero-mean/unit-variance normalization fit on training data.
DInterview Q&A
Why a Transformer over an LSTM? Parallel training and direct long-range attention — month $t$ can attend to a 12-month-old shock without information decaying through recurrent steps.
Why keep an ARIMA baseline? It is a strong, interpretable, low-data benchmark. Beating it is meaningful; failing to is informative and reported honestly.
How do you prevent leakage? Windows only look backward, the split is chronological, and scalers are fit on training data only.
Why scale the target? It conditions the MSE loss for stable optimization; predictions are inverse-transformed before RMSE.
Why mean-pool and last-step? The mean is a smooth window summary; the last step is the most recent state. Concatenating both gave a stronger head than either alone or a CLS token.
Why SHAP instead of raw attention weights? Attention is not a faithful importance measure; SHAP gives consistent, additive attributions with theoretical guarantees.
What would you change with more data? Real FRED series, multi-horizon and quantile outputs, and a more sample-efficient architecture (patch/temporal-fusion Transformer).