Model card — L1 trend filtering and componentwise L2 boosting¶
Family: l1_trend_filter, boosting
Two deterministic estimators from the machine-learning module's "convex and
greedy" corner. l1_trend_filter is Kim, Koh & Boyd's (2009) answer to the
Hodrick-Prescott filter: swap HP's squared penalty on second differences for
an L1 penalty and the trend becomes piecewise linear, with the kinks —
the knots — chosen by the data the way the LASSO chooses variables; the same
function computes the HP filter itself under penalty="l2", so the two
trends can be compared on one objective. boosting is componentwise L2
boosting (Bühlmann & Yu 2003; Bühlmann 2006), the engine of R's
mboost::glmboost: a slow-learning variable selector that adds one
shrunken single-column least-squares fit per step, which macro forecasters
read as sequential ARDL building (Ng 2014). Both are seedless and
deterministic; neither forms an n×n matrix.
| Function | Role |
|---|---|
l1_trend_filter |
Piecewise-linear (or -constant) trend with data-chosen knots; HP filter under penalty="l2" |
boosting |
Componentwise L2 boosting with corrected-AIC stopping; full coefficient / selection / df paths |
What it estimates¶
-
l1_trend_filter(y, lam, order=2, penalty="l1")— the trendxminimizing(1/2)·‖y − x‖² + lam·‖D x‖₁ (penalty="l1") (1/2)·‖y − x‖² + (lam/2)·‖D x‖² (penalty="l2")
with D the order-th difference operator. Under L1, most order-th
differences of the trend are exactly zero: order=2 gives a
piecewise-linear trend (the Kim-Koh-Boyd filter), order=1 a
piecewise-constant one (the fused LASSO on the level / total-variation
denoising). The indices where the difference is nonzero are the knots.
Under L2 with order=2 the minimizer is the Hodrick-Prescott trend for
the same lam — ‖y − x‖² + lam‖Dx‖² and (1/2)‖y − x‖² +
(lam/2)‖Dx‖² have identical minimizers — so lam=1600 is quarterly HP,
and the suite asserts equality with hp_filter at 1e-10.
- boosting(x, y, learning_rate=0.1, n_steps=500, stop="aic") — from
F_0 = 0, each step regresses the current residual on every column of
x separately, keeps the column with the smallest residual sum of
squares, and adds learning_rate times that least-squares fit to the
model. After m steps the fit is B_m y for the boosting operator
B_m = B_{m−1} + ν H_j (I − B_{m−1}), H_j = x_j x_jᵀ / x_jᵀx_j, whose
trace is the effective degrees of freedom in Bühlmann's (2006) corrected
AIC, AIC_c(m) = log(RSS_m/n) + (1 + df_m/n)/(1 − (df_m + 2)/n).
stop="aic" reports the minimizing step; the whole path is returned
regardless.
Assumptions¶
- No intercept, no standardization — as everywhere in the penalized
family:
boostingfits the objective on the design exactly as passed. Centeryand center (typically standardize) the columns ofxfirst; a nonzero mean inyis absorbed into the slopes and every coefficient is wrong.l1_trend_filterneeds no centering (the trend carries the level). lamscales with the data. The trend-filter objective scales asy², solamscales withy— alamtuned on one series does not transfer to another with different units. Scanlamdown fromlam_max(returned), the smallest value at which the L1 trend collapses to the least-squares polynomial of degreeorder − 1; below it, knots appear one at a time. Under L2 the usual HP conventions apply (1600 quarterly, 129600 monthly, 6.25 annual).lamis not comparable across penalties. An L1lamis a bound on the dual variable; an L2lamis a smoothness weight. They share the data-fit term and nothing else.- The boosting operator is tracked exactly, not approximately.
B_mis kept in a rank-mfactored form,B_m = Σ ν x_{j_i} w_iᵀ, so its trace is the same number a dense n×n update produces, to rounding (pinned at 1e-12 against a dense transcription). What is not done is materializingB_m; the fit at the chosen step is recomputed asx @ coef, which the dense-operator fit reproduces at 3.6e-15. - Boosting ties go to the smallest column index, and there is no
randomness anywhere: the
selectedsequence is a deterministic function of the inputs (seedless). Zero-norm columns are never selectable.
When to use¶
l1_trend_filter(order=2)when you want a trend that is linear between a few dates and want the dates chosen by the data — growth regimes, productivity-slowdown narratives, "the trend changed in 2008" arguments — rather than HP's smooth curve that spreads every change over years. Kim-Koh-Boyd's recommendation: read the knots as regime dates and the slopes as growth rates.l1_trend_filter(order=1)for level shifts (piecewise-constant means): a fast fused-LASSO segmentation to compare against the structural-breaks module's tests.l1_trend_filter(penalty="l2")when you want HP on the same footing as the L1 trend — samelamscale, same output keys, the same certificate — for a side-by-side.boostingas a slow, interpretable variable selector in a data-rich regression (many lags, many predictors): the selection order is the story, anddf_path/aic_pathshow where the corrected AIC stops adding. Withlearning_rate=1.0it is unshrunk forward stagewise; withlearning_rate → 0and many steps it converges to OLS on the columns it ever selects.
Key arguments and defaults¶
| Call | Argument | Default | Notes |
|---|---|---|---|
l1_trend_filter |
lam |
— (required) | penalty weight, ≥ 0; scan down from the returned lam_max |
order |
2 |
2 piecewise-linear, 1 piecewise-constant; anything else raises |
|
penalty |
"l1" |
"l1" trend filtering, "l2" Hodrick-Prescott |
|
tol |
None (→ 1e-8) |
relative duality gap at which the L1 solver stops; inert under "l2" and raises if passed there |
|
max_iter |
None (→ 10000) |
interior-point Newton-step budget (20–60 typical); same sentinel rule | |
boosting |
learning_rate |
0.1 |
step shrinkage ν ∈ (0, 1]; outside that range raises |
n_steps |
500 |
iterations run (≥ 1); the AIC step is searched inside them | |
stop |
"aic" |
"aic" reports the corrected-AIC minimizer, "none" the last step |
|
x_test |
None |
optional n_test × p matrix scored with the reported coefficients |
How to read the output¶
l1_trend_filter→{"trend", "cycle", "knots", "n_knots", "duality_gap", "objective", "converged", "n_iter", "lam_max"}.cycle = y − trend.knotsare indices into theorder-th differences (0..n−order) where|(D·trend)_i|exceedsmax(1e-6·max|D y|, 1e-12·max|y|); a knot at indexifororder=2is a slope change between observationsi+1andi+2. Under L1 the inactive differences are zero to rounding, so the count is meaningful; under L2 no difference is ever exactly zero and nearly every index is listed.duality_gapis a certificate: the objective at the returned trend minus a dual-feasible dual objective, soobjective − optimum ≤ duality_gapby weak duality — a number the user can check, not a solver's opinion.convergedisduality_gap ≤ tol·objective; it is alwaysTrueon the closed-form paths ("l2",lam=0,lam ≥ lam_max, all withn_iter=0).boosting→{"coef", "coef_path", "selected", "rss_path", "df_path", "aic_path", "best_step", "fitted", "predicted"}.best_stepis a 0-based index into the path arrays (the model afterbest_step + 1iterations);coef == coef_path[best_step].selected[m]is the column added at stepm;df_path[m] = tr(B_m);aic_pathentries wheredf_m + 2 ≥ nare+inf(never selected).predictedisNoneunlessx_testwas given.
Failure modes¶
- Passing a raw
y/xwith a mean toboosting. No intercept is fit; the slopes absorb the mean. Symptom: the first selected column is whichever best mimics a constant, and every coefficient is off. Fix: centery, standardizex. - Reading
lamacross series or penalties. The objective scales asy²; use the returnedlam_maxas the yardstick (lam = 0.05·lam_maxtransfers,lam = 24.7does not). An L1lamsays nothing about the right HPlam. - A
tolbelow the certificate's floating-point floor. The certificate is evaluated in floating point and its floor is about 1e-11 relative (the L1 term payslamon the rounding residue of every inactive difference; larger for largerlam²·n). Atolbelow it cannot be certified: the stall detector ends the loop after ten iterations without a 1% improvement (about 40 total) and the call returnsconverged=Falsewith the honest gap — the trend it returns is still the converged one to 1e-8. The default1e-8sits two to three decades above the floor.max_iteris likewise honest: a starved budget returns the last iterate withconverged=Falseand its larger gap. - Exactly
lam = lam_max. The problem is degenerate there (one constraint active with a zero multiplier) and no solver's gap pins the trend tightly — by strong convexity a relative gapgonly bounds the trend error by√(2·g·objective). The crate returns the closed-form polynomial atlam ≥ lam_maxusing its ownlam_max; alama rounding error below it goes through the solver and lands within ~1e-7 of the polynomial. Uselam ≥ 1.01·lam_maxif you mean "the polynomial". tol/max_iterwithpenalty="l2". The L2 trend is one banded closed-form solve: nothing iterates. Passing either explicitly raises naming the kwarg and the fix (the sentinel convention of audit round 10) rather than being silently ignored.- Boosting
df_pathis not monotone. The boosting operator is not symmetric, sotr(B_m)can dip by ~1e-5 near the saturatedlearning_rate=1limit. It is not a bug: the dense transcription agrees to 1e-15. aic_pathrunning to the last step. With a smalllearning_ratethe corrected AIC may still be falling atn_steps; thenbest_step == n_steps − 1and the model is under-fit. Raisen_steps(cost isO(n·(p + m))per step, 0.09 s forn=500, p=50, 500 steps).
Relation to the HP filter and the boosted HP filter¶
The three trends sit on one objective, (1/2)‖y − x‖² + lam·φ(D₂x):
- HP is
φ = (1/2)‖·‖²— a linear smoother,x = (I + lam D₂ᵀD₂)⁻¹y, which spreads every change in slope over many periods and, on a stochastic trend, under-smooths at the ends and leaves a residual with spurious cycles (Hamilton 2018).l1_trend_filter(penalty="l2")is this filter, samelam. - L1 trend filtering is
φ = ‖·‖₁— the same data-fit term, a nonlinear estimator: piecewise linear, sparse in slope changes, with the change dates estimated. It answers Hamilton's critique differently from the Hamilton filter: instead of abandoning the smoother it changes the penalty's geometry, and reports the fit's suboptimality as a certificate. - Boosted HP (Phillips & Shi 2021; roadmap Module 1, not shipped)
keeps
φ = (1/2)‖·‖²and iterates HP on its own residual — an L2 boosting of the HP smoother,x_(m) = x_(m−1) + S(y − x_(m−1))withSthe HP operator — stopping by a BIC or ADF rule. It removes the under-smoothing of stochastic trends by re-applying the linear smoother; L1 trend filtering removes it by sparsity. Theboostingfunction on this card is the componentwise (regression) version of the same L2-boosting idea: the base learner is a single column instead of the HP smoother, and the corrected AIC plays the role of Phillips-Shi's BIC stop. When a boosted HP lands in the filters module it will reuse this card's operator-trace bookkeeping.
Validated against¶
Graded per leg, honestly:
l1_trend_filter,penalty="l1"— (1) an optimality certificate re-derived from scratch in the Rust and Python tests for the crate's own trend on all 14 fixture cases (dual variable recovered from the residual byordercumulative sums, clipped into the dual box,P(x) − G(v)an upper bound by weak duality; asserted ≤ 1e-8 relative, achieved ≤ 3.3e-10 on the closed-form polynomial cases, ≤ 5.8e-11 on the interior-point cases, 1e-15 on the order-1 cases); (2) cvxpy + Clarabel 0.11.1 (cvxpy 1.9.2; an independent interior-point solver) converged at 1e-14, each reference's own relative gap recorded in the fixture (1.6e-16 to 1.6e-12): trends agree at 1e-8 absolute, achieved 1.4e-10; (3) the closed-form limits —lam_maxat 1e-10 relative and thenp.polyfitpolynomial atlam ≥ lam_maxat 1e-8 (achieved 1.4e-10),lam → 0returning the data at 1e-10.l1_trend_filter,penalty="l2"— the densenp.linalg.solve(I + lam DᵀD, y)closed form at 1e-10 (achieved 1.7e-12) and the cross-surface identity withhp_filterat 1e-10 (achieved 1.4e-12 across the suite's four series and threelamvalues).boosting— a transcription grade, not a third-party run: an independent dense NumPy transcription of the published algorithm with the boosting operator formed explicitly (so its trace is exact by construction) pinscoef_path,df_path,aic_pathat 1e-12 (achieved 6.7e-16 / 2.7e-15 / 1.6e-15) andselected/best_stepexactly on five cases; R mboostglmboost, the roadmap's target, is not runnable in the build environment, and cross-checking against it is an open follow-up. Properties: RSS nonincreasing; the small-step / many-step limit reproduces OLS on the selected support at 3.3e-14; AIC stopping recovers a sparse truth's support.- Timing (release wheel):
l1_trend_filteratn = 10000in 0.12 s (49 interior-point iterations; the L2 form in 4 ms);boostingatn = 500, p = 50, n_steps = 500in 0.09 s.
Fixture: fixtures/convex.json
(generator generate_convex_fixtures.py, seeded, never imports tsecon).
References¶
- Kim, S.-J., Koh, K., Boyd, S. & Gorinevsky, D. (2009). "ℓ₁ Trend Filtering." SIAM Review 51(2).
- Tibshirani, R. J. (2014). "Adaptive piecewise polynomial estimation via trend filtering." Annals of Statistics 42(1).
- Hodrick, R. & Prescott, E. (1997). "Postwar U.S. Business Cycles: An Empirical Investigation." JMCB 29(1).
- Phillips, P. C. B. & Shi, Z. (2021). "Boosting: Why You Can Use the HP Filter." International Economic Review 62(2).
- Bühlmann, P. & Yu, B. (2003). "Boosting with the L2 loss: regression and classification." JASA 98(462).
- Bühlmann, P. (2006). "Boosting for high-dimensional linear models." Annals of Statistics 34(2).
- Ng, S. (2014). "Viewpoint: Boosting recessions." Canadian Journal of Economics 47(1).
See also: Penalized regression and leakage-safe validation.
Runnable example¶
import numpy as np
import tsecon
rng = np.random.default_rng(3)
n = 160
t = np.arange(n)
# A trend whose slope changes at t = 60 and t = 110, plus noise.
slope = np.where(t < 60, 0.20, np.where(t < 110, -0.10, 0.15))
y = np.cumsum(slope) + 0.6 * rng.standard_normal(n)
# 1. L1 trend filtering: scan lam down from lam_max (the polynomial limit).
lam_max = tsecon.l1_trend_filter(y, 1.0)["lam_max"]
fit = tsecon.l1_trend_filter(y, lam=0.05 * lam_max)
print("knots:", fit["knots"].tolist(), " converged:", fit["converged"],
" relative gap: %.1e" % (fit["duality_gap"] / fit["objective"]))
# 2. The squared penalty is the Hodrick-Prescott filter under HP's own lam.
hp = tsecon.l1_trend_filter(y, lam=1600.0, penalty="l2")
print("HP identity, max |diff|: %.1e"
% np.max(np.abs(hp["trend"] - tsecon.hp_filter(y, lamb=1600.0)["trend"])))
# 3. Componentwise L2 boosting on a sparse truth (centered y, standardized X).
X = rng.standard_normal((120, 10))
X = (X - X.mean(0)) / X.std(0)
beta = np.array([2.0, -1.5, 1.0, 0, 0, 0, 0, 0, 0, 0])
yb = X @ beta + 0.5 * rng.standard_normal(120)
yb -= yb.mean()
b = tsecon.boosting(X, yb, learning_rate=0.1, n_steps=500)
print("AIC-chosen step:", b["best_step"], " df: %.2f" % b["df_path"][b["best_step"]],
" nonzero columns:", np.flatnonzero(b["coef"]).tolist())
print("coef[:3]:", np.round(b["coef"][:3], 3))
Expected output:
knots: [52, 57, 106, 111] converged: True relative gap: 6.1e-12
HP identity, max |diff|: 2.4e-12
AIC-chosen step: 141 df: 4.16 nonzero columns: [0, 1, 2, 3, 4, 7]
coef[:3]: [ 2.039 -1.505 0.981]
The two true slope changes (at 60 and 110) show up as knot pairs two
observations apart — the L1 solution splits a kink across neighbouring
second differences when the data cannot resolve the exact date — and the
boosted model keeps the three true signals near their values (2, −1.5, 1)
while three noise columns carry small coefficients the AIC could not
reject; a slower learning_rate or the LASSO path's BIC is the usual
tightening.