Source code for sparho.problem

"""Bilevel problem definition: ``Problem = (datafit, penalty, design, target)``.

The Datafit and Penalty families are tagged unions of frozen dataclasses. The
v0.1 set is closed; algorithms exhaustively dispatch via ``match`` statements
with ``typing.assert_never`` on the default branch so mypy will flag any
unhandled case.

Extending the library with a new datafit/penalty means: (1) add a new frozen
dataclass to the union here, (2) implement the corresponding Rust kernel
under ``crates/sparho-core``, (3) add a new match arm in each algorithm that
dispatches on it. There is no inheritance hierarchy to subclass.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TypeAlias

from .core.types import Array, DesignMatrix

# ---------------------------------------------------------------- Datafit


[docs] @dataclass(frozen=True, slots=True) class SquaredLoss: """``L(Xβ, y) = 0.5 · ‖Xβ − y‖²``."""
[docs] @dataclass(frozen=True, slots=True) class LogisticLoss: """``L(Xβ, y) = Σᵢ log(1 + exp(−yᵢ (Xβ)ᵢ))`` with ``yᵢ ∈ {−1, +1}``."""
Datafit: TypeAlias = SquaredLoss | LogisticLoss """v0.1 datafit family. ``SmoothHinge`` for SVM/SVR is deliberately out of scope.""" # ---------------------------------------------------------------- Penalty
[docs] @dataclass(frozen=True, slots=True) class L1: """``R(β; α) = α · ‖β‖₁`` with scalar hyperparameter ``α > 0``."""
[docs] @dataclass(frozen=True, slots=True) class ElasticNet: """``R(β; α) = α · (ρ · ‖β‖₁ + (1 − ρ)/2 · ‖β‖²)``. The mixing weight ``ρ ∈ (0, 1]`` is structural (carried here, not tuned). The hyperparameter optimized by ``grad_search`` is the scalar ``α``. """ rho: float
[docs] @dataclass(frozen=True, slots=True) class WeightedL1: """``R(β; α) = Σⱼ αⱼ · |βⱼ|`` with per-feature hyperparameter vector ``α``."""
Penalty: TypeAlias = L1 | ElasticNet | WeightedL1 # ---------------------------------------------------------------- Problem
[docs] @dataclass(frozen=True, slots=True) class Problem: """A bilevel inner problem ``argmin_β L(Xβ, y) + R(β; α)``. The hyperparameter ``α`` is **not** stored here — it is what the outer search tunes. The problem captures the fixed structure: which loss, which regularizer family, which design matrix, which target vector. """ datafit: Datafit penalty: Penalty design: DesignMatrix target: Array @property def n_samples(self) -> int: """Number of observations (``X.shape[0]``).""" return int(self.design.shape[0]) @property def n_features(self) -> int: """Number of features (``X.shape[1]``).""" return int(self.design.shape[1])