Explainable optimization¶
A convex optimizer normally tells you what to hold and nothing about why. If an asset comes back at 0%, there is no way to ask whether it was excluded on its own merits or crowded out by a limit elsewhere in the portfolio.
jf.explain answers that:
import jaxfolio as jf
res = jf.minimum_variance(returns, constraints=[
jf.GroupCap("tech", ["AAPL", "MSFT", "NVDA"], max=0.30),
])
print(jf.explain(res).explain_text())
Constraint attribution — Minimum Variance (9 assets)
quality: exact · stationarity 2.72e-04
cost of capital (budget): +0.00010 per unit
Asset NVDA: 0.00% [at lower]
binding: tech (group_cap) shadow price -0.00009
own merit -0.00009 · charged by tech +0.00009 · net +0.00000
Asset MSFT: 23.50%
cause: own merits (interior)
Read the third line: NVDA's own merit is negative — it wants in — but the tech
cap charges it enough to push the net over the line. It was not excluded
because it is unattractive; it was excluded because its sector was full. That is
the distinction the report exists to draw.
Where the numbers come from¶
No second solve, and no extra solver. Every optimizer here minimizes f over a
convex set C by projected gradient, so at a solution w* stationarity says w*
is a fixed point of the projection:
So projecting w* - s∇f and keeping the projection's own multipliers hands
back the multipliers of the portfolio problem, divided by s:
| Recovered | From the projection | Meaning |
|---|---|---|
| \(\lambda\) | budget multiplier / s |
cost of capital |
| \(\theta_k\) | row multiplier / s |
shadow price of named row k |
| \(rc_i\) | \(-\beta_i / s\) | reduced cost of asset i |
Total cost: one gradient and two projections, both outside the solver loop. It
is therefore solver-agnostic — which is how it retrofits a real KKT measure onto
the optax solvers, whose own residual is a weight-update norm and
explicitly not an optimality test.
The pricing decomposition¶
The reduced cost splits into the two terms a portfolio manager would name:
At an active bound \(rc_i \ne 0\), and "what put this asset here?" is answered by whichever term is largest.
Rank the terms, not the multipliers
The obvious rule — take the largest multiplier among the constraints touching
asset i — is wrong in a way that looks right. At \(w_i = 0\) the box
multiplier equals the entire reduced cost, so it dominates every group term
by construction and box_lower would win every single time. "binding: tech"
could never fire. Ranking the terms of the decomposition is the textbook LP
column-pricing identity, and it degrades correctly: with no groups every zero
attributes to own, which is the truthful answer.
Signs¶
shadow_price is \(\partial(\text{named objective}) / \partial(\text{active limit})\)
— the derivative with respect to the bound that is actually binding. The sign
follows from sense, and the table below is worth reading carefully, because this
is where a reader will (reasonably) suspect an error:
| Objective | Active bound | multiplier \(\theta\) |
shadow_price |
Reading |
|---|---|---|---|---|
| minimize (variance, CVaR) | cap | \(>0\) | \(-\theta < 0\) | raising the cap lowers variance |
| minimize | floor | \(<0\) | \(-\theta > 0\) | raising the floor raises variance |
| maximize (Sharpe, utility, growth) | cap | \(>0\) | \(+\theta > 0\) | raising the cap raises Sharpe |
| maximize | floor | \(<0\) | \(+\theta < 0\) | raising the floor lowers Sharpe |
The invariant that never breaks: relaxing a constraint can only ever weakly improve the optimum. Only the presentation flips.
Units are per period, and are not annualized
Every other diagnostic on a PortfolioResult (expected_return,
volatility, sharpe) is annualized. Multipliers are not, deliberately:
×252 is right for a variance objective (linear in the covariance) and wrong
for Sharpe (√252), so scaling them would be wrong in a different way for each
method. They are reported in the objective's own per-period units.
Diagnostics¶
explain() returns a ConstraintReport. A digest
also lands in result.metadata, JSON-serializable, for consumers that never call
explain:
| Key | Meaning |
|---|---|
kkt_residual |
\(\lVert d\rVert\), the part of the gradient the constraints could not absorb. Zero at an exact optimum. |
stationarity |
kkt_residual / ‖∇f‖ — the same thing, scale-free. |
dual_quality |
exact / approximate / unreliable. See below. |
n_binding_constraints, binding_constraints |
Which named rows are actually moving the optimum. |
Per-constraint and per-asset views come back as Polars frames:
report = jf.explain(res)
report.constraints_frame() # which constraint costs the portfolio the most
report.to_frame() # what put each asset where it is
report.binding() # just the rows that move the optimum
report.for_asset("NVDA")
Constraint status is four-way, not two:
| Status | Meaning |
|---|---|
binding |
Active and carrying a non-zero multiplier — relaxing it moves the optimum. |
weakly_active |
On its bound with a zero multiplier (a degenerate vertex). Relaxing it will not move anything, and it is never named as a cause. |
inactive |
Slack. The multiplier is exactly zero, not merely small. |
unidentified |
Active, but the multiplier is not pinned by the data. shadow_price is None. |
Five renderings, one report¶
The same fields, formatted for whoever is reading. None of them recompute anything.
| Call | For |
|---|---|
explain_text(top_n=…) |
prose — reading one asset's story |
to_table(max_assets=…, notes=…) |
a printed report: header block, constraints table, assets table, notes |
to_json(indent=…) / to_dict() |
a machine — the whole report, serialized |
to_frame() / constraints_frame() |
Polars — sorting, filtering, joining |
result.metadata |
a backtest log — the five-key digest, always present |
to_table() is the one to print. Every string jaxfolio wrote is folded to ASCII on
the way in — a report ends up in a log file, a CI artifact or a Windows console, and
<= survives an encoding that ≤ does not. The reasons and warnings are written for
this guide, so they arrive full of em dashes; the fold is a transliteration, not a
filter, so nothing a reader needs is lost. Your own strings — asset names, constraint
names — pass through verbatim, because mangling a ticker to fit a charset is the worse
trade:
==============================================================================
CONSTRAINT ATTRIBUTION -- Minimum Variance
==============================================================================
assets 9
objective variance (minimize)
dual quality exact
stationarity 2.72e-04
KKT residual 6.43e-08
cost of capital +9.622e-05 per unit
binding rows 1 of 3: tech
CONSTRAINTS
name kind activity bound slack multiplier shadow price status
---------- --------- -------- --------- ----- ---------- ------------ --------
tech group_cap 30.00% <= 30.00% 0.00% +8.900e-05 -8.900e-05 binding
energy group_row 45.29% <= 50.00% 4.71% +0.000e+00 +0.000e+00 inactive
financials group_row 24.71% >= 15.00% 9.71% +0.000e+00 +0.000e+00 inactive
ASSETS
asset weight status own merit imposed net cause confidence
-------- ------ -------- ---------- ---------- ---------- ---------------- ----------
ASSET_08 0.00% at lower -8.659e-05 +8.900e-05 +2.412e-06 tech (group_cap) unique
ASSET_01 23.50% interior n/a n/a +0.000e+00 own merits unique
...
NOTES
- multipliers are per period, in the objective's own units (not annualized)
Three details in there are load-bearing. The bound column names only a limit that
could bind — a cap at or above the budget and a floor at zero are not drawn, because a
column of <= 100% reads as a limit doing work. Anything the report declines to quote
prints as n/a, never as a 0.00 that would read as "costs nothing". And max_assets
announces what it dropped ((5 further asset(s) not shown)) rather than letting a
truncated report look complete.
to_json() carries the same discipline into the serialized form — a suppressed shadow
price is null:
payload = jf.explain(res).to_dict() # or .to_json(indent=2)
payload["constraints"][0]
# {'name': 'tech', 'kind': 'group_cap', 'activity': 0.30000002682209015,
# 'lower': 0.0, 'upper': 0.3, 'slack': -2.682209016002801e-08,
# 'multiplier': 8.900064131012186e-05, 'shadow_price': -8.900064131012186e-05,
# 'status': 'binding', 'members': ['AAPL', 'MSFT', 'NVDA']}
Top-level keys: schema, method, objective, sense, available, quality,
reason, diagnostics, budget, constraints, assets, warnings. Every scalar is a
plain Python float / str / bool / None — no numpy types, no NaN, so
json.dumps(..., allow_nan=False) holds. schema is versioned
(jaxfolio.constraint_report/1) so a consumer can tell layouts apart if this grows.
What this cannot tell you¶
The list is not short, and every entry is enforced rather than merely documented.
When a number cannot be stood behind it comes back None.
Non-smooth objectives — min_cvar refuses outright. The CVaR objective
contains max(loss - tau, 0), and at the optimum tau sits exactly on a sample
loss, generically. jax.grad returns one arbitrary subgradient at that kink, so
stationarity need not hold even at the exact solution. Quality is unreliable and
every shadow price is None. The primal active set is still reported, along with
aux_stationarity — a free bonus diagnostic, |∂CVaR/∂tau|, saying how far the
VaR threshold is from its own optimum.
Unconverged solves. Judged against the tolerance you asked for, plus a
scale-free backstop. solver="sgd", max_iter=5 earns unreliable and gets no
shadow prices — and note that this is detected by a KKT residual computed
independently of the solver's own convergence measure, which for the optax
family cannot reveal it.
Scale-invariant objectives have no cost of capital. Sharpe and the
diversification ratio are degree-0 homogeneous, so by Euler's identity
\(g'w^* = 0\) and "relax the budget" is meaningless — the optimum is unchanged up to
scaling. budget_shadow_price is None with a note. Detected numerically
(testing \(|g'w|\)), not from a list of method names, so a custom scale-invariant
objective gets the same protection. Row and box multipliers stay meaningful.
Unidentified multipliers. A row's multiplier is pinned only when at least one
member is strictly interior to its box — that member's stationarity equation is
what separates \(\theta_k\) from \(\beta_i\). When every member sits on a bound, only
the sum is determined and any split is an equally valid KKT certificate. The row
reports status="unidentified", shadow_price=None, and every affected asset gets
confidence="ambiguous" with primary=None. The same holds for the budget
multiplier, which additionally needs an interior asset outside every binding row.
l2_reg muddies the picture, and says so. With a penalty active the
multipliers price the penalized objective, and more insidiously L2 softens
corners — so a constraint doing real economic work can read as inactive. A
warning is attached whenever l2_reg > 0.
Non-convex objectives are local only. maximum_sharpe and
maximum_diversification as written are not convex. The multipliers remain valid
on the local branch, so a warning is attached and no global-optimality claim is
made.
Optimizers with no duals at all. equal_weight, inverse_volatility,
risk_parity, the graph methods (HRP, HERC, MST) and the learning policies solve
no constrained program. explain() still returns a well-formed report, with
available=False and a specific reason. These are feasible by construction,
not optimal subject to — a zero weight is caused by the construction, not by a
constraint. risk_parity's reason points at its existing
metadata["risk_contributions"], which is the right per-asset attribution for
that method.
And finally: a shadow price is not a forecast. It is \(\partial f^*/\partial c\) on in-sample estimated moments. It says what the optimizer would have done with a different limit, not what the market will do.
Cost, and switching it off¶
One gradient and two projections per solve, outside the solver loop — negligible next to the solve itself, and it never touches the weights (there is a test asserting bit-identical results with the capture on and off). For tight backtest loops where the explanation is never read:
explain() then returns available=False rather than failing — it never requires
the payload.
Accuracy¶
Shadow prices are validated against central finite differences on re-solves of the optimizer itself — no external solver involved — across all three sign regimes:
| Case | Agreement with finite difference |
|---|---|
| minimize + cap (variance vs a sector cap) | 4–5 significant figures |
| maximize + cap (Sharpe vs a sector cap) | 4–5 significant figures |
| minimize + floor (variance vs a sector floor) | 4 significant figures |
The floor case is included deliberately: taking an absolute value anywhere in the sign handling inverts it while every cap test keeps passing.