Protocols & extension points¶
sparho deliberately exposes a small extension surface — four pluggable shapes that cover the things downstream users actually want to change.
Surface |
Kind |
What it does |
|---|---|---|
|
Protocol |
Drive the inner problem at a fixed |
|
Protocol |
Evaluate the outer loss + gradient |
Datafit / Penalty |
Tagged unions |
Add new |
Hypergrad function |
Callable |
Swap the implicit-diff solver itself (rarely needed at v0.1). |
The Optimizer Protocol that was sketched in the original plan was
folded into grad_search / hoag_search. If you need a fundamentally
different outer step (Adam, L-BFGS), write a new search function — the
shared boundary is the Criterion.value_and_hypergrad call, not an
optimizer state object.
Solver¶
A solver is anything callable with the canonical signature:
from sparho import Problem, SolverResult
from sparho.core.types import Array, Hyperparam
def my_solver(
problem: Problem,
hyperparam: Hyperparam,
/,
*,
x0: Array | None = None,
tol: float | None = None,
) -> SolverResult:
...
Return a sparho.SolverResult with the
estimated β, the integer active_set (sorted ascending, int32),
a dual-gap (or stationarity proxy), and the inner iteration count.
x0is an optional warm-start guess. If your solver supports it, use it. If not, ignore it — the outer loop will still work, just slower.tolis an optional override of the adapter’s default inner tolerance.hoag_searchuses this to schedule inner accuracy across outer iterations. Adapters with no meaningfultolmay ignore it.
The cheapest way to plug in is sparho.adapters.as_solver():
from sparho.adapters import as_solver
solver = as_solver(my_solver, name="my-coord-descent")
The wrapper introspects your function’s signature and forwards x0 /
tol only when they are declared — a plain (problem, hp) -> SolverResult
callable keeps working.
If you can’t satisfy the protocol (e.g. you want to validate inputs in
__post_init__), write a frozen dataclass with __call__ matching the
signature above — that’s how the bundled adapters are written.
What active_set has to be¶
The implicit-differentiation hypergradient is restricted to the active set you return. A wrong active set produces a wrong hypergradient — there is no separate sanity check. Use the bundled helper:
from sparho.adapters._common import active_set_of
active = active_set_of(coef) # int32 indices where |coef| > 0
For penalties whose support isn’t simply coef != 0 (group lasso, fused
lasso), report the generalized active set — the set of indices the inner
solver considers “free” at convergence. Subgradient slack on the boundary
is documented as a v0.2 concern.
Criterion¶
A criterion exposes two methods:
class MyCriterion:
def value(self, problem, hp, solver, *, x0=None, tol=None) -> float:
...
def value_and_hypergrad(
self, problem, hp, solver, hypergrad_fn, *, x0=None, tol=None
) -> CriterionResult:
...
valueis the cheap value-only path used by trial steps. Don’t compute derivatives here.value_and_hypergradruns the inner solver, evaluates the outer loss, computes∂C/∂βat the convergedβ*, and callshypergrad_fnto chain it through the implicit-diff solve. Return asparho.CriterionResult.
The bundled HeldOutMSE, HeldOutLogistic, CrossVal are 60-line
frozen dataclasses; copy and adapt. A common case is “the same MSE but
weighted observations” — write a new dataclass; don’t subclass.
Datafit / Penalty (tagged unions)¶
The v0.1 Datafit = SquaredLoss | LogisticLoss and
Penalty = L1 | ElasticNet | WeightedL1 unions are closed. To add a new
variant:
Add a
@dataclass(frozen=True, slots=True)topython/sparho/problem.pyand export it frompython/sparho/__init__.py.Implement the corresponding Rust kernels in
crates/sparho-coreand expose them viacrates/sparho-py/src/lib.rs. Update the type stubs inpython/sparho/_core.pyi.Add a
casearm in everymatchover the union. At minimumsparho.implicit_forward(); plus any adapter / criterion that dispatches on the union. Leave thecase _: assert_never(x)tail intact — mypy strict mode is the safety net for missed cases.
There is no inheritance hierarchy to subclass. If a new datafit doesn’t
fit (e.g. a non-separable penalty needs a prox rather than a closed-form
Jacobian) lift the abstraction one notch higher, but do it in a PR — the
union shape is a deliberate v0.1 constraint.
Hypergradient function¶
implicit_forward is the only mode at v0.1. The function signature is
HypergradFn = Callable[..., Hyperparam]
# (train_problem, hp, solver_result, criterion_grad_beta, **opts) -> Hyperparam
Both grad_search and hoag_search accept it as an optional kwarg:
hoag_search(problem, hp0=1e-2, solver=..., criterion=..., hypergrad=my_hg)
Anything with that signature works. The v0.1 design assumes implicit
differentiation; unrolled / forward-mode hypergradients are deferred to
v0.2. If you’re writing one, look at implicit_forward’s sparse-X
machinery in python/sparho/hypergrad.py — the Rust kernels for
restricted Hessian matvecs are usable as-is.