🇬🇧 English · 🇪🇸 Español
Penalized Forecasting
puremacro.forecast.forecast_penalized answers one question: out of a wide
panel of candidate indicators, which few carry h-step-ahead information
about the target, and what is the number for T+h?
It is the tool for the case where OLS has stopped being an option — 200 monthly indicators against 120 quarters — and for the case where OLS is still defined but is fitting noise. It returns a sparse coefficient vector and a single point forecast.
import numpy as np
import pandas as pd
from puremacro.forecast import forecast_penalized
# The shipped 30-predictor panel: AR(1) indicators, four of which drive y one step ahead.
rng = np.random.default_rng(123)
T, P = 160, 30
X = np.zeros((T, P))
for j in range(P):
rho = rng.uniform(0.3, 0.8)
for t in range(1, T):
X[t, j] = rho * X[t - 1, j] + rng.normal(scale=0.8)
y = np.full(T, 2.0)
for t in range(1, T):
y[t] = 2.0 + 1.8 * X[t-1, 1] - 1.4 * X[t-1, 5] + 1.2 * X[t-1, 12] - 0.9 * X[t-1, 22] + rng.normal(scale=0.5)
dates = pd.date_range("2010-01-01", periods=T, freq="MS")
X_panel = pd.DataFrame(X, index=dates, columns=[f"Macro_Indicator_{j+1:02d}" for j in range(P)])
y_target = pd.Series(y, index=dates, name="CPI Inflation")
res = forecast_penalized(X_panel, y_target, horizon=1, alpha=1.0, adaptive=True)
res.forecast # one float: ŷ_{T+h}
res.selected_features # the names that survived
print(res.summary())
Notebook 34 (notebooks/34_penalized_macro_forecasting.py) and
puremacro/examples/penalized_macro_forecasting.py run the whole thing on
that panel. Nothing on this page touches the network.
The estimator
There is exactly one: an elastic net fitted by pure-NumPy coordinate descent with soft-thresholding, optionally with adaptive weights. Two arguments select the corner of that family you get.
alpha |
adaptive |
what you are fitting |
|---|---|---|
1.0 |
False |
Lasso |
1.0 |
True |
Adaptive Lasso (Zou 2006) |
0 < α < 1 |
False |
Elastic Net (Zou & Hastie 2005) |
0 < α < 1 |
True |
Adaptive Elastic Net |
0.0 |
False |
Ridge — closed-form path, see below |
0.0 |
True |
weighted Ridge (penalty w_j β_j²) |
alpha must lie in [0, 1]; anything else raises ValueError. The
objective, on standardized predictors, is
adaptive=False sets every w_j = 1. adaptive=True sets
Two details of that weight are worth knowing. The +1e-3 floor caps any
weight at 1000 before rescaling, so a predictor whose first-stage coefficient
is numerically zero is heavily penalized but not hard-excluded — Zou's
1/|β̂_j|^γ is +∞ there. (The coordinate loop does zero a predictor
outright at w_j ≥ 1e8, but the 1000 cap puts that threshold out of reach
unless the median first-stage coefficient exceeds 1e5.) And the exponent γ is
fixed at 1; there is no gamma argument. The first stage is a ridge on the
standardized data with penalty 1e-2 · I, but X'X has diagonal T, so with T > P that penalty is
a numerical stabiliser, not shrinkage: it agrees with OLS to about 1e-4. Its
job is to keep the first stage solvable when P > T, which is the case the
whole function exists for.
Parameters
Everything after y_target is keyword-only.
| parameter | default | what it does |
|---|---|---|
X_panel |
— | (T, P) DataFrame or ndarray of predictors dated t |
y_target |
— | (T,) Series or ndarray of the target |
horizon |
1 |
direct horizon h; y_{t+h} is regressed on X_t |
alpha |
0.5 |
elastic-net mixing in [0, 1]; 1.0 = Lasso, 0.0 = Ridge |
adaptive |
True |
adaptive weighting of the penalty |
n_lambdas |
40 |
points on the λ grid |
lambda_min_ratio |
1e-3 |
λ_min / λ_max |
The coordinate-descent tolerances (max_iter=1000, tol=1e-6) are internal to
_fit_coordinate_descent and not exposed.
Direct, never iterated
Multi-step is direct only. With horizon=h, the function fits
on rows t = 0 … T-h-1, and evaluates the fitted equation at the last row of
X_panel to get ŷ_{T+h}. There is no iterated route — no one-step model
chained forward, and no companion form — so a horizon-4 forecast is a
separately estimated horizon-4 model, not four applications of a horizon-1
one. That is the right default for a penalized fit (the iterated route needs a
model for every predictor as well as for the target), but it means the
selected predictor set changes with h and there is no reason for the
horizon-1 and horizon-4 sets to agree. On the shipped 30-predictor example the
adaptive Lasso selects 4 predictors at h=1 with in-sample R² 0.963, and a
different 3 at h=4 with R² 0.096 — the DGP loads on X_{t-1} and its
predictors are AR(1) with ρ between 0.3 and 0.8, so nearly all of the one-step
signal has decayed by four steps. That collapse is the estimator working, not
failing.
horizon=0 is accepted and does something different: it skips the alignment
entirely and returns the in-sample fitted value at the last observation,
not a forecast. horizon is not validated, and a negative value takes the same
branch.
How λ is chosen — BIC, and there are no folds
Selection is a grid search minimising the Bayesian information criterion on the estimation sample:
over n_lambdas points spaced geometrically from λ_max down to
λ_max · lambda_min_ratio. The df count is taken on the standardized
coefficients, while selected_features thresholds the un-standardized ones, so
on a panel with wildly different column scales the two counts can differ by one
or two. For alpha > 0, λ_max is the standard closed form — the smallest
penalty that drives every coefficient to zero, max_j |x_j'(y-ȳ)| / (T α w_j)
— verified: at λ_max exactly 0 coefficients survive and one appears at
0.999 λ_max. Ties go to the largest λ, i.e. to the sparser model. Each λ is
fitted from a cold start; there is no warm-starting down the path.
Ridge (alpha=0) is a different path. No finite λ zeroes a ridge
coefficient, so the grid is anchored on the spectrum instead: with e_max the
largest eigenvalue of the weighted X'X/T (X W^{-1/2}, W = diag(w_j)),
λ_max = 10 · e_max — where every direction of X is shrunk by a factor of at
least 11 — and the default lambda_min_ratio puts λ_min at 0.01 · e_max,
where the leading direction is shrunk by 1%. The path is solved in closed form
from one SVD, and the BIC's df is the trace of the hat matrix,
Σ_j d_j² / (d_j² + T λ) plus one for the intercept, not a count of non-zeros
(which would be P at every λ). On the shipped example alpha=0.0,
adaptive=False spans [0.024, 24.3] and reaches R² 0.967 against 0.968 for
OLS — at the low edge of the grid, because with T = 159 ≫ P = 30 the BIC has
no reason to shrink — while alpha=0.0, adaptive=True finds an interior
optimum at λ* = 2.03 with R² 0.964. Ridge never selects: selected_features
lists all P names.
There is no cross-validation anywhere in this function, or anywhere in
puremacro.forecast. No k-fold, no rolling-origin CV, no blocked or purged
folds, no held-out set. To say the obvious thing explicitly: the question "do
the folds respect time order?" has no answer here because there are no folds.
Nothing is resampled and nothing is shuffled, so no future observation can leak
into a training fold — but the flip side is that λ is chosen to fit the
estimation sample well, not to forecast well, and an information criterion is
a much blunter instrument for that than an out-of-sample loss.
Two consequences to plan around:
optimal_lambdais not comparable across calls. The grid is built fromαand the adaptive weights, so λ means a different amount of shrinkage in each configuration. On the shipped example,λ_maxis 1.57 atalpha=1.0, adaptive=Falseand 73.55 atalpha=1.0, adaptive=True. Compare selected sets and losses, not λ.- Check the chosen λ is interior. If it lands on either end of the grid,
the grid did not bracket the BIC minimum and the answer is an artefact of
lambda_min_ratio. It happens for real. Atalpha=0.02, adaptive=Trueon the shipped example the default grid pins toλ_min = 3.68and returns 11 predictors with R² 0.956;lambda_min_ratio=1e-5moves the optimum into the interior at λ* = 2.29 (13 predictors, R² 0.961), and addingn_lambdas=60refines it to λ* = 1.01 (16 predictors, R² 0.966).
edges = (res.bic_path.index[0], res.bic_path.index[-1])
assert res.optimal_lambda not in edges, "widen lambda_min_ratio"
Standardisation, and what a coefficient means
The predictors are standardized before fitting, using the training rows
only (X_mat[:-horizon]), by that block's mean and its population standard
deviation (np.std, ddof = 0). A column with zero variance gets its σ forced
to 1, which leaves it demeaned to all zeros — a constant column you leave in
X_panel therefore receives a coefficient of exactly 0.0 and never appears
in selected_features. Do not add your own intercept column; the intercept is
fitted separately and is never penalized.
The target is not standardized. It is only demeaned implicitly, through the
intercept, which is re-estimated as mean(y - Xβ) on every sweep.
This matters for reading the output, because the penalty applies to the standardized coefficients — so it is scale-free, and rescaling a predictor from percent to basis points does not change which predictors are selected — but the coefficients that come back are un-standardized:
So res.coefficients["unemployment"] is the effect on y of one unit of
that predictor in its own units, directly usable in
res.intercept + X_row @ res.coefficients, and not the standardized effect
you would compare across predictors to rank importance. For that ranking,
multiply back: res.coefficients * X_panel.iloc[:-horizon].std(ddof=0). The
summary() table sorts by the un-standardized magnitude, so it ranks by units,
not by importance.
What comes back
A frozen PenalizedForecastResult dataclass:
| field | type | contents |
|---|---|---|
forecast |
float |
ŷ_{T+h} — one number, not a path and not an interval |
selected_features |
list[str] |
names whose coefficient exceeds 1e-5 in absolute value, in column order |
coefficients |
pd.Series, length P |
all candidates, zeros included, original units |
intercept |
float |
un-standardized constant |
optimal_lambda |
float |
the BIC-minimising λ |
in_sample_r2 |
float |
R² of the chosen model on the training rows, clipped to [0, 1] |
bic_path |
pd.Series, length n_lambdas |
index = λ descending from λ_max; values = BIC |
horizon |
int |
the h you passed, echoed |
Plus the presentation methods: .summary() (a formatted string),
.to_frame() (every candidate with its coefficient and a selected flag),
.to_markdown() / .to_latex() / .to_typst() (that table rendered through
puremacro.reports) and .plot() (horizontal bars of the selected
coefficients, or the BIC path when nothing survived; returns the Figure). When
X_panel is an ndarray the feature names are generated as X_1 … X_P.
There is no standard error, no confidence interval and no predictive density — post-selection inference on a penalized fit is not valid without a debiasing step this module does not implement, and none is pretended.
Five things that will bite you
- NaNs propagate silently and the R² lies. There is no
dropna, no mask and no complete-case filter. One NaN anywhere inX_panelreturnsforecast=nan,selected_features=[]andin_sample_r2=1.0— the R² is clipped into[0, 1]after being computed from NaNs, so it comes out at the top of the range. Drop or interpolate before you call. A NaN confined to the last row ofX_panelis the nastier case: training is unaffected, the coefficients and R² are fine, and onlyforecastis NaN. - Alignment is positional; the index is ignored.
y_targetis converted with.to_numpy()and never joined onX_panel's index. Shifting the target's DatetimeIndex by five years produces a bit-identical result. Line the two up yourself. Mismatched lengths raiseValueError: y_target has 155 rows but X_panel has 160; alignment is positional. T - horizonmust be at least 10. Below that it raisesValueError: forecast_penalized: effective training sample too small (9 rows).in_sample_r2is in-sample and BIC-selected. WithP > Tit goes to 1.0 by interpolation — on a(40, 200)panel of pure noise the fit selects more predictors than it has training rows (40–45 across six seeds, against 39 rows) and reports R² 1.000. It is a diagnostic of the fit, never evidence about the forecast.horizonis not validated.horizon=0returns the in-sample fit at the last row and a negative value takes the same branch (see above).alpha,n_lambdasandlambda_min_ratioare validated:alpha=2.0, which used to run, now raises.
Turning one number into a track record
forecast_penalized produces a single forecast from a single estimation
sample, so evaluating it means refitting at every origin. Refitting is the
point — selecting predictors once on the full sample and then "backtesting"
them is look-ahead bias, and the sparse set genuinely does move. A fit is 40
cold-start coordinate-descent paths; at T=200, P=40 that is about 45 ms on an
Apple M3 Pro, so an 80-origin loop is a few seconds:
import numpy as np
from puremacro.forecast import forecast_penalized, diebold_mariano
X, y = X_panel, y_target # the shipped panel from the first block
h, start = 1, 120
preds, actuals = [], []
for t in range(start, len(y) - h):
res = forecast_penalized(X.iloc[: t + 1], y.iloc[: t + 1], horizon=h, alpha=1.0)
preds.append(res.forecast)
actuals.append(y.iloc[t + h])
e_pen = np.array(preds) - np.array(actuals)
e_bench = np.array([y.iloc[: t + 1].mean() for t in range(start, len(y) - h)]) - np.array(actuals)
diebold_mariano(e_pen, e_bench, h=h)
# {'stat': ..., 'p_value': ..., 'n_obs': len(preds), 'lag_used': 0}
lag_used is max(0, h-1), so a one-step comparison uses no Newey-West lags
at all.
Note that X.iloc[: t + 1] includes row t, which is exactly right: rows
0 … t-h train the model and row t is the one the forecast is evaluated at.
puremacro.forecast carries the rest of the evaluation kit, all of it
independent of how the forecasts were made:
| function | tests |
|---|---|
diebold_mariano(e1, e2, h=1, loss="mse", power=2.0, small_sample=True) |
unconditional equal predictive ability; Newey-West at h-1 lags, Harvey-Leybourne-Newbold small-sample correction on by default |
giacomini_white(e1, e2, h=1, conditioning_vars=None, loss="mse", power=2.0) |
conditional predictive ability — is the loss differential forecastable? |
model_confidence_set(losses, *, alpha=0.10, statistic="tmax", n_boot=1000, bootstrap="stationary", seed=None) |
Hansen-Lunde-Nason (2011) block-bootstrap elimination over M ≥ 2 models; block length defaults to max(2, round(T**(1/3))) |
losses_from_forecasts(forecasts, realized, *, loss="mse") |
builds the (T, M) loss matrix the MCS wants |
crps_gaussian, crps_ensemble, pit, pit_uniformity_test, berkowitz_test, klic_amisano_giacomini, combine_forecasts |
density-forecast scoring and combination |
model_confidence_set is the one to reach for past two models: a pairwise DM
run over M candidates has no family-wise error control, and the MCS is a
sequential elimination that does. It returns a dict with included,
excluded, pvalues, elimination_order, and the settings echoed back. Note
that puremacro.nowcast.combine.model_confidence_set is a different,
deliberately lightweight function — an MSE threshold used to build combination
weights, with no bootstrap and no size control.