diff --git a/.github/workflows/maintenance-compatibility.yml b/.github/workflows/maintenance-compatibility.yml new file mode 100644 index 000000000..04c16ccd8 --- /dev/null +++ b/.github/workflows/maintenance-compatibility.yml @@ -0,0 +1,45 @@ +name: Maintenance compatibility + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + sklearn-compatibility: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sklearn_spec: + - scikit-learn==1.2.2 + - scikit-learn==1.3.2 + - scikit-learn + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install compatibility environment + run: | + python -m pip install --upgrade pip + python -m pip install "numpy<2" scipy pytest packaging pandas patsy "${{ matrix.sklearn_spec }}" + python -m pip install -e . --no-deps + + - name: Validate maintenance benchmark syntax + run: | + python -m py_compile dev/benchmarks/benchmark_torch_compile_maintenance.py + + - name: Run maintenance regressions + run: | + python -m pytest \ + dev/tests/test_maintenance_024_025.py \ + dev/tests/test_legacy_sklearn_integration.py \ + dev/tests/test_second_full_review.py::TestEstimatorCloneAndFeatureSelectionBackend::test_all_default_public_estimators_clone \ + -q --tb=short diff --git a/.gitignore b/.gitignore index dfdb9fbf4..2dfb92386 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,11 @@ dev/tests/verify_*.py dev/tests/REMOTE_GPU_TEST_GUIDE.md dev/tests/TORCH_BACKEND_CV_TESTING.md + +# Maintained pytest modules must always be visible to git. Move manual, +# remote, benchmark, or exploratory scripts to dev/manual instead. +!dev/tests/test_*.py + # Build artifacts build/ dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 282c12a5e..8439901f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,79 @@ # Changelog +- Removed universal ElasticNet backend thresholds, coefficient tolerances, and fixed speedup claims that were not established for the current exact-head environment; the model guide now requires workload-specific benchmarking and dtype/solver-specific validation. + +- Corrected ElasticNet/Ridge scaling documentation and added a regression test confirming that `ElasticNet(alpha, l1_ratio=0)` matches `Ridge(alpha)` under the shared average-loss convention. + +- Reconciled the ElasticNet API documentation with the implementation by correcting constructor defaults, removing nonexistent parameters, and replacing stale strict/approx guidance with the actual FISTA and post-fit inference semantics. + +- Completed the public ElasticNet inference contract: the standalone wrapper now exposes and forwards inference options, and ElasticNetCV honors `compute_inference=True` on its final full-data refit with NumPy/CuPy/Torch matrix tests. + +- Integrated transactional CV reset with the shared public finite-input guard, so NaN/Inf refit attempts invalidate stale RidgeCV, ElasticNetCV, LogisticRegressionCV, and unified penalized-CV state before validation raises. + +- Made dedicated RidgeCV, ElasticNetCV, and LogisticRegressionCV refits failure-safe: every fit attempt clears stale fitted state, and CV selections are published only after the final model refit succeeds. + +- Pinned AUTO-mode RidgeCV, ElasticNetCV, and LogisticRegressionCV final refits to the backend selected during CV, preventing silent Torch/CuPy backend drift after parameter selection. + +- Preserved `device='auto'` through public RidgeCV, ElasticNetCV, and LogisticRegressionCV dispatch so GPU-resident inputs retain their owning backend; LogisticRegressionCV now validates 0/1 responses without a full GPU-to-CPU copy. + +- Logistic and ElasticNet CV default regularization grids now use analytic weights and satisfy integer-weight row-replication equivalence; CV GPU-array device inspection no longer masks runtime failures. + +- Dedicated Ridge, ElasticNet, and Logistic CV routines now preserve explicit Torch versus CuPy backend requests, normalize Device enum values consistently, and validate analytic weights before grid generation or degenerate returns. + +- Corrected analytic-weight LogisticRegression IRLS across NumPy, CuPy, and Torch: weights now enter WLS curvature rather than the working-response denominator, and weighted likelihood/inference use the same objective. Narrowed penalized-CV alpha-grid and exact CuPy Ridge fallbacks so programming, CUDA OOM, and device errors propagate. + +- Completed penalized-CV fallback hardening: optional Lipschitz recovery now recognizes NumPy/CuPy/Torch rank failures consistently, while alpha-grid estimation no longer hides memory or GPU infrastructure failures. + +- Preserved the declared validation objective in penalized CV: non-Gaussian losses no longer silently fall back to MSE, weighted squared-error fallback retains validation weights, and GPU infrastructure failures propagate through layered CV fallbacks. + +- Narrowed GPU linear-algebra fallbacks so only genuine rank/definiteness failures use least-squares, pseudo-inverse, ridge, or zero-block recovery; CUDA OOM, device, index, and programming errors now propagate. + All notable changes to statgpu are documented here, organized by release and date. +## Unreleased — maintenance hardening + +- Removed the package-initialization cycle between `statgpu.glm_core` and the Cox loss export by lazily exposing `CoxPartialLikelihoodLoss`; GLM internals can now be imported first in a fresh interpreter. +- Removed the over-broad Armijo `out of range` numerical marker so index and device programming errors propagate instead of being mistaken for recoverable trial-point domain failures. +- Made proximal-Newton Armijo backtracking treat recognized numeric-domain ValueError trials consistently with Newton while still propagating input-contract and infrastructure failures. +- Narrowed the shared backend linear-system fallback to genuine rank failures; CUDA OOM, device, and unrelated RuntimeError failures now propagate instead of being silently retried with least squares. +- Made shared NumPy zero/conversion helpers honor a floating reference array dtype, matching the existing CuPy/Torch backend contract while retaining float64 defaults for integer references. +- Normalized FISTA/FISTA-BB warm starts to the preprocessed design and converted smooth proximal-Newton sample weights to the active backend, device, and dtype before loss evaluation. +- Narrowed Newton-family Armijo trial exception handling to expected numeric-domain failures so CUDA OOM, device, and infrastructure errors remain visible to callers. +- Preserved backend RuntimeError failures (including CUDA OOM/device errors) during solver sample-weight validation instead of rewriting them as ordinary invalid-input ValueError exceptions. +- Aligned the executable loss/penalty/solver matrix with the maintained compatibility contract: Elastic Net precision is tested through FISTA, while smooth solvers are tested to reject it explicitly. +- Smooth Newton/L-BFGS solvers now reject Elastic Net and other non-smooth penalties before preprocessing instead of silently omitting their non-smooth objective component. +- Normalized Newton, proximal-Newton, L-BFGS, L-BFGS-B, and ADMM warm starts onto the preprocessed design backend, device, and dtype; added physical Torch/CuPy regression entry points. +- Removed the incorrect Euclidean-prox Newton shortcut that duplicated smooth penalty terms and solved the wrong non-smooth objective. Smooth L2/no-penalty requests retain Newton updates; non-smooth requests now explicitly use FISTA, and FISTA-LLA requires a future metric-prox capability. +- Completed ADMM's legitimate Cholesky-to-iterative fallback and kept L-BFGS-B directions/bounds feasible and backend-native. +- Hardened adjacent Newton, proximal-Newton, ADMM, FISTA-BB, L-BFGS, and L-BFGS-B contracts: validate weights before curvature work, only downgrade true singular systems, preserve dtype/device for proximal Newton and CuPy bounds, and use the correct squared-gradient Armijo slope. +- Kept direct solver and penalized-CV sample-weight checks backend-native, validated weights before weighted Lipschitz operations, rejected overflowing weight totals, and made HC1 analytic-weight inference invariant to global weight rescaling. +- Fixed Issue #45 by routing statgpu-owned Torch compilation through a + centralized policy that avoids CUDA Graph lifecycle hazards for iterative + solvers; compile decisions are observable, and only the known lifecycle + failure falls back to eager execution. Performance comparison with + `reduce-overhead` remains explicitly deferred. +- Addressed Issue #81 with backend-native finite-value validation at public + estimator boundaries without full GPU-array transfers. +- Aligned formula sample weights after missing-row filtering across linear, + GLM, and penalized estimators; retained Torch/CuPy weights on device; and + corrected Gaussian GLM FISTA to use weighted centering and the intended + weighted squared-loss intercept. +- Unified analytic-weight GLM semantics across IRLS ridge scaling, line search, + pseudo-loglikelihood, AIC/BIC, dispersion, and sandwich inference; centralized + active GLM Torch compilation; narrowed singular-system fallbacks; and added + backend-native response-domain validation for every supported GLM family, + including penalized estimators and cross-validation entrypoints; scalar + GLMs now normalize single-column responses and reject empty, non-real, + multicolumn, or length-mismatched responses before solver/fold dispatch; + GLM design matrices and analytic weights now share backend-native real, + finite, shape, length, and non-empty validation across model, CV, formula, + and direct IRLS entrypoints. +- Addressed Issue #82 by preserving exact raw constructor arguments for + legacy scikit-learn clone identity while retaining normalized runtime + attributes and `set_params` bookkeeping. +- Addressed Issue #83 by making maintained `test_*.py` files visible to git, + documenting the manual GPU diagnostic boundary, and adding maintained + regression coverage. ## 0.2.3 — 2026-08-04 ### Added diff --git a/dev/README.md b/dev/README.md index 15763be86..bb1242aee 100644 --- a/dev/README.md +++ b/dev/README.md @@ -39,6 +39,15 @@ The canonical developer architecture entry point is defines the current Cox module ownership, canonical statistical source, CV/refit reuse contract, and inference boundaries. + +## Maintained tests versus manual diagnostics + +Every `dev/tests/test_*.py` file is maintained pytest coverage and must be +tracked, discoverable, side-effect free at import time, and runnable from a +clean checkout. Hardware-specific experiments and one-off reproducers +belong under `dev/manual/gpu_diagnostics/`; `.gitignore` is not a test +ownership mechanism. + ## Remote GPU Testing ### Server Access diff --git a/dev/benchmarks/benchmark_torch_compile_maintenance.py b/dev/benchmarks/benchmark_torch_compile_maintenance.py new file mode 100644 index 000000000..23346c5b5 --- /dev/null +++ b/dev/benchmarks/benchmark_torch_compile_maintenance.py @@ -0,0 +1,405 @@ +"""Physical-GPU benchmark for the maintenance torch.compile policy. + +Each compile mode runs in a fresh subprocess so module-level compiled-callable +caches cannot leak across modes. Cases within one mode intentionally share a +process, so later cases may reuse an already-created compiled callable. The +script writes one machine-readable JSON artifact and makes no +performance-equivalence claim before it is executed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +import numpy as np + + +_PRECISION_RTOL = 1e-6 +_PRECISION_ATOL = 1e-8 + + +def _validate_compile_evidence( + mode, case, events, graph_delta, *, cached_callable_observed=False +): + """Validate graph execution while accounting for compiled-callable reuse. + + ``compile_torch`` diagnostics describe callable construction decisions. A + later model can reuse the same module-level compiled callable, in which case + no new ``compiled`` event is emitted even though ``torch._dynamo.reset()`` + forces a fresh graph during that case. Therefore an empty event sequence plus + a positive case-local graph delta is valid cached-callable evidence. Nonempty + diagnostics without a ``compiled`` event remain an error so disabled or + unavailable compilation cannot be mistaken for success. + """ + if mode != "default": + return "not-applicable" + + if int(graph_delta) <= 0: + raise RuntimeError(f"{case}:{mode} did not create a Dynamo graph") + + statuses = tuple(str(event.get("status", "")) for event in events) + if any("fallback" in status for status in statuses): + raise RuntimeError(f"{case}:{mode} entered fallback") + + if any(status == "compiled" for status in statuses): + return "compiled-diagnostic-and-dynamo-graph" + + if events: + raise RuntimeError( + f"{case}:{mode} has diagnostics {statuses!r} but no compiled event" + ) + + if not cached_callable_observed: + raise RuntimeError(f"{case}:{mode} has no compiled diagnostic") + + return "cached-callable-and-dynamo-graph" + + +def _json_value(value): + if value is None or isinstance(value, (bool, int, float, str)): + return value + if hasattr(value, "item"): + try: + return value.item() + except Exception: + pass + return str(value) + + +def _run_child(mode: str, repeats: int) -> dict: + os.environ["STATGPU_TORCH_COMPILE_MODE"] = mode + + import torch + + if not torch.cuda.is_available(): + raise RuntimeError("A physical Torch CUDA GPU is required") + + from statgpu.backends import _to_numpy + from statgpu.backends._torch_compile import get_torch_compile_diagnostics + from statgpu.linear_model import ( + ElasticNet, + GeneralizedLinearModel, + Lasso, + PenalizedLinearRegression, + ) + + rng = np.random.default_rng(20260805) + X = rng.normal(size=(1024, 64)).astype(np.float64) + beta = np.zeros(64, dtype=np.float64) + beta[:8] = np.array([1.3, -1.0, 0.8, -0.6, 0.5, -0.4, 0.3, -0.2]) + y = X @ beta + 0.05 * rng.normal(size=X.shape[0]) + + groups = [list(range(start, start + 8)) for start in range(0, 64, 8)] + cases = { + "glm_irls": lambda: GeneralizedLinearModel( + family="gaussian", + solver="irls", + C=0.0, + max_iter=50, + tol=1e-7, + device="torch", + compute_inference=False, + ), + "lasso": lambda: Lasso( + alpha=0.01, max_iter=100, tol=1e-6, device="torch" + ), + "elasticnet": lambda: ElasticNet( + alpha=0.01, l1_ratio=0.6, max_iter=100, tol=1e-6, device="torch" + ), + "scad": lambda: PenalizedLinearRegression( + penalty="scad", + alpha=0.03, + max_iter=60, + max_lla_iters=3, + tol=1e-6, + lla_tol=1e-6, + device="torch", + compute_inference=False, + ), + "mcp": lambda: PenalizedLinearRegression( + penalty="mcp", + alpha=0.03, + max_iter=60, + max_lla_iters=3, + tol=1e-6, + lla_tol=1e-6, + device="torch", + compute_inference=False, + ), + "group_scad": lambda: PenalizedLinearRegression( + penalty="group_scad", + penalty_kwargs={"groups": groups}, + alpha=0.03, + max_iter=60, + max_lla_iters=3, + tol=1e-6, + lla_tol=1e-6, + device="torch", + compute_inference=False, + ), + "group_mcp": lambda: PenalizedLinearRegression( + penalty="group_mcp", + penalty_kwargs={"groups": groups}, + alpha=0.03, + max_iter=60, + max_lla_iters=3, + tol=1e-6, + lla_tol=1e-6, + device="torch", + compute_inference=False, + ), + } + + case_results = {} + compiled_callable_observed = False + for name, factory in cases.items(): + get_torch_compile_diagnostics(clear=True) + torch._dynamo.reset() + before_graphs = int( + torch._dynamo.utils.counters["stats"].get("unique_graphs", 0) + ) + timings = [] + prediction = None + model = None + for _ in range(repeats): + torch.cuda.synchronize() + start = time.perf_counter() + model = factory().fit(X, y) + torch.cuda.synchronize() + timings.append(time.perf_counter() - start) + prediction = np.asarray(_to_numpy(model.predict(X))) + events = get_torch_compile_diagnostics(clear=True) + after_graphs = int( + torch._dynamo.utils.counters["stats"].get("unique_graphs", 0) + ) + graph_delta = after_graphs - before_graphs + compile_evidence = _validate_compile_evidence( + mode, + name, + events, + graph_delta, + cached_callable_observed=compiled_callable_observed, + ) + if any(event.get("status") == "compiled" for event in events): + compiled_callable_observed = True + coefficients = np.asarray(_to_numpy(model.coef_)) + finite_prediction = bool(np.isfinite(prediction).all()) + finite_coefficients = bool(np.isfinite(coefficients).all()) + fallback_seen = any( + "fallback" in str(event.get("status", "")) for event in events + ) + if not finite_prediction or not finite_coefficients: + raise RuntimeError(f"{name}:{mode} produced non-finite output") + + case_results[name] = { + "fit_seconds": timings, + "finite_prediction": finite_prediction, + "finite_coefficients": finite_coefficients, + "prediction": prediction.tolist(), + "coefficients": coefficients.tolist(), + "n_iter": _json_value(getattr(model, "n_iter_", None)), + "converged": _json_value(getattr(model, "converged_", None)), + "compile_events": events, + "compile_evidence": compile_evidence, + "compiled_event_count": sum( + event["status"] == "compiled" for event in events + ), + "unique_graphs_before": before_graphs, + "unique_graphs_after": after_graphs, + "unique_graphs_delta": graph_delta, + "fallback_seen": fallback_seen, + } + + return { + "mode": mode, + "cases": case_results, + "environment": { + "python": platform.python_version(), + "torch": torch.__version__, + "cuda": torch.version.cuda, + "gpu": torch.cuda.get_device_name(0), + "compute_capability": list(torch.cuda.get_device_capability(0)), + }, + } + + +def _child_main(args) -> None: + result = _run_child(args.mode, args.repeats) + Path(args.child_output).write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _parent_main(args) -> None: + mode_results = {} + with tempfile.TemporaryDirectory() as directory: + for mode in ("disable", "default"): + child_output = Path(directory) / f"{mode}.json" + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--child", + "--mode", + mode, + "--repeats", + str(args.repeats), + "--child-output", + str(child_output), + ] + subprocess.run(command, check=True) + mode_results[mode] = json.loads(child_output.read_text(encoding="utf-8")) + + precision = {} + for case in mode_results["default"]["cases"]: + eager = mode_results["disable"]["cases"][case] + compiled = mode_results["default"]["cases"][case] + eager_prediction = np.asarray(eager["prediction"], dtype=float) + compiled_prediction = np.asarray(compiled["prediction"], dtype=float) + eager_coef = np.asarray(eager["coefficients"], dtype=float) + compiled_coef = np.asarray(compiled["coefficients"], dtype=float) + + np.testing.assert_allclose( + compiled_prediction, + eager_prediction, + rtol=_PRECISION_RTOL, + atol=_PRECISION_ATOL, + err_msg=f"{case}: compiled predictions differ from eager reference", + ) + np.testing.assert_allclose( + compiled_coef, + eager_coef, + rtol=_PRECISION_RTOL, + atol=_PRECISION_ATOL, + err_msg=f"{case}: compiled coefficients differ from eager reference", + ) + + precision[case] = { + "prediction_max_abs_diff": float( + np.max(np.abs(compiled_prediction - eager_prediction)) + ), + "coefficient_max_abs_diff": float( + np.max(np.abs(compiled_coef - eager_coef)) + ), + "rtol": _PRECISION_RTOL, + "atol": _PRECISION_ATOL, + "status": "pass", + } + + public_mode_results = json.loads(json.dumps(mode_results)) + for result in public_mode_results.values(): + for details in result["cases"].values(): + details.pop("prediction", None) + details.pop("coefficients", None) + + output = { + "method": "torch_compile_maintenance", + "backend_times": { + "numpy": None, + "cupy": None, + "torch": { + mode: { + case: details["fit_seconds"] + for case, details in result["cases"].items() + } + for mode, result in mode_results.items() + }, + }, + "external_baseline": {"name": None, "time": None, "version": None}, + "precision_vs_external": { + "reference": "STATGPU_TORCH_COMPILE_MODE=disable", + "default_vs_disable": precision, + }, + "convergence_status": { + mode: { + case: { + "n_iter": details["n_iter"], + "converged": details["converged"], + } + for case, details in result["cases"].items() + } + for mode, result in mode_results.items() + }, + "backend_precision": { + mode: { + case: { + "finite_prediction": details["finite_prediction"], + "finite_coefficients": details["finite_coefficients"], + } + for case, details in result["cases"].items() + } + for mode, result in mode_results.items() + }, + "compatibility_matrix": public_mode_results, + "cv_matrix": {}, + "inference_matrix": { + "status": "not applicable: benchmark uses estimation-only fits" + }, + "threshold_source": { + "source": "Issue #45 maintenance workload matrix", + "repeats": args.repeats, + "precision_rtol": _PRECISION_RTOL, + "precision_atol": _PRECISION_ATOL, + }, + "objective_scaling": "unchanged across compile modes", + "penalty_scale_mapping": None, + "cpu_vs_external": None, + "gpu_vs_cpu": None, + "crossover_n": None, + "target_scale_source": "maintenance benchmark n=1024, p=64", + "optimization_notes": [ + "Compile modes run in fresh subprocesses to isolate callable caches across modes.", + "Cases within a mode share a process and may reuse module-level compiled callables.", + "A positive case-local Dynamo graph delta proves execution; diagnostics prove callable construction when newly emitted.", + "Correctness and visible fallback are release gates; timing parity is not assumed.", + ], + "validation_tier": "remote-full", + "schema_status": "ok", + "timing_scope": { + "fit": "solver execution including first-use compilation; data generation excluded" + }, + "reproducibility": { + "seed": 20260805, + "modes": ["disable", "default"], + "environment": mode_results["default"]["environment"], + }, + "uncovered_reasons": [ + "NumPy/CuPy and external timing baselines are outside this compile-policy benchmark." + ], + } + + path = Path(args.output) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(output, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(path) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--output", default="results/torch_compile_maintenance.json") + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--child", action="store_true") + parser.add_argument("--mode", choices=["disable", "default"]) + parser.add_argument("--child-output") + args = parser.parse_args() + + if args.repeats < 1: + parser.error("--repeats must be positive") + if args.child: + if args.mode is None or args.child_output is None: + parser.error("--child requires --mode and --child-output") + _child_main(args) + else: + _parent_main(args) + + +if __name__ == "__main__": + main() diff --git a/dev/benchmarks/benchmark_torch_compile_scale.py b/dev/benchmarks/benchmark_torch_compile_scale.py new file mode 100644 index 000000000..994d8ba4e --- /dev/null +++ b/dev/benchmarks/benchmark_torch_compile_scale.py @@ -0,0 +1,665 @@ +"""Scale-crossover benchmark for statgpu's explicit ``torch.compile`` path. + +The maintenance compile benchmark validates correctness at one small workload. +This benchmark varies ``n`` and ``p`` separately, isolates every case/mode/scale +combination in a fresh subprocess, and reports cold-start cost, post-compilation +latency, dispersion, and the estimated number of repeated fits required to +amortize compilation. + +A physical CUDA GPU is required. Results are written to ``results/*.json`` and +an adjacent Markdown summary. No universal speedup claim is made. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import platform +import statistics +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Iterable, Optional, Sequence + +import numpy as np + + +_PRECISION_RTOL = 1e-6 +_PRECISION_ATOL = 1e-8 +_BASELINE_SCALE = (1024, 64) +_CASE_NAMES = ( + "glm_irls", + "lasso", + "elasticnet", + "scad", + "mcp", + "group_scad", + "group_mcp", +) +_PRESETS = { + "quick": { + "scales": ((1024, 64),), + "repeats": 3, + }, + "standard": { + "scales": ( + (1024, 64), + (4096, 64), + (16384, 64), + (4096, 256), + (4096, 1024), + (16384, 1024), + ), + "repeats": 7, + }, + "extended": { + "scales": ( + (1024, 64), + (4096, 64), + (16384, 64), + (65536, 64), + (4096, 256), + (4096, 1024), + (16384, 256), + (16384, 1024), + (8192, 2048), + ), + "repeats": 11, + }, +} + + +def _parse_scales(value: str) -> tuple[tuple[int, int], ...]: + """Parse a comma-separated ``n x p`` scale list with stable deduplication.""" + if not value or not value.strip(): + raise argparse.ArgumentTypeError("--scales must not be empty") + + scales = [] + seen = set() + for raw_token in value.split(","): + token = raw_token.strip().lower().replace("×", "x") + parts = token.split("x") + if len(parts) != 2: + raise argparse.ArgumentTypeError( + f"invalid scale {raw_token!r}; expected NXP such as 1024x64" + ) + try: + n_samples, n_features = (int(part.strip()) for part in parts) + except ValueError as exc: + raise argparse.ArgumentTypeError( + f"invalid scale {raw_token!r}; N and P must be integers" + ) from exc + if n_samples < 1 or n_features < 1: + raise argparse.ArgumentTypeError( + f"invalid scale {raw_token!r}; N and P must be positive" + ) + scale = (n_samples, n_features) + if scale not in seen: + seen.add(scale) + scales.append(scale) + return tuple(scales) + + +def _parse_cases(value: str) -> tuple[str, ...]: + """Parse and validate a comma-separated benchmark case list.""" + if not value or not value.strip(): + raise argparse.ArgumentTypeError("--cases must not be empty") + cases = [] + seen = set() + for raw_case in value.split(","): + case = raw_case.strip().lower() + if case not in _CASE_NAMES: + allowed = ", ".join(_CASE_NAMES) + raise argparse.ArgumentTypeError( + f"unknown case {raw_case!r}; expected one of {allowed}" + ) + if case not in seen: + seen.add(case) + cases.append(case) + return tuple(cases) + + +def _resolve_plan( + preset: str, + scales: Optional[Sequence[tuple[int, int]]], + cases: Optional[Sequence[str]], + repeats: Optional[int], +) -> tuple[tuple[tuple[int, int], ...], tuple[str, ...], int]: + """Resolve CLI overrides against a named benchmark preset.""" + preset_config = _PRESETS[preset] + resolved_scales = tuple(scales or preset_config["scales"]) + resolved_cases = tuple(cases or _CASE_NAMES) + resolved_repeats = int( + preset_config["repeats"] if repeats is None else repeats + ) + if resolved_repeats < 3: + raise ValueError("scale benchmark requires at least 3 repeats") + return resolved_scales, resolved_cases, resolved_repeats + + +def _timing_stats(values: Iterable[float]) -> dict: + """Return robust timing summaries, including median and IQR.""" + samples = np.asarray(tuple(float(value) for value in values), dtype=float) + if samples.size == 0: + raise ValueError("timing sample must not be empty") + if not np.isfinite(samples).all() or np.any(samples <= 0): + raise ValueError("timing samples must be finite and positive") + q1, q3 = np.percentile(samples, [25.0, 75.0]) + return { + "count": int(samples.size), + "median": float(statistics.median(samples.tolist())), + "min": float(np.min(samples)), + "max": float(np.max(samples)), + "q1": float(q1), + "q3": float(q3), + "iqr": float(q3 - q1), + } + + +def _summarize_timings( + eager_values: Sequence[float], compiled_values: Sequence[float] +) -> dict: + """Summarize eager, cold compiled, warm compiled, and amortization metrics.""" + if len(compiled_values) < 2: + raise ValueError("compiled timings require one cold and at least one warm run") + + eager = _timing_stats(eager_values) + compiled_cold = float(compiled_values[0]) + compiled_warm = _timing_stats(compiled_values[1:]) + eager_median = eager["median"] + warm_median = compiled_warm["median"] + warm_speedup = eager_median / warm_median + cold_overhead_ratio = compiled_cold / eager_median + + additional_fits = None + total_fits = None + if warm_median < eager_median: + if compiled_cold <= eager_median: + additional_fits = 0 + else: + additional_fits = int( + math.ceil( + (compiled_cold - eager_median) + / (eager_median - warm_median) + ) + ) + total_fits = 1 + additional_fits + + return { + "eager": eager, + "compiled_cold": compiled_cold, + "compiled_warm": compiled_warm, + "warm_speedup": float(warm_speedup), + "cold_overhead_ratio": float(cold_overhead_ratio), + "first_fit_extra_seconds_vs_eager": float(compiled_cold - eager_median), + "break_even_additional_warm_fits": additional_fits, + "break_even_total_fits": total_fits, + } + + +def _scale_axis(n_samples: int, n_features: int) -> str: + if (n_samples, n_features) == _BASELINE_SCALE: + return "baseline" + if n_features == _BASELINE_SCALE[1]: + return "n-scaling" + if n_samples == 4096 and n_features > _BASELINE_SCALE[1]: + return "p-scaling" + return "joint-scaling" + + +def _make_data(n_samples: int, n_features: int) -> tuple[np.ndarray, np.ndarray]: + seed = 20260806 + 1009 * n_samples + 9176 * n_features + rng = np.random.default_rng(seed % (2**32)) + X = rng.normal(size=(n_samples, n_features)).astype(np.float64) + beta = np.zeros(n_features, dtype=np.float64) + signal = np.array([1.3, -1.0, 0.8, -0.6, 0.5, -0.4, 0.3, -0.2]) + beta[: min(n_features, signal.size)] = signal[: min(n_features, signal.size)] + y = X @ beta + 0.05 * rng.normal(size=n_samples) + return X, y + + +def _make_model(case: str, groups: list[list[int]]): + from statgpu.linear_model import ( + ElasticNet, + GeneralizedLinearModel, + Lasso, + PenalizedLinearRegression, + ) + + if case == "glm_irls": + return GeneralizedLinearModel( + family="gaussian", + solver="irls", + C=0.0, + max_iter=50, + tol=1e-7, + device="torch", + compute_inference=False, + ) + if case == "lasso": + return Lasso(alpha=0.01, max_iter=100, tol=1e-6, device="torch") + if case == "elasticnet": + return ElasticNet( + alpha=0.01, + l1_ratio=0.6, + max_iter=100, + tol=1e-6, + device="torch", + ) + + penalty_kwargs = {"groups": groups} if case.startswith("group_") else {} + return PenalizedLinearRegression( + penalty=case, + penalty_kwargs=penalty_kwargs, + alpha=0.03, + max_iter=60, + max_lla_iters=3, + tol=1e-6, + lla_tol=1e-6, + device="torch", + compute_inference=False, + ) + + +def _validate_mode_evidence(mode: str, events: Sequence[dict], graph_delta: int) -> str: + statuses = tuple(str(event.get("status", "")) for event in events) + if any("fallback" in status for status in statuses): + raise RuntimeError(f"compile mode {mode!r} entered fallback: {statuses!r}") + + if mode == "disable": + if int(graph_delta) != 0: + raise RuntimeError( + f"disable mode unexpectedly created {graph_delta} Dynamo graph(s)" + ) + if events and any(status != "disabled" for status in statuses): + raise RuntimeError( + f"disable mode emitted unexpected diagnostics {statuses!r}" + ) + return "eager-no-dynamo-graph" + + if int(graph_delta) <= 0: + raise RuntimeError("default mode did not create a Dynamo graph") + if not any(status == "compiled" for status in statuses): + raise RuntimeError( + f"default mode has diagnostics {statuses!r} but no compiled event" + ) + return "compiled-diagnostic-and-dynamo-graph" + + +def _run_child( + *, mode: str, case: str, n_samples: int, n_features: int, repeats: int +) -> dict: + os.environ["STATGPU_TORCH_COMPILE_MODE"] = mode + + import torch + + if not torch.cuda.is_available(): + raise RuntimeError("A physical Torch CUDA GPU is required") + + from statgpu.backends import _to_numpy + from statgpu.backends._torch_compile import get_torch_compile_diagnostics + + X, y = _make_data(n_samples, n_features) + groups = [ + list(range(start, min(start + 8, n_features))) + for start in range(0, n_features, 8) + ] + + get_torch_compile_diagnostics(clear=True) + torch._dynamo.reset() + before_graphs = int( + torch._dynamo.utils.counters["stats"].get("unique_graphs", 0) + ) + + timings = [] + model = None + for _ in range(repeats): + torch.cuda.synchronize() + start = time.perf_counter() + model = _make_model(case, groups).fit(X, y) + torch.cuda.synchronize() + timings.append(time.perf_counter() - start) + + assert model is not None + prediction = np.asarray(_to_numpy(model.predict(X))) + coefficients = np.asarray(_to_numpy(model.coef_)) + events = get_torch_compile_diagnostics(clear=True) + after_graphs = int( + torch._dynamo.utils.counters["stats"].get("unique_graphs", 0) + ) + graph_delta = after_graphs - before_graphs + evidence = _validate_mode_evidence(mode, events, graph_delta) + + if not np.isfinite(prediction).all() or not np.isfinite(coefficients).all(): + raise RuntimeError( + f"{case}:{mode}:n={n_samples}:p={n_features} produced non-finite output" + ) + + return { + "mode": mode, + "case": case, + "n_samples": n_samples, + "n_features": n_features, + "fit_seconds": timings, + "prediction": prediction.tolist(), + "coefficients": coefficients.tolist(), + "finite_prediction": True, + "finite_coefficients": True, + "n_iter": getattr(model, "n_iter_", None), + "converged": getattr(model, "converged_", None), + "compile_events": events, + "compile_evidence": evidence, + "unique_graphs_delta": graph_delta, + "environment": { + "python": platform.python_version(), + "torch": torch.__version__, + "cuda": torch.version.cuda, + "gpu": torch.cuda.get_device_name(0), + "compute_capability": list(torch.cuda.get_device_capability(0)), + }, + } + + +def _child_main(args: argparse.Namespace) -> None: + result = _run_child( + mode=args.mode, + case=args.case, + n_samples=args.n_samples, + n_features=args.n_features, + repeats=args.repeats, + ) + Path(args.child_output).write_text( + json.dumps(result, indent=2, sort_keys=True, default=str) + "\n", + encoding="utf-8", + ) + + +def _run_subprocess_case( + *, + mode: str, + case: str, + n_samples: int, + n_features: int, + repeats: int, + output_path: Path, +) -> dict: + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--child", + "--mode", + mode, + "--case", + case, + "--n-samples", + str(n_samples), + "--n-features", + str(n_features), + "--repeats", + str(repeats), + "--child-output", + str(output_path), + ] + subprocess.run(command, check=True) + return json.loads(output_path.read_text(encoding="utf-8")) + + +def _format_optional_integer(value) -> str: + return "never" if value is None else str(value) + + +def _render_markdown(report: dict, json_path: Path) -> str: + environment = report["environment"] + lines = [ + "# Torch Compile Scale-Crossover Benchmark", + "", + f"- JSON artifact: `{json_path.as_posix()}`", + f"- Preset: `{report['preset']}`", + f"- Repeats per mode/case/scale: `{report['repeats']}`", + f"- GPU: `{environment['gpu']}`", + f"- Torch: `{environment['torch']}`", + f"- CUDA: `{environment['cuda']}`", + "", + "The first compiled repetition is reported as cold-start. Remaining compiled repetitions form the warm sample. Every case/mode/scale combination runs in a fresh subprocess.", + "", + "| axis | n | p | case | eager median (s) | compiled cold (s) | compiled warm median (s) | warm speedup | cold/eager | break-even total fits |", + "|---|---:|---:|---|---:|---:|---:|---:|---:|---:|", + ] + for record in report["results"]: + summary = record["timing_summary"] + lines.append( + "| {axis} | {n} | {p} | `{case}` | {eager:.6f} | {cold:.6f} | " + "{warm:.6f} | {speedup:.3f}x | {cold_ratio:.3f}x | {break_even} |".format( + axis=record["axis"], + n=record["n_samples"], + p=record["n_features"], + case=record["case"], + eager=summary["eager"]["median"], + cold=summary["compiled_cold"], + warm=summary["compiled_warm"]["median"], + speedup=summary["warm_speedup"], + cold_ratio=summary["cold_overhead_ratio"], + break_even=_format_optional_integer( + summary["break_even_total_fits"] + ), + ) + ) + lines.extend( + [ + "", + "`never` means the measured compiled warm median was not faster than eager, so the observed cold cost cannot be amortized by repeating the same fit.", + "", + ] + ) + return "\n".join(lines) + + +def _parent_main(args: argparse.Namespace) -> None: + scales, cases, repeats = _resolve_plan( + args.preset, args.scales, args.cases, args.repeats + ) + + raw_results = {} + with tempfile.TemporaryDirectory() as directory: + temp_dir = Path(directory) + for n_samples, n_features in scales: + for case in cases: + key = (n_samples, n_features, case) + raw_results[key] = {} + for mode in ("disable", "default"): + child_path = temp_dir / ( + f"n{n_samples}_p{n_features}_{case}_{mode}.json" + ) + raw_results[key][mode] = _run_subprocess_case( + mode=mode, + case=case, + n_samples=n_samples, + n_features=n_features, + repeats=repeats, + output_path=child_path, + ) + + results = [] + environment = None + for (n_samples, n_features, case), modes in raw_results.items(): + eager = modes["disable"] + compiled = modes["default"] + environment = environment or compiled["environment"] + + eager_prediction = np.asarray(eager.pop("prediction"), dtype=float) + compiled_prediction = np.asarray(compiled.pop("prediction"), dtype=float) + eager_coef = np.asarray(eager.pop("coefficients"), dtype=float) + compiled_coef = np.asarray(compiled.pop("coefficients"), dtype=float) + np.testing.assert_allclose( + compiled_prediction, + eager_prediction, + rtol=_PRECISION_RTOL, + atol=_PRECISION_ATOL, + err_msg=( + f"{case}:n={n_samples}:p={n_features} compiled predictions differ" + ), + ) + np.testing.assert_allclose( + compiled_coef, + eager_coef, + rtol=_PRECISION_RTOL, + atol=_PRECISION_ATOL, + err_msg=( + f"{case}:n={n_samples}:p={n_features} compiled coefficients differ" + ), + ) + + results.append( + { + "axis": _scale_axis(n_samples, n_features), + "n_samples": n_samples, + "n_features": n_features, + "matrix_bytes_float64": n_samples * n_features * 8, + "case": case, + "timing_summary": _summarize_timings( + eager["fit_seconds"], compiled["fit_seconds"] + ), + "raw_fit_seconds": { + "disable": eager["fit_seconds"], + "default": compiled["fit_seconds"], + }, + "precision": { + "prediction_max_abs_diff": float( + np.max(np.abs(compiled_prediction - eager_prediction)) + ), + "coefficient_max_abs_diff": float( + np.max(np.abs(compiled_coef - eager_coef)) + ), + "rtol": _PRECISION_RTOL, + "atol": _PRECISION_ATOL, + "status": "pass", + }, + "compile": { + "disable": { + "evidence": eager["compile_evidence"], + "unique_graphs_delta": eager["unique_graphs_delta"], + "events": eager["compile_events"], + }, + "default": { + "evidence": compiled["compile_evidence"], + "unique_graphs_delta": compiled["unique_graphs_delta"], + "events": compiled["compile_events"], + }, + }, + "convergence": { + "disable": { + "n_iter": eager["n_iter"], + "converged": eager["converged"], + }, + "default": { + "n_iter": compiled["n_iter"], + "converged": compiled["converged"], + }, + }, + } + ) + + report = { + "method": "torch_compile_scale_crossover", + "benchmark_version": 1, + "preset": args.preset, + "scales": [ + {"n_samples": n_samples, "n_features": n_features} + for n_samples, n_features in scales + ], + "cases": list(cases), + "repeats": repeats, + "environment": environment, + "timing_scope": { + "fit": "model construction plus fit; data generation and prediction excluded", + "compiled_cold": "first default-mode fit, including first-use compilation", + "compiled_warm": "default-mode fits after the first repetition", + }, + "statistics": { + "location": "median", + "dispersion": "min, max, and linear-interpolation IQR", + "warm_speedup": "eager median / compiled warm median", + "break_even": ( + "minimum total identical-shape fits for cold + warm compiled time " + "to be no greater than repeated eager median time" + ), + }, + "isolation": ( + "Each case/mode/scale combination runs in a fresh subprocess; " + "module-level compiled-callable caches cannot leak between records." + ), + "results": results, + "interpretation": ( + "This benchmark estimates workload-specific crossover behavior and " + "does not establish a universal torch.compile speedup." + ), + "schema_status": "ok", + } + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(report, indent=2, sort_keys=True, default=str) + "\n", + encoding="utf-8", + ) + + summary_path = Path(args.summary_output) if args.summary_output else output_path.with_suffix(".md") + summary_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.write_text( + _render_markdown(report, output_path) + "\n", encoding="utf-8" + ) + print(output_path) + print(summary_path) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument("--output", default="results/torch_compile_scale.json") + parser.add_argument("--summary-output") + parser.add_argument("--preset", choices=tuple(_PRESETS), default="standard") + parser.add_argument("--scales", type=_parse_scales) + parser.add_argument("--cases", type=_parse_cases) + parser.add_argument("--repeats", type=int) + + parser.add_argument("--child", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--mode", choices=("disable", "default"), help=argparse.SUPPRESS) + parser.add_argument("--case", choices=_CASE_NAMES, help=argparse.SUPPRESS) + parser.add_argument("--n-samples", type=int, help=argparse.SUPPRESS) + parser.add_argument("--n-features", type=int, help=argparse.SUPPRESS) + parser.add_argument("--child-output", help=argparse.SUPPRESS) + return parser + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + if args.child: + required = { + "mode": args.mode, + "case": args.case, + "n_samples": args.n_samples, + "n_features": args.n_features, + "repeats": args.repeats, + "child_output": args.child_output, + } + missing = [name for name, value in required.items() if value is None] + if missing: + parser.error("--child requires " + ", ".join(missing)) + if args.n_samples < 1 or args.n_features < 1 or args.repeats < 3: + parser.error("child dimensions must be positive and repeats >= 3") + _child_main(args) + return + + try: + _resolve_plan(args.preset, args.scales, args.cases, args.repeats) + except ValueError as exc: + parser.error(str(exc)) + _parent_main(args) + + +if __name__ == "__main__": + main() diff --git a/dev/manual/gpu_diagnostics/README.md b/dev/manual/gpu_diagnostics/README.md new file mode 100644 index 000000000..4a8d375c9 --- /dev/null +++ b/dev/manual/gpu_diagnostics/README.md @@ -0,0 +1,65 @@ +# Manual GPU diagnostics + +This directory is for intentionally ad-hoc GPU reproducers and +hardware-specific exploratory scripts. Files here are not collected by the +maintained pytest gate. + +Maintained regression coverage belongs under `dev/tests/test_*.py` and must: + +- expose discoverable pytest functions or classes; +- avoid substantial work at module import time; +- use explicit CuPy/Torch/CUDA availability checks and deterministic skip + reasons; +- run from a clean checkout without ignored local fixtures; +- assert the current public inference and device contracts. + +## Issue #83 legacy-script triage + +The files reported in Issue #83 were ignored or unversioned local diagnostics, +so the original source is not available from a clean Git checkout. They must not +be recreated under `dev/tests/` as import-time scripts. The disposition of each +reported filename is recorded below. + +| Historical filename | Classification | Disposition / maintained replacement | +|---|---|---| +| `test_coxph_3backends.py` | Unversioned diagnostic with an unclassified historical CuPy runtime failure | Retired as a test asset. Current Cox backend semantics are covered by `test_cox_phase1_completion.py`, `test_survival_risk_sets.py`, and the maintained physical-GPU suite. Because the original reproducer is unavailable, no claim is made that its exact failure was pre-existing or fixed; a recovered reproducer must be run on the Issue #83 base and current head before classification. | +| `test_irls_gpu.py` | Test-harness defect: undeclared `loss_name` fixture | Not promoted. Maintained IRLS/loss backend tests provide explicit parametrization and skip conditions. | +| `test_lasso_cv_torch_quick.py` | Import-time script; zero pytest tests | Retired. Lasso/CV contracts remain in maintained Lasso, CV, backend, and physical-GPU tests. | +| `test_ridge_cv_torch_backend.py` | Import-time script; zero pytest tests | Retired. RidgeCV and cross-backend behavior remain in maintained Ridge/CV regression tests. | +| `test_torch_comprehensive.py` | Broad import-time diagnostic; zero pytest tests | Replaced by focused, discoverable backend and model regression tests. New hardware-specific exploration belongs in this directory. | +| `test_lassocv_inference_simple.py` | Obsolete expectation against an older inference API | Retired. Current strict inference and unsupported-combination behavior is asserted by maintained Lasso inference tests. | + +The Issue #45 Torch-CUDA lifecycle regression now has a discoverable physical +GPU test in `dev/tests/test_maintenance_024_025.py`. It skips deterministically +unless PyTorch is at least 2.1, CUDA is available, and the device has compute +capability 7 or newer. + +## Exact-head hardware evidence + +Before recording a physical-GPU result, update the branch with a fast-forward +pull and capture `git rev-parse HEAD`. The recorded evidence must identify the +exact commit, GPU model and compute capability, backend/library versions, test +command, exit status, and pass/skip counts. A result from a superseded commit is +historical context, not acceptance evidence for the current head. + +Do not record hostnames, ports, credentials, account names, or other private +infrastructure identifiers in pull requests or committed diagnostics. Environment +metadata should be limited to the hardware and software properties needed to +reproduce or interpret the result. + +## Adding a new diagnostic + +A manual script should state its environment, expected command, and whether it +is exploratory or a minimized reproducer. Once a behavior becomes a supported +contract or regression, move the smallest deterministic assertion into +`dev/tests/` and leave the manual script only when it remains useful for +hardware investigation. + +## Torch compile performance note + +The maintenance release prioritizes correctness by defaulting iterative kernels +to Torch `default` compile mode. No claim is made that this matches the +steady-state latency of `reduce-overhead`; representative Lasso, ElasticNet, +nonconvex, adaptive, and group-penalty benchmarks remain an optimization task. +Users may opt into `reduce-overhead` explicitly, and construction/runtime +fallback decisions remain available through `get_torch_compile_diagnostics()`. \ No newline at end of file diff --git a/dev/tests/test_core_contracts.py b/dev/tests/test_core_contracts.py index 7bfb7cd52..dd27b5320 100644 --- a/dev/tests/test_core_contracts.py +++ b/dev/tests/test_core_contracts.py @@ -1,5 +1,6 @@ """Regression tests for core contracts found during iterative review.""" +import subprocess import sys import types @@ -57,12 +58,29 @@ def test_set_params_rejects_unknown_and_supports_nested_estimators(): child = DummyEstimator(value=1) parent = DummyEstimator(child=child) assert parent.set_params(child__value=7) is parent - assert child.value == 7 + assert child.value == 1 + assert parent.child is not child assert parent.get_params(deep=True)["child__value"] == 7 with pytest.raises(ValueError, match="Invalid parameter"): parent.set_params(unknown=3) parent.set_params(device="auto") - assert parent.device is Device.AUTO + assert parent.device == "auto" + assert parent._device is Device.AUTO + + +def test_sklearn_clone_recursively_clears_nested_fitted_state(): + from sklearn.base import clone + + child = DummyEstimator(value=3).fit(np.ones((2, 1))) + parent = DummyEstimator(value=5, child=child) + + cloned = clone(parent) + + assert cloned is not parent + assert cloned.child is not child + assert cloned.child.value == 3 + assert cloned.child._fitted is False + assert child._fitted is True def test_torch_rng_none_uses_entropy(monkeypatch): @@ -144,3 +162,25 @@ def test_umap_fuzzy_graph_uses_reverse_edge_memberships(monkeypatch): [[0.0, 0.68, 0.58], [0.68, 0.0, 0.90], [0.58, 0.90, 0.0]] ) np.testing.assert_allclose(graph, expected, rtol=0.0, atol=1e-12) + + +def test_glm_core_import_order_is_clean_in_fresh_interpreter(): + """Internal GLM imports must not depend on importing linear_model first.""" + code = """ +from statgpu.glm_core._family import Gaussian +from statgpu.glm_core._irls import IRLSSolver +from statgpu.glm_core._logistic import LogisticLoss +from statgpu.linear_model import LogisticRegression + +assert Gaussian().name == 'gaussian' +assert IRLSSolver is not None +assert LogisticLoss().name == 'logistic' +assert LogisticRegression is not None +""" + completed = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr diff --git a/dev/tests/test_legacy_sklearn_integration.py b/dev/tests/test_legacy_sklearn_integration.py new file mode 100644 index 000000000..66530309d --- /dev/null +++ b/dev/tests/test_legacy_sklearn_integration.py @@ -0,0 +1,73 @@ +"""scikit-learn <=1.2 integration contracts for public statgpu estimators.""" + +from __future__ import annotations + +import numpy as np + + +def _regression_sample(seed: int = 20260804): + rng = np.random.default_rng(seed) + X = rng.normal(size=(48, 4)) + beta = np.array([1.5, -0.75, 0.4, 0.0]) + y = X @ beta + 0.05 * rng.normal(size=X.shape[0]) + return X, y + + +def test_clone_of_fitted_ridge_is_unfitted_and_preserves_parameters(): + from sklearn.base import clone + from statgpu.linear_model import Ridge + + X, y = _regression_sample() + fitted = Ridge( + alpha=0.2, + fit_intercept=True, + compute_inference=False, + device="cpu", + ).fit(X, y) + + cloned = clone(fitted) + + assert cloned is not fitted + assert type(cloned) is type(fitted) + assert cloned._fitted is False + assert cloned.get_params(deep=False)["alpha"] == 0.2 + assert cloned.get_params(deep=False)["device"] == "cpu" + + +def test_pipeline_nested_set_params_and_grid_search_work_on_legacy_sklearn(): + from sklearn.model_selection import GridSearchCV + from sklearn.pipeline import Pipeline + from statgpu.linear_model import Ridge + + X, y = _regression_sample(seed=20260805) + pipeline = Pipeline( + [ + ( + "ridge", + Ridge( + alpha=1.0, + compute_inference=False, + device="cpu", + ), + ) + ] + ) + + pipeline.set_params(ridge__alpha=0.25) + assert pipeline.get_params(deep=True)["ridge__alpha"] == 0.25 + assert pipeline.named_steps["ridge"].get_params(deep=False)["alpha"] == 0.25 + + search = GridSearchCV( + pipeline, + param_grid={"ridge__alpha": [0.01, 0.1, 1.0]}, + scoring="neg_mean_squared_error", + cv=3, + refit=True, + error_score="raise", + ) + search.fit(X, y) + + prediction = np.asarray(search.predict(X)) + assert prediction.shape == y.shape + assert np.isfinite(prediction).all() + assert search.best_params_["ridge__alpha"] in {0.01, 0.1, 1.0} diff --git a/dev/tests/test_loss_penalty_solver_matrix.py b/dev/tests/test_loss_penalty_solver_matrix.py index ed9dbdf28..fa6d7cfcb 100644 --- a/dev/tests/test_loss_penalty_solver_matrix.py +++ b/dev/tests/test_loss_penalty_solver_matrix.py @@ -54,9 +54,18 @@ def _make_penalties(p): "group_scad": GroupSCADPenalty(alpha=0.01, groups=groups), } -# Penalties that are non-smooth (L-BFGS/Newton can't handle) -# ElasticNet has a smooth L2 component, so L-BFGS/Newton handle it via the smooth part -NON_SMOOTH_PENALTIES = {"l1", "scad", "mcp", "adaptive_l1", "group_lasso", "group_mcp", "group_scad"} +# Penalties with a non-smooth component. Smooth solvers must not silently +# optimize only the L2 part of Elastic Net. +NON_SMOOTH_PENALTIES = { + "l1", + "elasticnet", + "scad", + "mcp", + "adaptive_l1", + "group_lasso", + "group_mcp", + "group_scad", +} # ── Solvers ────────────────────────────────────────────────────────── @@ -214,17 +223,13 @@ def test_combination(self, loss_name, penalty_name, solver_name, class TestSolverPenaltyCompatibility: """Test that solver × penalty compatibility is correctly enforced.""" - def test_newton_with_l1_raises_or_skips(self, continuous_data): - """Newton + L1 should either raise or be handled gracefully.""" + def test_newton_with_l1_raises_explicitly(self, continuous_data): + """Newton must reject L1 before silently changing the objective.""" X, y, _ = continuous_data loss = HuberLoss(delta=1.0) penalty = L1Penalty(0.01) - try: - coef, _ = newton_solver(loss, penalty, X, y, max_iter=10) - # If it doesn't raise, it should still produce finite results - assert np.all(np.isfinite(coef.cpu().numpy() if hasattr(coef, 'cpu') else coef)) - except (NotImplementedError, ValueError, TypeError): - pass # Expected + with pytest.raises(ValueError, match="supports only l2/none"): + newton_solver(loss, penalty, X, y, max_iter=10) def test_fista_with_scad(self, continuous_data): """FISTA + SCAD should work (FISTA handles non-smooth via proximal).""" @@ -265,11 +270,11 @@ def test_quantile_l1_produces_sparse(self, continuous_data): assert n_zeros > 0, f"L1 should produce sparsity, got {coef_np}" def test_huber_elasticnet(self, continuous_data): - """HuberLoss + ElasticNet should work.""" + """HuberLoss + ElasticNet is optimized by a proximal solver.""" X, y, _ = continuous_data loss = HuberLoss(delta=1.0) penalty = ElasticNetPenalty(alpha=0.01, l1_ratio=0.5) - coef, _ = lbfgs_solver(loss, penalty, X, y, max_iter=200, tol=1e-6) + coef, _ = fista_solver(loss, penalty, X, y, max_iter=500, tol=1e-6) coef_np = coef.cpu().numpy() if hasattr(coef, 'cpu') else np.asarray(coef) assert np.all(np.isfinite(coef_np)) diff --git a/dev/tests/test_maintenance_024_025.py b/dev/tests/test_maintenance_024_025.py new file mode 100644 index 000000000..dd01a17b9 --- /dev/null +++ b/dev/tests/test_maintenance_024_025.py @@ -0,0 +1,4634 @@ +"""Maintenance regressions for issues #45, #81, #82, and #83.""" + +from __future__ import annotations + +import inspect +import sys +import types + +import numpy as np +import pytest + + +def test_iterative_compile_policy_defaults_to_eager_and_allows_opt_in(monkeypatch): + from statgpu.backends._torch_compile import resolve_torch_compile_mode + + monkeypatch.delenv("STATGPU_TORCH_COMPILE_MODE", raising=False) + assert resolve_torch_compile_mode(workload="iterative") is None + assert resolve_torch_compile_mode( + workload="general", requested_mode="default" + ) is None + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "auto") + assert resolve_torch_compile_mode(workload="iterative") is None + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "disable") + assert resolve_torch_compile_mode(workload="iterative") is None + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + assert resolve_torch_compile_mode(workload="iterative") == "default" + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "reduce-overhead") + assert resolve_torch_compile_mode(workload="iterative") == "reduce-overhead" + + +def test_compile_runtime_cudagraph_failure_falls_back_once(monkeypatch): + fake_torch = types.ModuleType("torch") + + class FakeCuda: + @staticmethod + def is_available(): + return False + + calls = {"compiled": 0, "eager": 0} + + def fake_compile(fn, *, mode, **kwargs): + assert mode == "default" + + def broken(*args, **call_kwargs): + calls["compiled"] += 1 + raise RuntimeError( + "accessing tensor output of CUDAGraphs that has been " + "overwritten by a subsequent run" + ) + + return broken + + fake_torch.cuda = FakeCuda() + fake_torch.compile = fake_compile + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + + from statgpu.backends._torch_compile import compile_torch + + def eager(value): + calls["eager"] += 1 + return value + 1 + + guarded = compile_torch(eager, workload="iterative") + with pytest.warns(RuntimeWarning, match="CUDA Graph lifecycle"): + assert guarded(1) == 2 + assert guarded(2) == 3 + assert calls == {"compiled": 1, "eager": 2} + assert guarded.__statgpu_compile_status__ == "runtime-fallback" + assert "overwritten" in guarded.__statgpu_compile_error__ + + +def test_repository_has_no_unscoped_reduce_overhead_calls(): + from pathlib import Path + + offenders = [] + for path in Path("statgpu").rglob("*.py"): + if path.name == "_torch_compile.py": + continue + text = path.read_text(encoding="utf-8") + if "mode='reduce-overhead'" in text or 'mode="reduce-overhead"' in text: + offenders.append(path.as_posix()) + assert offenders == [] + + +def test_numpy_finite_validation_vectorizes_sequences(): + from statgpu.backends._validation import check_finite + + value = [np.array([1.0, 2.0]), np.array([3.0, 4.0])] + assert check_finite(value, name="X") is value + with pytest.raises(ValueError, match=r"X.*finite"): + check_finite([np.array([1.0]), np.array([np.inf])], name="X") + with pytest.raises(ValueError, match=r"sample_weight.*finite"): + check_finite(np.array([1.0, np.nan]), name="sample_weight") + + +def test_torch_finite_validation_stays_on_device(): + torch = pytest.importorskip("torch") + from statgpu.backends._validation import check_finite + + tensor = torch.tensor([1.0, 2.0], dtype=torch.float64) + result = check_finite(tensor, name="X") + assert result is tensor + with pytest.raises(ValueError, match=r"X.*finite"): + check_finite(torch.tensor([1.0, float("inf")]), name="X") + + +def test_public_method_guard_rejects_nonfinite_before_fit_body(): + from statgpu._base import BaseEstimator + + class DummyEstimator(BaseEstimator): + def __init__(self, options=None, device="cpu"): + super().__init__(device=device) + self.options = dict(options or {}) + self.body_called = False + + def fit(self, X, y=None): + self.body_called = True + self._fitted = True + return self + + def predict(self, X): + self.body_called = True + return np.zeros(len(X)) + + estimator = DummyEstimator() + with pytest.raises(ValueError, match=r"X.*finite"): + estimator.fit(np.array([[1.0], [np.nan]]), np.array([0.0, 1.0])) + assert estimator.body_called is False + + +def test_legacy_clone_preserves_raw_constructor_identity(): + from sklearn.base import clone + from statgpu._base import BaseEstimator + + class CopyingEstimator(BaseEstimator): + def __init__(self, options=None, solver="AUTO", device="cpu"): + super().__init__(device=device) + self.options = dict(options or {}) + self.solver = str(solver).lower() + + def fit(self, X, y=None): + self._fitted = True + return self + + def predict(self, X): + return np.zeros(len(X)) + + options = {"threshold": 1.0} + estimator = CopyingEstimator(options=options, solver="AUTO") + assert estimator.get_params(deep=False)["options"] is options + assert estimator.get_params(deep=False)["solver"] == "AUTO" + cloned = clone(estimator) + assert type(cloned) is CopyingEstimator + assert cloned.solver == "AUTO" + assert cloned._solver == "auto" + replacement = {"threshold": 2.0} + cloned.set_params(options=replacement) + assert cloned.get_params(deep=False)["options"] is replacement + + +def test_torch_lasso_py21_iterative_compile_smoke(monkeypatch): + """Exercise the original Issue #45 path on a physical modern CUDA GPU.""" + torch = pytest.importorskip("torch") + if not torch.cuda.is_available(): + pytest.skip("requires a physical Torch CUDA backend") + + from packaging.version import Version + + torch_version = Version(torch.__version__.split("+", 1)[0]) + if torch_version < Version("2.1"): + pytest.skip("Issue #45 requires PyTorch 2.1 or newer") + if torch.cuda.get_device_capability()[0] < 7: + pytest.skip("torch.compile acceptance requires CUDA capability >= 7") + + from statgpu.backends import _to_numpy + from statgpu.backends._torch_compile import get_torch_compile_diagnostics + from statgpu.linear_model import Lasso + + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + get_torch_compile_diagnostics(clear=True) + torch._dynamo.reset() + before_graphs = _dynamo_unique_graphs(torch) + rng = np.random.default_rng(20260804) + X = rng.normal(size=(384, 24)).astype(np.float64) + beta = np.zeros(24, dtype=np.float64) + beta[:6] = np.array([1.5, -1.0, 0.8, -0.6, 0.4, -0.2]) + y = X @ beta + 0.05 * rng.normal(size=X.shape[0]) + + kwargs = {"alpha": 0.01, "device": "torch"} + signature = inspect.signature(Lasso) + if "max_iter" in signature.parameters: + kwargs["max_iter"] = 80 + if "tol" in signature.parameters: + kwargs["tol"] = 1e-7 + + model = Lasso(**kwargs) + model.fit(X, y) + first = np.asarray(_to_numpy(model.predict(X))) + model.fit(X, y) + second = np.asarray(_to_numpy(model.predict(X))) + + assert first.shape == y.shape + assert second.shape == y.shape + assert np.isfinite(first).all() + assert np.isfinite(second).all() + np.testing.assert_allclose(first, second, rtol=1e-7, atol=1e-8) + events = get_torch_compile_diagnostics(clear=True) + after_graphs = _dynamo_unique_graphs(torch) + assert after_graphs > before_graphs + assert any(event["status"] == "compiled" for event in events) + assert not any("fallback" in event["status"] for event in events) + + + +def test_compile_construction_fallback_is_visible(monkeypatch): + fake_torch = types.ModuleType("torch") + + class FakeCuda: + @staticmethod + def is_available(): + return False + + def broken_compile(fn, **kwargs): + raise RuntimeError("compiler unavailable") + + fake_torch.cuda = FakeCuda() + fake_torch.compile = broken_compile + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + + from statgpu.backends._torch_compile import ( + compile_torch, + get_torch_compile_diagnostics, + ) + + get_torch_compile_diagnostics(clear=True) + with pytest.warns(RuntimeWarning, match="construction failed"): + wrapped = compile_torch(lambda x: x + 1, workload="iterative") + assert wrapped(2) == 3 + assert wrapped.__statgpu_compile_status__ == "construction-fallback" + assert "compiler unavailable" in wrapped.__statgpu_compile_error__ + assert get_torch_compile_diagnostics(clear=True)[-1]["status"] == "construction-fallback" + + +def test_set_params_rebuilds_normalized_panel_state(): + from statgpu.panel import PooledOLS + + model = PooledOLS() + model._fitted = True + model.set_params(cov_type="HAC") + assert model.get_params(deep=False)["cov_type"] == "HAC" + assert model.cov_type == "HAC" + assert model._cov_type == "hac" + assert model._fitted is False + + +def test_current_sklearn_classifier_and_regressor_tags(): + pytest.importorskip("sklearn") + from sklearn.base import is_classifier, is_regressor + from statgpu.linear_model import LogisticRegression, Ridge + + from statgpu.covariance import GraphicalLasso + + assert is_classifier(LogisticRegression()) + assert is_regressor(Ridge(compute_inference=False)) + assert not is_regressor(GraphicalLasso()) + assert not is_classifier(GraphicalLasso()) + + class ExternalRidge(Ridge): + pass + + assert is_regressor(ExternalRidge(compute_inference=False)) + + +def test_extended_public_finite_validation_matrix(): + from statgpu.backends._validation import check_finite + from statgpu.unsupervised import PCA + + with pytest.raises(ValueError, match="finite"): + check_finite(np.array([1.0, np.nan], dtype=object), name="X") + + X = np.arange(24, dtype=float).reshape(8, 3) + model = PCA(n_components=2).fit(X) + with pytest.raises(ValueError, match="finite"): + model.inverse_transform(np.array([[np.nan, 0.0]])) + + +def _require_modern_torch_cuda(): + torch = pytest.importorskip("torch") + if not torch.cuda.is_available(): + pytest.skip("requires a physical Torch CUDA backend") + from packaging.version import Version + if Version(torch.__version__.split("+", 1)[0]) < Version("2.1"): + pytest.skip("requires PyTorch 2.1 or newer") + if torch.cuda.get_device_capability()[0] < 7: + pytest.skip("requires CUDA capability >= 7") + return torch + + +def _dynamo_unique_graphs(torch): + return int(torch._dynamo.utils.counters["stats"].get("unique_graphs", 0)) + + +def test_physical_cuda_compile_path_is_observable(monkeypatch): + torch = _require_modern_torch_cuda() + from statgpu.backends._torch_compile import ( + compile_torch, + get_torch_compile_diagnostics, + ) + + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + get_torch_compile_diagnostics(clear=True) + + def add_one(x): + return x + 1 + + torch._dynamo.reset() + counters = torch._dynamo.utils.counters + before_graphs = int(counters["stats"].get("unique_graphs", 0)) + compiled = compile_torch(add_one, workload="iterative") + x = torch.arange(16, device="cuda", dtype=torch.float64) + result = compiled(x) + torch.cuda.synchronize() + after_graphs = int(counters["stats"].get("unique_graphs", 0)) + assert compiled.__statgpu_compile_status__ == "compiled" + assert after_graphs > before_graphs + assert torch.allclose(result, x + 1) + assert get_torch_compile_diagnostics(clear=True)[-1]["status"] == "compiled" + + +def test_torch_penalty_compile_matrix_py21(monkeypatch): + torch = _require_modern_torch_cuda() + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + + from statgpu.backends._torch_compile import get_torch_compile_diagnostics + import statgpu.penalties._adaptive_l1 as adaptive_module + import statgpu.penalties._group_lasso as group_lasso_module + import statgpu.penalties._group_mcp as group_mcp_module + import statgpu.penalties._group_scad as group_scad_module + import statgpu.penalties._l1 as l1_module + import statgpu.penalties._mcp as mcp_module + import statgpu.penalties._scad as scad_module + from statgpu.penalties import ( + AdaptiveL1Penalty, + GroupLassoPenalty, + GroupMCPPenalty, + GroupSCADPenalty, + L1Penalty, + MCPPenalty, + SCADPenalty, + ) + + l1_module._L1_PROXIMAL_TORCH_COMPILED = None + adaptive_module._ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED = None + scad_module._SCAD_PROXIMAL_TORCH_COMPILED = None + mcp_module._MCP_PROXIMAL_TORCH_COMPILED = None + group_lasso_module._GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL = None + group_scad_module._GROUP_SCAD_PROXIMAL_TORCH_COMPILED = None + group_mcp_module._GROUP_MCP_PROXIMAL_TORCH_COMPILED = None + get_torch_compile_diagnostics(clear=True) + torch._dynamo.reset() + counters = torch._dynamo.utils.counters + before_graphs = int(counters["stats"].get("unique_graphs", 0)) + + groups = [[0, 1], [2, 3], [4, 5], [6, 7]] + penalties = [ + L1Penalty(alpha=0.2), + AdaptiveL1Penalty(alpha=0.2, weights=np.ones(8)), + SCADPenalty(alpha=0.2), + MCPPenalty(alpha=0.2), + GroupLassoPenalty(alpha=0.2, groups=groups), + GroupSCADPenalty(alpha=0.2, groups=groups), + GroupMCPPenalty(alpha=0.2, groups=groups), + ] + w = torch.linspace(-2.0, 2.0, 8, device="cuda", dtype=torch.float64) + for penalty in penalties: + case_before_graphs = _dynamo_unique_graphs(torch) + result = penalty.proximal(w, step=0.1, backend="torch") + torch.cuda.synchronize() + case_after_graphs = _dynamo_unique_graphs(torch) + assert case_after_graphs > case_before_graphs, penalty.name + assert result.is_cuda + assert torch.isfinite(result).all() + + after_graphs = int(counters["stats"].get("unique_graphs", 0)) + events = get_torch_compile_diagnostics(clear=True) + assert after_graphs > before_graphs + assert len([event for event in events if event["status"] == "compiled"]) >= len(penalties) + assert [event for event in events if "fallback" in event["status"]] == [] + + +def test_cupy_finite_validation_stays_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.backends._validation import check_finite + + value = cp.asarray([1.0, 2.0]) + assert check_finite(value, name="X") is value + with pytest.raises(ValueError, match="finite"): + check_finite(cp.asarray([1.0, cp.inf]), name="X") + + + +def test_set_params_preserves_estimator_fit_validation_boundary(): + from statgpu.survival import CoxPH + + model = CoxPH() + model.set_params(compute_inference="False") + assert model.get_params(deep=False)["compute_inference"] == "False" + + + +def test_compile_call_sites_do_not_swallow_policy_errors(): + import ast + from pathlib import Path + + offenders = [] + for path in Path("statgpu").rglob("*.py"): + source = path.read_text(encoding="utf-8") + if "compile_torch" not in source and "suppress_errors" not in source: + continue + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for decorator in node.decorator_list: + if ( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Name) + and decorator.func.id == "compile_torch" + ): + offenders.append((path.as_posix(), decorator.lineno, "decorator")) + if isinstance(node, ast.Try): + contains_compile = any( + isinstance(child, ast.Call) + and isinstance(child.func, ast.Name) + and child.func.id == "compile_torch" + for stmt in node.body + for child in ast.walk(stmt) + ) + if contains_compile: + offenders.append((path.as_posix(), node.lineno, "caught")) + if "torch._dynamo.config.suppress_errors" in source: + offenders.append((path.as_posix(), 0, "suppress_errors")) + assert offenders == [] + + +def test_invalid_compile_mode_reaches_penalty_callsite(monkeypatch): + fake_torch = types.ModuleType("torch") + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "invalid-mode") + + import statgpu.penalties._l1 as l1_module + from statgpu.penalties import L1Penalty + + l1_module._L1_PROXIMAL_TORCH_COMPILED = None + with pytest.raises(ValueError, match="STATGPU_TORCH_COMPILE_MODE"): + L1Penalty(alpha=0.1).proximal(np.array([1.0]), 0.1, backend="torch") + + +def test_set_params_invalid_update_is_transactional(): + from statgpu.panel import PooledOLS + + model = PooledOLS(cov_type="robust", kernel="bartlett") + model._fitted = True + model.marker_ = object() + marker = model.marker_ + before = model.get_params(deep=False).copy() + + with pytest.raises(ValueError, match="cov_type"): + model.set_params(cov_type="invalid", kernel="PARZEN") + + assert model.get_params(deep=False) == before + assert model.cov_type == "robust" + assert model.kernel == "bartlett" + assert model._fitted is True + assert model.marker_ is marker + + +def test_pandas_nullable_boolean_missing_is_rejected(): + pd = pytest.importorskip("pandas") + from statgpu.backends._validation import check_finite + + with pytest.raises(ValueError, match="finite"): + check_finite(pd.Series([True, pd.NA], dtype="boolean"), name="X") + + +def test_public_sklearn_tags_are_available_and_transformers_are_marked(): + import inspect + import statgpu + + try: + from sklearn.utils import get_tags + except ImportError: + get_tags = None + from sklearn.utils._tags import _safe_tags + + errors = [] + missing_transformer_tags = [] + for name in statgpu.__all__: + cls = getattr(statgpu, name, None) + if not inspect.isclass(cls) or not hasattr(cls, "fit") or inspect.isabstract(cls): + continue + signature = inspect.signature(cls) + required = [ + parameter + for parameter in signature.parameters.values() + if parameter.default is inspect._empty + and parameter.kind + not in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD) + ] + if required: + continue + try: + estimator = cls() + if get_tags is None: + tags = _safe_tags(estimator) + else: + tags = get_tags(estimator) + except Exception as exc: + errors.append(f"{name}: {type(exc).__name__}: {exc}") + continue + if ( + get_tags is not None + and callable(getattr(estimator, "transform", None)) + and tags.transformer_tags is None + ): + missing_transformer_tags.append(name) + + assert errors == [] + assert missing_transformer_tags == [] + + +def test_knockoff_selectors_reject_nonfinite_inputs(): + from statgpu.feature_selection import FixedXKnockoffSelector, KnockoffSelector + + X = np.array([[1.0, np.nan], [2.0, 3.0]]) + y = np.array([0.0, 1.0]) + for selector in (KnockoffSelector(), FixedXKnockoffSelector()): + with pytest.raises(ValueError, match="finite"): + selector.fit(X, y) + + +def test_base_inference_helpers_reject_nonfinite_inputs(): + from statgpu.linear_model import LinearRegression + + model = LinearRegression() + with pytest.raises(ValueError, match="finite"): + model.combine_pvalues(np.array([0.1, np.nan])) + + + +def _default_public_estimators(): + import inspect + import statgpu + + for name in statgpu.__all__: + cls = getattr(statgpu, name, None) + if not inspect.isclass(cls) or not hasattr(cls, "fit") or inspect.isabstract(cls): + continue + signature = inspect.signature(cls) + required = [ + parameter + for parameter in signature.parameters.values() + if parameter.default is inspect._empty + and parameter.kind + not in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD) + ] + if required: + continue + try: + yield name, cls() + except Exception: + continue + + +def test_public_constructor_attributes_preserve_identity(): + mismatches = [] + for estimator_name, estimator in _default_public_estimators(): + for parameter, value in estimator.get_params(deep=False).items(): + if not hasattr(estimator, parameter): + mismatches.append((estimator_name, parameter, "missing")) + elif getattr(estimator, parameter) is not value: + mismatches.append((estimator_name, parameter, "identity")) + assert mismatches == [] + + +def test_public_raw_private_normalized_choice_contracts(): + from statgpu.linear_model import LassoCV + from statgpu.panel import PooledOLS + + panel = PooledOLS(cov_type="HAC") + assert panel.cov_type == "HAC" + assert panel._cov_type == "hac" + + lasso = LassoCV(method="STANDARD", solver="AUTO") + assert lasso.method == "STANDARD" + assert lasso._method == "standard" + assert lasso.solver == "AUTO" + assert lasso._solver == "AUTO" + + +def test_public_raw_private_mutable_kwargs_preserve_runtime_identity(): + from statgpu.linear_model import PenalizedLinearRegression + + penalty_kwargs = {"gamma": 3.0} + loss_kwargs = {"scale": 2.0} + model = PenalizedLinearRegression( + penalty_kwargs=penalty_kwargs, + loss_kwargs=loss_kwargs, + ) + assert model.penalty_kwargs is penalty_kwargs + assert model.loss_kwargs is loss_kwargs + assert model._penalty_kwargs is penalty_kwargs + assert model._loss_kwargs is loss_kwargs + + penalty_kwargs["external"] = True + loss_kwargs["external"] = True + assert model._penalty_kwargs["external"] is True + assert model._loss_kwargs["external"] is True + + +def test_device_public_value_and_private_runtime_are_separate(): + from statgpu.linear_model import Ridge + from statgpu._config import Device + + model = Ridge(device="cpu", compute_inference=False) + assert model.device == "cpu" + assert model._device is Device.CPU + assert model._get_compute_device() is Device.CPU + + +def test_set_params_refreshes_public_and_private_constructor_state(): + from statgpu.panel import PooledOLS + + model = PooledOLS(cov_type="robust") + model._fitted = True + model.set_params(cov_type="HAC") + assert model.cov_type == "HAC" + assert model._cov_type == "hac" + assert model._fitted is False + + +def test_delegated_wrapper_parameters_exist_publicly(): + from statgpu.linear_model import ( + GammaRegression, + NegativeBinomialRegression, + TweedieRegression, + ) + + gamma = GammaRegression(link="log") + negative_binomial = NegativeBinomialRegression(alpha=0.75) + tweedie = TweedieRegression(power=1.7) + assert gamma.link == "log" + assert negative_binomial.alpha == 0.75 + assert tweedie.power == 1.7 + + + +def test_all_public_numeric_methods_expose_finite_contract(): + import inspect + import statgpu + + candidate_names = { + "X", "X_new", "x", "y", "sample_weight", "weights", "offset", + "exposure", "entry", "start", "stop", "time", "event", "times", + "cluster", "clusters", "strata", "subject", "subjects", "groups", + "init", "init_coef", "initial_coef", "time_index", "entity_ids", + "time_ids", "pvalues", "arrays", "scores", "thresholds", "Xk", + "mu", "Sigma", + } + missing = [] + for estimator_name, estimator in _default_public_estimators(): + cls = type(estimator) + for method_name in dir(cls): + if method_name.startswith("_"): + continue + method = getattr(cls, method_name, None) + if not callable(method): + continue + try: + signature = inspect.signature(method) + except (TypeError, ValueError): + continue + if not (set(signature.parameters) & candidate_names): + continue + if not getattr(method, "__statgpu_finite_validation__", False): + missing.append((estimator_name, method_name)) + assert missing == [] + + +def test_inherited_penalized_fit_rejects_nonfinite_before_solver(): + from statgpu.linear_model import PenalizedLinearRegression + + model = PenalizedLinearRegression(compute_inference=False, device="cpu") + X = np.array([[1.0, 0.0], [np.nan, 1.0], [2.0, 2.0]]) + y = np.array([1.0, 2.0, 3.0]) + with pytest.raises(ValueError, match=r"X.*finite"): + model.fit(X, y) + assert model._fitted is False + + +def test_inherited_ridge_predict_rejects_nonfinite(): + from statgpu.linear_model import Ridge + + X = np.arange(24, dtype=float).reshape(8, 3) + y = np.arange(8, dtype=float) + model = Ridge(compute_inference=False, device="cpu").fit(X, y) + bad = X.copy() + bad[0, 0] = np.inf + with pytest.raises(ValueError, match=r"X.*finite"): + model.predict(bad) + + +def test_inherited_lasso_score_rejects_nonfinite_target(): + from statgpu.linear_model import Lasso + + X = np.arange(30, dtype=float).reshape(10, 3) + y = np.arange(10, dtype=float) + model = Lasso(alpha=0.01, compute_inference=False, device="cpu").fit(X, y) + bad_y = y.copy() + bad_y[0] = np.nan + with pytest.raises(ValueError, match=r"y.*finite"): + model.score(X, bad_y) + + +def test_knockoff_manual_validation_is_marked(): + from statgpu.feature_selection import FixedXKnockoffSelector, KnockoffSelector + + for cls in (KnockoffSelector, FixedXKnockoffSelector): + for method_name in ("fit", "fit_transform", "transform"): + assert getattr( + getattr(cls, method_name), + "__statgpu_finite_validation__", + False, + ) + + + +def test_custom_get_params_do_not_expose_normalized_private_values(): + import ast + from pathlib import Path + + normalized_private = { + "_device", "_cov_type", "_hac_maxlags", "_gpu_memory_cleanup", + "_solver", "_cpu_solver", "_stopping", "_inference_method", + "_simultaneous_method", "_n_bootstrap", + "_enable_simultaneous_inference", "_simultaneous_alpha", + "_simultaneous_n_bootstrap", "_simultaneous_include_intercept", + "_method", "_admm_rho", "_alpha_min_ratio", "_cd_kkt_check_every", + "_compute_inference_enabled", "_cv", "_fit_intercept", + "_gpu_cv_mixed_precision", "_max_iter", "_n_alphas", "_tol", + "_n_Cs", "_C_min_ratio", "_penalty_kwargs", "_loss_kwargs", + "_epsilon", "_ties", "_acknowledge_approx", "_refine_top_k", + "_batch_size", "_min_effective_weight", "_quantile", "_cv_strategy", + } + offenders = [] + for path in Path("statgpu").rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + stack = [] + + class Visitor(ast.NodeVisitor): + def visit_FunctionDef(self, node): + stack.append(node.name) + self.generic_visit(node) + stack.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Attribute(self, node): + if ( + stack + and stack[-1] == "get_params" + and isinstance(node.value, ast.Name) + and node.value.id == "self" + and node.attr in normalized_private + ): + offenders.append((path.as_posix(), node.lineno, node.attr)) + self.generic_visit(node) + + Visitor().visit(tree) + assert offenders == [] + + +def test_tsne_nondefault_get_params_preserve_raw_identity(): + from sklearn.base import clone + from statgpu.unsupervised import TSNE + + max_iter = np.int64(300) + device = "".join(("c", "pu")) + model = TSNE(max_iter=max_iter, device=device) + params = model.get_params(deep=False) + assert params["max_iter"] is max_iter + assert params["device"] is device + + cloned = clone(model) + cloned_params = cloned.get_params(deep=False) + assert isinstance(cloned_params["max_iter"], np.integer) + assert cloned_params["device"] == "cpu" + + + +def test_supervised_generic_estimators_have_sklearn_types(): + from sklearn.base import is_classifier, is_regressor + from statgpu.linear_model import ( + GeneralizedLinearModel, + OrderedGeneralizedLinearModel, + PenalizedGLM_CV, + PenalizedGeneralizedLinearModel, + ) + from statgpu.nonparametric import KernelRegression + + assert is_regressor(GeneralizedLinearModel()) + assert is_classifier(OrderedGeneralizedLinearModel()) + assert is_regressor(PenalizedGeneralizedLinearModel()) + assert is_regressor(PenalizedGLM_CV()) + assert is_regressor(KernelRegression()) + + +# PR87_REVIEW_FIX_BATCH_TESTS +def test_formula_history_does_not_bypass_direct_pandas_finite_guard(): + pd = pytest.importorskip("pandas") + from statgpu.linear_model import LinearRegression + + data = pd.DataFrame( + {"y": [1.0, 2.0, 3.0, 4.0], "x": [0.0, 1.0, 2.0, 3.0]} + ) + model = LinearRegression(device="cpu").fit(formula="y ~ x", data=data) + X_bad = pd.DataFrame({"x": [0.0, np.nan, 2.0, 3.0]}) + y = pd.Series([1.0, 2.0, 3.0, 4.0]) + with pytest.raises(ValueError, match=r"X.*finite"): + model.fit(X_bad, y) + + +def test_stepwise_selector_finite_and_supervised_tag_contract(): + from statgpu.feature_selection import StepwiseSelector + from statgpu.linear_model import LinearRegression + + selector = StepwiseSelector(LinearRegression) + X = np.array([[1.0, np.nan], [2.0, 3.0]]) + y = np.array([0.0, 1.0]) + with pytest.raises(ValueError, match=r"X.*finite"): + selector.fit(X, y) + assert selector._more_tags()["requires_y"] is True + + try: + from sklearn.utils import get_tags + except ImportError: + from sklearn.utils._tags import _safe_tags + + assert _safe_tags(selector)["requires_y"] is True + else: + assert get_tags(selector).target_tags.required is True + assert get_tags(selector).transformer_tags is not None + + +@pytest.mark.parametrize( + "entrypoint", + [ + "fixed_x_knockoff_filter", + "model_x_knockoff_filter", + "knockoff_filter", + ], +) +def test_function_style_knockoff_entrypoints_reject_nonfinite(entrypoint): + import statgpu.feature_selection as feature_selection + + fn = getattr(feature_selection, entrypoint) + X = np.array([[1.0, np.nan], [2.0, 3.0]]) + y = np.array([0.0, 1.0]) + with pytest.raises(ValueError, match=r"X.*finite"): + fn(X, y, backend="numpy") + + +def test_nested_set_params_is_atomic_and_does_not_mutate_shared_children(): + from statgpu._base import BaseEstimator + + class Child(BaseEstimator): + def __init__(self, value=1, device="cpu"): + super().__init__(device=device) + self.value = value + + def fit(self, X, y=None): + self._fitted = True + return self + + def predict(self, X): + return np.zeros(len(X)) + + class Parent(BaseEstimator): + def __init__(self, left=None, right=None, device="cpu"): + super().__init__(device=device) + self.left = left + self.right = right + + def fit(self, X, y=None): + self._fitted = True + return self + + def predict(self, X): + return np.zeros(len(X)) + + left = Child(value=1) + right = Child(value=2) + parent = Parent(left=left, right=right) + + with pytest.raises(ValueError, match="Invalid parameter"): + parent.set_params(left__value=7, right__unknown=3) + assert parent.left is left + assert parent.right is right + assert left.value == 1 + assert right.value == 2 + + parent.set_params(left__value=7) + assert left.value == 1 + assert parent.left is not left + assert parent.left.value == 7 + + +def test_torch_public_finite_validation_stays_on_cuda(): + torch = _require_modern_torch_cuda() + from statgpu.linear_model import LinearRegression + + X = torch.tensor( + [[1.0, 2.0], [3.0, float("nan")]], + dtype=torch.float64, + device="cuda", + ) + y = torch.tensor([1.0, 2.0], dtype=torch.float64, device="cuda") + with pytest.raises(ValueError, match=r"X.*finite"): + LinearRegression(device="torch").fit(X, y) + assert X.is_cuda and y.is_cuda + + +def test_cupy_public_finite_validation_stays_on_cuda(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.linear_model import LinearRegression + + X = cp.asarray([[1.0, 2.0], [3.0, cp.nan]], dtype=cp.float64) + y = cp.asarray([1.0, 2.0], dtype=cp.float64) + with pytest.raises(ValueError, match=r"X.*finite"): + LinearRegression(device="cuda").fit(X, y) + assert isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) + + +@pytest.mark.parametrize( + "penalty,penalty_kwargs", + [ + ("scad", {}), + ("mcp", {}), + ("group_scad", {"groups": [[0, 1], [2, 3], [4, 5]]}), + ("group_mcp", {"groups": [[0, 1], [2, 3], [4, 5]]}), + ], +) +def test_torch_nonconvex_model_level_compile_matrix_py21( + monkeypatch, penalty, penalty_kwargs +): + torch = _require_modern_torch_cuda() + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + + from statgpu.backends import _to_numpy + from statgpu.backends._torch_compile import get_torch_compile_diagnostics + from statgpu.linear_model import PenalizedLinearRegression + import statgpu.solvers._fista_lla as fista_lla_module + + fista_lla_module._SQERR_PROXIMAL_TORCH = None + fista_lla_module._FUSED_PROXIMAL_CLIP_TORCH = None + if penalty == "group_scad": + import statgpu.penalties._group_scad as group_scad_module + + group_scad_module._GROUP_SCAD_PROXIMAL_TORCH_COMPILED = None + elif penalty == "group_mcp": + import statgpu.penalties._group_mcp as group_mcp_module + + group_mcp_module._GROUP_MCP_PROXIMAL_TORCH_COMPILED = None + get_torch_compile_diagnostics(clear=True) + torch._dynamo.reset() + before_graphs = _dynamo_unique_graphs(torch) + + rng = np.random.default_rng(20260805) + X = rng.normal(size=(192, 6)).astype(np.float64) + beta = np.array([1.2, -0.8, 0.6, 0.0, 0.0, 0.0]) + y = X @ beta + 0.05 * rng.normal(size=X.shape[0]) + model = PenalizedLinearRegression( + penalty=penalty, + penalty_kwargs=penalty_kwargs, + alpha=0.03, + max_iter=40, + max_lla_iters=3, + tol=1e-6, + lla_tol=1e-6, + device="torch", + compute_inference=False, + ).fit(X, y) + prediction = np.asarray(_to_numpy(model.predict(X))) + + assert prediction.shape == y.shape + assert np.isfinite(prediction).all() + assert np.isfinite(np.asarray(_to_numpy(model.coef_))).all() + events = get_torch_compile_diagnostics(clear=True) + after_graphs = _dynamo_unique_graphs(torch) + assert after_graphs > before_graphs + assert any(event["status"] == "compiled" for event in events) + assert not any("fallback" in event["status"] for event in events) + + +def test_torch_elasticnet_model_level_compile_path_py21(monkeypatch): + torch = _require_modern_torch_cuda() + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "default") + from statgpu.backends import _to_numpy + from statgpu.backends._torch_compile import get_torch_compile_diagnostics + from statgpu.linear_model import ElasticNet + + get_torch_compile_diagnostics(clear=True) + torch._dynamo.reset() + before_graphs = _dynamo_unique_graphs(torch) + rng = np.random.default_rng(20260806) + X = rng.normal(size=(192, 10)).astype(np.float64) + y = X[:, 0] - 0.5 * X[:, 1] + 0.05 * rng.normal(size=X.shape[0]) + model = ElasticNet( + alpha=0.02, + l1_ratio=0.6, + max_iter=60, + tol=1e-6, + device="torch", + ).fit(X, y) + prediction = np.asarray(_to_numpy(model.predict(X))) + assert np.isfinite(prediction).all() + events = get_torch_compile_diagnostics(clear=True) + after_graphs = _dynamo_unique_graphs(torch) + assert after_graphs > before_graphs + assert any(event["status"] == "compiled" for event in events) + assert not any("fallback" in event["status"] for event in events) + + +# PR87_FORMULA_PREDICT_OWNERSHIP_TEST +def test_formula_predict_dataframe_keeps_formula_missing_row_semantics(): + pd = pytest.importorskip("pandas") + from statgpu.linear_model import LinearRegression + + data = pd.DataFrame( + {"y": [1.0, 2.0, 3.0, 4.0], "x": [0.0, 1.0, 2.0, 3.0]} + ) + model = LinearRegression(device="cpu").fit(formula="y ~ x", data=data) + new_data = pd.DataFrame({"x": [0.0, np.nan, 2.0, 3.0]}) + prediction = np.asarray(model.predict(new_data)) + assert prediction.shape == (3,) + assert np.isfinite(prediction).all() + + +# PR87_STEPWISE_LIFECYCLE_TESTS +def test_stepwise_transform_and_set_params_lifecycle(): + from statgpu.feature_selection import StepwiseSelector + from statgpu.linear_model import LinearRegression + + X = np.column_stack([np.arange(8.0), np.arange(8.0) ** 2]) + y = 1.0 + 2.0 * X[:, 0] + selector = StepwiseSelector( + LinearRegression, max_features=1, device="cpu" + ).fit(X, y) + + transformed = selector.transform(X) + assert transformed.shape == (X.shape[0], 1) + assert selector.__sklearn_is_fitted__() is True + + selector.set_params(criterion="BIC") + assert selector.criterion == "BIC" + assert selector._criterion == "bic" + assert selector.__sklearn_is_fitted__() is False + with pytest.raises(RuntimeError, match="not been fitted"): + selector.predict(X) + + before = selector.get_params(deep=False) + with pytest.raises(ValueError, match="criterion"): + selector.set_params(criterion="invalid") + assert selector.get_params(deep=False) == before + + +# PR87_DATA_ONLY_FORMULA_GUARD_TEST +def test_data_argument_alone_does_not_disable_direct_pandas_finite_guard(): + pd = pytest.importorskip("pandas") + from statgpu.linear_model import LinearRegression + + X_bad = pd.DataFrame({"x": [0.0, np.nan, 2.0, 3.0]}) + y = pd.Series([1.0, 2.0, 3.0, 4.0]) + unrelated_data = pd.DataFrame({"z": [1.0, 2.0, 3.0, 4.0]}) + with pytest.raises(ValueError, match=r"X.*finite"): + LinearRegression(device="cpu").fit(X_bad, y, data=unrelated_data) + + +# PR87_KNOCKOFF_SET_PARAMS_TRANSACTION_TESTS +@pytest.mark.parametrize("selector_name", ["KnockoffSelector", "FixedXKnockoffSelector"]) +def test_knockoff_selector_set_params_is_transactional(selector_name): + import statgpu.feature_selection as feature_selection + + selector = getattr(feature_selection, selector_name)(q=0.1) + sentinel_result = object() + sentinel_features = np.array([0], dtype=np.int64) + selector.result_ = sentinel_result + selector.selected_features_ = sentinel_features + + with pytest.raises(ValueError, match="Invalid parameter"): + selector.set_params(q=0.2, unknown_parameter=1) + assert selector.q == 0.1 + assert selector.result_ is sentinel_result + assert selector.selected_features_ is sentinel_features + + selector.set_params(q=0.2) + assert selector.q == 0.2 + assert selector.result_ is None + assert selector.selected_features_ is None + + +# PR87_SECOND_REVIEW_FORMULA_WEIGHT_TESTS +def test_glm_formula_sample_weight_aligns_patsy_retained_rows(): + pd = pytest.importorskip("pandas") + from statgpu.linear_model import GeneralizedLinearModel + + data = pd.DataFrame( + { + "y": [1.0, 2.0, 3.0, 5.0, 8.0], + "x": [0.0, 1.0, np.nan, 3.0, 4.0], + } + ) + original_weights = np.array([1.0, 2.0, 1000.0, 4.0, 5.0]) + retained = np.array([0, 1, 3, 4]) + + formula_model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, device="cpu" + ).fit( + formula="y ~ x", data=data, sample_weight=original_weights + ) + direct_model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, device="cpu" + ).fit( + data.loc[retained, ["x"]].to_numpy(), + data.loc[retained, "y"].to_numpy(), + sample_weight=original_weights[retained], + ) + np.testing.assert_allclose(formula_model.coef_, direct_model.coef_) + np.testing.assert_allclose(formula_model.intercept_, direct_model.intercept_) + + aligned_model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, device="cpu" + ).fit( + formula="y ~ x", data=data, sample_weight=original_weights[retained] + ) + np.testing.assert_allclose(aligned_model.coef_, direct_model.coef_) + np.testing.assert_allclose(aligned_model.intercept_, direct_model.intercept_) + + with pytest.raises(ValueError, match="sample_weight must (?:have length|match)"): + GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, device="cpu" + ).fit( + formula="y ~ x", data=data, sample_weight=np.ones(3) + ) + + +def test_compile_benchmark_has_hard_per_case_graph_gate(): + import importlib.util + from pathlib import Path + + path = Path("dev/benchmarks/benchmark_torch_compile_maintenance.py") + spec = importlib.util.spec_from_file_location("compile_benchmark", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + module._validate_compile_evidence( + "default", "lasso", [{"status": "compiled"}], graph_delta=1 + ) + with pytest.raises(RuntimeError, match="Dynamo graph"): + module._validate_compile_evidence( + "default", "lasso", [{"status": "compiled"}], graph_delta=0 + ) + with pytest.raises(RuntimeError, match="compiled diagnostic"): + module._validate_compile_evidence( + "default", "lasso", [], graph_delta=1 + ) + + +# PR87_FORMULA_WEIGHT_SHARED_ALIGNMENT_TESTS +@pytest.mark.parametrize("kind", ["linear", "glm", "penalized"]) +def test_formula_sample_weight_validates_after_row_alignment(kind): + pd = pytest.importorskip("pandas") + from statgpu.linear_model import ( + GeneralizedLinearModel, + LinearRegression, + PenalizedLinearRegression, + ) + + data = pd.DataFrame( + {"y": [1.0, 2.0, 3.0, 5.0], "x": [0.0, 1.0, np.nan, 3.0]} + ) + if kind == "linear": + factory = lambda: LinearRegression(device="cpu", compute_inference=False) + elif kind == "glm": + factory = lambda: GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, device="cpu" + ) + else: + factory = lambda: PenalizedLinearRegression( + penalty="l1", + alpha=0.01, + max_iter=30, + device="cpu", + compute_inference=False, + ) + + # Non-finite value belongs only to the row Patsy drops, so it is removed + # before the retained side array is validated. + model = factory().fit( + formula="y ~ x", + data=data, + sample_weight=np.array([1.0, 1.0, np.nan, 1.0]), + ) + assert model is not None + + with pytest.raises(ValueError, match=r"sample_weight.*finite"): + factory().fit( + formula="y ~ x", + data=data, + sample_weight=pd.Series([1.0, np.nan, 1.0, 1.0]), + ) + with pytest.raises(ValueError, match="one-dimensional"): + factory().fit( + formula="y ~ x", + data=data, + sample_weight=np.ones((len(data), 1)), + ) + with pytest.raises(ValueError, match="non-negative"): + factory().fit( + formula="y ~ x", + data=data, + sample_weight=np.array([1.0, -1.0, 1.0, 1.0]), + ) + with pytest.raises(ValueError, match="positive sum"): + factory().fit( + formula="y ~ x", + data=data, + sample_weight=np.zeros(len(data)), + ) + + +def test_glm_direct_sample_weight_semantic_contract(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.arange(8.0).reshape(4, 2) + y = np.arange(4.0) + factory = lambda: GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, device="cpu" + ) + with pytest.raises(ValueError, match="one-dimensional"): + factory().fit(X, y, sample_weight=np.ones((4, 1))) + with pytest.raises(ValueError, match="non-negative"): + factory().fit(X, y, sample_weight=np.array([1.0, 1.0, -1.0, 1.0])) + with pytest.raises(ValueError, match="positive sum"): + factory().fit(X, y, sample_weight=np.zeros(4)) + + +# PR87_FORMULA_WEIGHT_GPU_DEVICE_TESTS +def test_torch_formula_sample_weight_alignment_stays_on_device(): + torch = _require_modern_torch_cuda() + from statgpu.core.formula import align_formula_sample_weight + + weights = torch.tensor( + [1.0, float("nan"), 3.0, 4.0], + dtype=torch.float64, + device="cuda", + ) + aligned = align_formula_sample_weight( + weights, + data_length=4, + retained_rows=np.array([0, 2, 3], dtype=np.int64), + retained_length=3, + ) + assert aligned.is_cuda + assert aligned.device == weights.device + assert torch.isfinite(aligned).all() + torch.testing.assert_close( + aligned, + torch.tensor([1.0, 3.0, 4.0], dtype=torch.float64, device="cuda"), + ) + assert weights.is_cuda + + with pytest.raises(ValueError, match=r"sample_weight.*finite"): + align_formula_sample_weight( + weights, + data_length=4, + retained_rows=np.array([0, 1, 3], dtype=np.int64), + retained_length=3, + ) + assert weights.is_cuda + + +def test_cupy_formula_sample_weight_alignment_stays_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.core.formula import align_formula_sample_weight + + weights = cp.asarray([1.0, cp.nan, 3.0, 4.0], dtype=cp.float64) + aligned = align_formula_sample_weight( + weights, + data_length=4, + retained_rows=np.array([0, 2, 3], dtype=np.int64), + retained_length=3, + ) + assert isinstance(aligned, cp.ndarray) + assert int(aligned.device.id) == int(weights.device.id) + assert bool(cp.isfinite(aligned).all().item()) + cp.testing.assert_allclose(aligned, cp.asarray([1.0, 3.0, 4.0])) + assert isinstance(weights, cp.ndarray) + + with pytest.raises(ValueError, match=r"sample_weight.*finite"): + align_formula_sample_weight( + weights, + data_length=4, + retained_rows=np.array([0, 1, 3], dtype=np.int64), + retained_length=3, + ) + assert isinstance(weights, cp.ndarray) + + +# PR87_GLM_WEIGHT_INFERENCE_DEVICE_TESTS +def test_torch_glm_formula_weight_inference_avoids_cpu_roundtrip(monkeypatch): + torch = _require_modern_torch_cuda() + pd = pytest.importorskip("pandas") + import statgpu.linear_model._glm_base as glm_module + from statgpu.linear_model import GeneralizedLinearModel + + data = pd.DataFrame( + {"y": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], "x": [0., 1., 2., 3., 4., 5.]} + ) + weights = torch.tensor( + [1.0, 1.5, 2.0, 2.5, 3.0, 3.5], + dtype=torch.float64, + device="cuda", + ) + original_to_numpy = glm_module._to_numpy + + def guarded_to_numpy(value): + if ( + torch.is_tensor(value) + and value.is_cuda + and tuple(value.shape) == tuple(weights.shape) + and bool(torch.allclose(value, weights)) + ): + raise AssertionError("formula sample_weight copied to CPU") + return original_to_numpy(value) + + monkeypatch.setattr(glm_module, "_to_numpy", guarded_to_numpy) + model = GeneralizedLinearModel( + family="gaussian", + solver="irls", + C=0.0, + device="torch", + compute_inference=True, + ).fit(formula="y ~ x", data=data, sample_weight=weights) + assert torch.is_tensor(model._sample_weight_inf) + assert model._sample_weight_inf.is_cuda + assert weights.is_cuda + + +def test_cupy_glm_formula_weight_inference_avoids_cpu_roundtrip(monkeypatch): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + pd = pytest.importorskip("pandas") + import statgpu.linear_model._glm_base as glm_module + from statgpu.linear_model import GeneralizedLinearModel + + data = pd.DataFrame( + {"y": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], "x": [0., 1., 2., 3., 4., 5.]} + ) + weights = cp.asarray([1.0, 1.5, 2.0, 2.5, 3.0, 3.5], dtype=cp.float64) + original_to_numpy = glm_module._to_numpy + + def guarded_to_numpy(value): + if ( + isinstance(value, cp.ndarray) + and tuple(value.shape) == tuple(weights.shape) + and bool(cp.allclose(value, weights)) + ): + raise AssertionError("formula sample_weight copied to CPU") + return original_to_numpy(value) + + monkeypatch.setattr(glm_module, "_to_numpy", guarded_to_numpy) + model = GeneralizedLinearModel( + family="gaussian", + solver="irls", + C=0.0, + device="cuda", + compute_inference=True, + ).fit(formula="y ~ x", data=data, sample_weight=weights) + assert isinstance(model._sample_weight_inf, cp.ndarray) + assert int(model._sample_weight_inf.device.id) == int(weights.device.id) + assert isinstance(weights, cp.ndarray) + + +# PR87_GLM_FISTA_WEIGHTED_INTERCEPT_TESTS +def _weighted_linear_reference(X, y, weights): + X = np.asarray(X, dtype=np.float64) + y = np.asarray(y, dtype=np.float64) + weights = np.asarray(weights, dtype=np.float64) + design = np.column_stack([np.ones(X.shape[0]), X]) + root_w = np.sqrt(weights) + return np.linalg.lstsq( + design * root_w[:, None], y * root_w, rcond=None + )[0] + + +def test_glm_fista_weighted_intercept_matches_closed_form_wls(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array( + [[-2.0], [-1.0], [0.0], [1.0], [2.0], [3.0]], dtype=np.float64 + ) + y = np.array([-1.0, 0.2, 1.1, 2.0, 8.0, 9.5], dtype=np.float64) + weights = np.array([8.0, 7.0, 6.0, 2.0, 1.0, 0.5], dtype=np.float64) + expected = _weighted_linear_reference(X, y, weights) + + model = GeneralizedLinearModel( + family="gaussian", + solver="fista", + C=0.0, + max_iter=4000, + tol=1e-11, + device="cpu", + compute_inference=False, + ).fit(X, y, sample_weight=weights) + np.testing.assert_allclose(model.intercept_, expected[0], rtol=2e-5, atol=2e-5) + np.testing.assert_allclose(model.coef_, expected[1:], rtol=2e-5, atol=2e-5) + + +def test_glm_formula_fista_weighted_intercept_matches_retained_wls(): + pd = pytest.importorskip("pandas") + from statgpu.linear_model import GeneralizedLinearModel + + data = pd.DataFrame( + { + "y": [-1.0, 0.2, 99.0, 2.0, 8.0, 9.5], + "x": [-2.0, -1.0, np.nan, 1.0, 2.0, 3.0], + } + ) + weights = np.array([8.0, 7.0, 1000.0, 2.0, 1.0, 0.5]) + retained = np.array([0, 1, 3, 4, 5]) + expected = _weighted_linear_reference( + data.loc[retained, ["x"]].to_numpy(), + data.loc[retained, "y"].to_numpy(), + weights[retained], + ) + + model = GeneralizedLinearModel( + family="gaussian", + solver="fista", + C=0.0, + max_iter=4000, + tol=1e-11, + device="cpu", + compute_inference=False, + ).fit(formula="y ~ x", data=data, sample_weight=weights) + np.testing.assert_allclose(model.intercept_, expected[0], rtol=2e-5, atol=2e-5) + np.testing.assert_allclose(model.coef_, expected[1:], rtol=2e-5, atol=2e-5) + + +def test_torch_glm_formula_fista_weighted_intercept_matches_wls(): + torch = _require_modern_torch_cuda() + pd = pytest.importorskip("pandas") + from statgpu.linear_model import GeneralizedLinearModel + + data = pd.DataFrame( + { + "y": [-1.0, 0.2, 99.0, 2.0, 8.0, 9.5], + "x": [-2.0, -1.0, np.nan, 1.0, 2.0, 3.0], + } + ) + weights_np = np.array([8.0, 7.0, 1000.0, 2.0, 1.0, 0.5]) + weights = torch.as_tensor(weights_np, dtype=torch.float64, device="cuda") + retained = np.array([0, 1, 3, 4, 5]) + expected = _weighted_linear_reference( + data.loc[retained, ["x"]].to_numpy(), + data.loc[retained, "y"].to_numpy(), + weights_np[retained], + ) + + model = GeneralizedLinearModel( + family="gaussian", + solver="fista", + C=0.0, + max_iter=4000, + tol=1e-11, + device="torch", + compute_inference=False, + ).fit(formula="y ~ x", data=data, sample_weight=weights) + np.testing.assert_allclose(model.intercept_, expected[0], rtol=3e-5, atol=3e-5) + np.testing.assert_allclose(model.coef_, expected[1:], rtol=3e-5, atol=3e-5) + assert weights.is_cuda + + +def test_cupy_glm_formula_fista_weighted_intercept_matches_wls(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + pd = pytest.importorskip("pandas") + from statgpu.linear_model import GeneralizedLinearModel + + data = pd.DataFrame( + { + "y": [-1.0, 0.2, 99.0, 2.0, 8.0, 9.5], + "x": [-2.0, -1.0, np.nan, 1.0, 2.0, 3.0], + } + ) + weights_np = np.array([8.0, 7.0, 1000.0, 2.0, 1.0, 0.5]) + weights = cp.asarray(weights_np, dtype=cp.float64) + retained = np.array([0, 1, 3, 4, 5]) + expected = _weighted_linear_reference( + data.loc[retained, ["x"]].to_numpy(), + data.loc[retained, "y"].to_numpy(), + weights_np[retained], + ) + + model = GeneralizedLinearModel( + family="gaussian", + solver="fista", + C=0.0, + max_iter=4000, + tol=1e-11, + device="cuda", + compute_inference=False, + ).fit(formula="y ~ x", data=data, sample_weight=weights) + np.testing.assert_allclose(model.intercept_, expected[0], rtol=3e-5, atol=3e-5) + np.testing.assert_allclose(model.coef_, expected[1:], rtol=3e-5, atol=3e-5) + assert isinstance(weights, cp.ndarray) + + +# PR87_WEIGHTED_IRLS_AND_GLM_COMPILE_POLICY_TESTS +def test_weighted_irls_line_search_uses_weighted_objective_cpu(): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + X = np.ones((4, 1), dtype=np.float64) + y = np.array([0.0, 0.0, 0.0, 10.0], dtype=np.float64) + weights = np.array([1.0, 1.0, 1.0, 100.0], dtype=np.float64) + params, _ = IRLSSolver(Gaussian(), max_iter=20, tol=1e-12).fit( + X, y, sample_weight=weights, backend="numpy" + ) + np.testing.assert_allclose( + params[0], np.average(y, weights=weights), rtol=1e-10, atol=1e-10 + ) + + +def test_glm_irls_weighted_ridge_matches_closed_form_and_weight_rescaling(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array([[-2.0], [-1.0], [0.0], [1.0], [2.0]], dtype=np.float64) + y = np.array([-2.0, -0.5, 0.5, 2.0, 6.0], dtype=np.float64) + weights = np.array([1.0, 2.0, 3.0, 7.0, 20.0], dtype=np.float64) + C = 2.0 + lam = 1.0 / (2.0 * C) + design = np.column_stack([np.ones(X.shape[0]), X]) + expected = np.linalg.solve( + design.T @ (design * weights[:, None]) + + np.diag([0.0, weights.sum() * lam]), + design.T @ (weights * y), + ) + + def fit(current_weights): + return GeneralizedLinearModel( + family="gaussian", + solver="irls", + C=C, + max_iter=100, + tol=1e-12, + device="cpu", + compute_inference=False, + ).fit(X, y, sample_weight=current_weights) + + model = fit(weights) + scaled = fit(17.0 * weights) + np.testing.assert_allclose(model.intercept_, expected[0], rtol=1e-9, atol=1e-9) + np.testing.assert_allclose(model.coef_, expected[1:], rtol=1e-9, atol=1e-9) + np.testing.assert_allclose(scaled.intercept_, model.intercept_, rtol=1e-9, atol=1e-9) + np.testing.assert_allclose(scaled.coef_, model.coef_, rtol=1e-9, atol=1e-9) + + +def test_glm_weighted_loglikelihood_and_dispersion_match_manual_values(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array([[-1.0], [0.0], [1.0], [2.0], [3.0]], dtype=np.float64) + y = np.array([-0.5, 0.2, 1.8, 2.1, 5.5], dtype=np.float64) + weights = np.array([1.0, 2.0, 4.0, 3.0, 8.0], dtype=np.float64) + model = GeneralizedLinearModel( + family="gaussian", + solver="irls", + C=0.0, + max_iter=100, + tol=1e-12, + device="cpu", + compute_inference=True, + ).fit(X, y, sample_weight=weights) + + eta = model.intercept_ + X @ model.coef_ + resid_sq = (y - eta) ** 2 + expected_ll = -0.5 * X.shape[0] * float( + np.sum(weights * resid_sq) / np.sum(weights) + ) + k = 1 + X.shape[1] + expected_dispersion = float(np.sum(weights * resid_sq)) / (X.shape[0] - k) + np.testing.assert_allclose(model.loglikelihood, expected_ll, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose( + model._inference_result.metadata["dispersion"], + expected_dispersion, + rtol=1e-12, + atol=1e-12, + ) + + no_inference = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, device="cpu", + compute_inference=False, + ).fit(X, y, sample_weight=weights) + eta_no_inf = no_inference.intercept_ + X @ no_inference.coef_ + expected_no_inf = -0.5 * X.shape[0] * float( + np.sum(weights * (y - eta_no_inf) ** 2) / np.sum(weights) + ) + np.testing.assert_allclose( + no_inference.loglikelihood, expected_no_inf, rtol=1e-12, atol=1e-12 + ) + + +def test_active_glm_compile_helpers_use_central_policy_and_reraise(): + from pathlib import Path + from statgpu.glm_core import _irls, _solver_utils + + for filename in ( + "statgpu/glm_core/_irls.py", + "statgpu/glm_core/_solver_utils.py", + ): + source = Path(filename).read_text(encoding="utf-8") + assert "torch.compile(" not in source + assert "compile_torch(" in source + + def fail(*args): + raise RuntimeError("unrelated runtime failure") + + with pytest.raises(RuntimeError, match="unrelated runtime failure"): + _irls._irls_step_call(fail) + with pytest.raises(RuntimeError, match="unrelated runtime failure"): + _solver_utils._fista_step_call(fail) + with pytest.raises(RuntimeError, match="unrelated runtime failure"): + _solver_utils._newton_step_call(fail) + + +def test_torch_weighted_irls_compile_path_is_observable(): + torch = _require_modern_torch_cuda() + from statgpu.backends._torch_compile import get_torch_compile_diagnostics + from statgpu.glm_core import _irls + from statgpu.linear_model import GeneralizedLinearModel + + _irls._IRLS_STEP_COMPILED = None + torch._dynamo.reset() + get_torch_compile_diagnostics(clear=True) + before_graphs = _dynamo_unique_graphs(torch) + + X = np.arange(24.0, dtype=np.float64).reshape(12, 2) + y = 1.5 + X @ np.array([0.2, -0.1]) + weights = torch.linspace(1.0, 3.0, X.shape[0], dtype=torch.float64, device="cuda") + model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + max_iter=20, tol=1e-10, device="torch", compute_inference=False, + ).fit(X, y, sample_weight=weights) + torch.cuda.synchronize() + + events = get_torch_compile_diagnostics(clear=True) + assert _dynamo_unique_graphs(torch) > before_graphs + assert any(event["status"] == "compiled" for event in events) + assert not any("fallback" in event["status"] for event in events) + assert np.isfinite(model.coef_).all() + assert weights.is_cuda + + +def test_cupy_weighted_irls_matches_cpu_reference(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array([[-2.0], [-1.0], [0.0], [1.0], [2.0]], dtype=np.float64) + y = np.array([-2.0, -0.5, 0.5, 2.0, 6.0], dtype=np.float64) + weights_np = np.array([1.0, 2.0, 3.0, 7.0, 20.0], dtype=np.float64) + weights = cp.asarray(weights_np) + cpu = GeneralizedLinearModel( + family="gaussian", solver="irls", C=2.0, + max_iter=100, tol=1e-12, device="cpu", compute_inference=False, + ).fit(X, y, sample_weight=weights_np) + gpu = GeneralizedLinearModel( + family="gaussian", solver="irls", C=2.0, + max_iter=100, tol=1e-12, device="cuda", compute_inference=False, + ).fit(X, y, sample_weight=weights) + np.testing.assert_allclose(gpu.intercept_, cpu.intercept_, rtol=1e-9, atol=1e-9) + np.testing.assert_allclose(gpu.coef_, cpu.coef_, rtol=1e-9, atol=1e-9) + assert isinstance(weights, cp.ndarray) + + +# PR87_IRLS_OBJECTIVE_AND_EFFECTIVE_NOBS_TESTS +def test_irls_line_search_reuses_registered_loss_and_propagates_errors(monkeypatch): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + from statgpu.glm_core._squared import SquaredErrorLoss + + def fail(self, eta, y): + raise RuntimeError("objective evaluation failed") + + monkeypatch.setattr(SquaredErrorLoss, "per_sample_value", fail) + with pytest.raises(RuntimeError, match="objective evaluation failed"): + IRLSSolver(Gaussian(), max_iter=2).fit( + np.ones((4, 1)), np.arange(4.0), backend="numpy" + ) + + +def test_irls_solve_only_falls_back_for_singular_systems(monkeypatch): + from statgpu.glm_core import _irls + + singular = np.array([[1.0, 1.0], [2.0, 2.0]]) + rhs = np.array([1.0, 2.0]) + solution = _irls._solve(singular, rhs, backend="numpy") + np.testing.assert_allclose(singular @ solution, rhs, rtol=1e-12, atol=1e-12) + + def invalid_solve(A, b): + raise ValueError("shape/device contract failure") + + def forbidden_lstsq(*args, **kwargs): + raise AssertionError("lstsq must not mask non-singularity failures") + + monkeypatch.setattr(np.linalg, "solve", invalid_solve) + monkeypatch.setattr(np.linalg, "lstsq", forbidden_lstsq) + with pytest.raises(ValueError, match="shape/device contract failure"): + _irls._solve(np.eye(2), np.ones(2), backend="numpy") + + +def test_irls_source_has_no_broad_objective_fallback_and_cupy_norm_is_native(): + from pathlib import Path + + source = Path("statgpu/glm_core/_irls.py").read_text(encoding="utf-8") + line_search = source.split("# Backtracking line search", 1)[1].split( + "# Convergence: normalized penalized score norm.", 1 + )[0] + assert "except Exception" not in line_search + assert "objective_loss.per_sample_value" in line_search + norm_body = source.split("def _norm", 1)[1].split("def _zeros", 1)[0] + assert "cp.linalg.norm" in norm_body + + +def test_glm_analytic_weight_diagnostics_are_scale_invariant(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array([[-1.0], [0.0], [2.0], [4.0]], dtype=np.float64) + y = np.array([-0.4, 0.5, 2.2, 5.1], dtype=np.float64) + weights = np.array([0.5, 1.5, 2.0, 4.0], dtype=np.float64) + + def fit(current_weights, cov_type="nonrobust"): + return GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + max_iter=100, tol=1e-12, device="cpu", + compute_inference=True, cov_type=cov_type, + ).fit(X, y, sample_weight=current_weights) + + weighted = fit(weights) + scaled = fit(23.0 * weights) + np.testing.assert_allclose(weighted.coef_, scaled.coef_, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(weighted.intercept_, scaled.intercept_, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(weighted.loglikelihood, scaled.loglikelihood, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(weighted.aic, scaled.aic, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(weighted.bic, scaled.bic, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(weighted._bse, scaled._bse, rtol=1e-11, atol=1e-11) + assert weighted._df_resid == X.shape[0] - (X.shape[1] + 1) + + robust = fit(weights, cov_type="hc0") + robust_scaled = fit(23.0 * weights, cov_type="hc0") + np.testing.assert_allclose(robust._bse, robust_scaled._bse, rtol=1e-11, atol=1e-11) + + +def test_glm_weighted_loglikelihood_uses_normalized_analytic_weights(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array([[-1.0], [0.0], [1.0], [3.0]], dtype=np.float64) + y = np.array([-0.2, 0.3, 1.4, 4.0], dtype=np.float64) + weights = np.array([0.5, 2.0, 1.0, 6.0], dtype=np.float64) + model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ).fit(X, y, sample_weight=weights) + eta = model.intercept_ + X @ model.coef_ + expected = -X.shape[0] * np.sum(weights * 0.5 * (y - eta) ** 2) / np.sum(weights) + np.testing.assert_allclose(model.loglikelihood, expected, rtol=1e-12, atol=1e-12) + + +def test_inference_solve_errors_only_downgrade_true_singularity(monkeypatch): + import statgpu.inference._sandwich as sandwich + from statgpu.glm_core._squared import SquaredErrorLoss + + X = np.column_stack([np.ones(5), np.arange(5.0)]) + y = np.arange(5.0) + coef = np.array([0.0, 1.0]) + + def oom(*args, **kwargs): + raise RuntimeError("CUDA out of memory") + + monkeypatch.setattr(np.linalg, "solve", oom) + with pytest.raises(RuntimeError, match="out of memory"): + sandwich.compute_bread_avg(SquaredErrorLoss(), X, y, coef) + + assert sandwich._runtime_error_is_singular( + RuntimeError("matrix is singular") + ) + assert not sandwich._runtime_error_is_singular( + RuntimeError("CUDA out of memory") + ) + + +def test_glm_parameter_count_does_not_use_numpy_array_conversion(): + from pathlib import Path + + source = Path("statgpu/linear_model/_glm_base.py").read_text(encoding="utf-8") + block = source.split("# Parameter counts are backend-neutral", 1)[1].split( + "# ---- Store design/loss", 1 + )[0] + assert "self._params.shape[0]" in block + assert "np.asarray(self._params)" not in block + + +# PR87_WEIGHTED_HELPER_SINGLE_SOURCE_TESTS +def test_solver_utils_weighted_helper_delegates_without_silent_unweighting(monkeypatch): + from pathlib import Path + import statgpu.glm_core._fused as fused + import statgpu.glm_core._solver_utils as solver_utils + + sentinel = RuntimeError("weighted implementation failed") + + def fail(*args, **kwargs): + raise sentinel + + monkeypatch.setattr(fused, "_weighted_loss_and_grad", fail) + with pytest.raises(RuntimeError, match="weighted implementation failed"): + solver_utils._weighted_loss_and_grad( + object(), np.ones((2, 1)), np.ones(2), np.zeros(1), np.ones(2) + ) + + source = Path("statgpu/glm_core/_solver_utils.py").read_text(encoding="utf-8") + block = source.split( + "def _weighted_loss_and_grad(loss, X, y, coef, sample_weight):", 1 + )[1] + assert "_to_numpy(sample_weight)" not in block + assert "except TypeError" not in block + assert "statgpu.glm_core._fused" in block + + +# PR87_GLM_RESPONSE_DOMAIN_MATRIX_TESTS +@pytest.mark.parametrize("solver", ["irls", "fista", "newton", "lbfgs"]) +@pytest.mark.parametrize( + "family,bad_y,message", + [ + ("binomial", np.array([0.0, 1.0, -0.1, 0.5]), r"logistic response.*\[0, 1\]"), + ("binomial", np.array([0.0, 1.0, 1.1, 0.5]), r"logistic response.*\[0, 1\]"), + ("poisson", np.array([0.0, 1.0, -1.0, 2.0]), "poisson response.*non-negative"), + ("gamma", np.array([1.0, 2.0, 0.0, 3.0]), "gamma response.*strictly positive"), + ("inverse_gaussian", np.array([1.0, 2.0, -0.1, 3.0]), "inverse_gaussian response.*strictly positive"), + ("negative_binomial", np.array([0.0, 1.0, -1.0, 2.0]), "negative_binomial response.*non-negative"), + ("tweedie", np.array([0.0, 1.0, -0.1, 2.0]), "tweedie response.*non-negative"), + ], +) +def test_glm_response_domain_is_validated_before_every_solver(family, bad_y, message, solver): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.arange(8.0, dtype=np.float64).reshape(4, 2) + with pytest.raises(ValueError, match=message): + GeneralizedLinearModel( + family=family, + solver=solver, + C=0.0, + device="cpu", + compute_inference=False, + ).fit(X, bad_y) + + +def test_binomial_glm_accepts_fractional_responses_in_unit_interval(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array([[-1.0], [0.0], [1.0], [2.0], [3.0]], dtype=np.float64) + y = np.array([0.0, 0.2, 0.5, 0.8, 1.0], dtype=np.float64) + model = GeneralizedLinearModel( + family="binomial", solver="irls", C=0.0, + max_iter=100, device="cpu", compute_inference=False, + ).fit(X, y) + assert np.isfinite(model.coef_).all() + + +def test_formula_response_domain_validation_occurs_after_patsy_row_selection(): + pd = pytest.importorskip("pandas") + from statgpu.linear_model import GeneralizedLinearModel + + data = pd.DataFrame( + {"y": [0.0, 1.0, -2.0, 3.0], "x": [0.0, 1.0, np.nan, 3.0]} + ) + # The negative response belongs to the row Patsy removes. Retained rows + # are valid Poisson responses and must fit successfully. + model = GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ).fit(formula="y ~ x", data=data) + assert np.isfinite(model.coef_).all() + + data.loc[1, "y"] = -1.0 + with pytest.raises(ValueError, match="poisson response.*non-negative"): + GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ).fit(formula="y ~ x", data=data) + + +def test_direct_irls_solver_uses_loss_owned_response_validation(): + from statgpu.glm_core._family import Poisson + from statgpu.glm_core._irls import IRLSSolver + + with pytest.raises(ValueError, match="poisson response.*non-negative"): + IRLSSolver(Poisson()).fit( + np.ones((3, 1)), np.array([0.0, -1.0, 2.0]), backend="numpy" + ) + + +def test_torch_glm_response_domain_validation_stays_on_device(): + torch = _require_modern_torch_cuda() + from statgpu.linear_model import GeneralizedLinearModel + + X = torch.arange(8.0, dtype=torch.float64, device="cuda").reshape(4, 2) + y = torch.tensor([0.0, 1.0, -1.0, 2.0], dtype=torch.float64, device="cuda") + with pytest.raises(ValueError, match="poisson response.*non-negative"): + GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="torch", compute_inference=False, + ).fit(X, y) + assert X.is_cuda and y.is_cuda + + +def test_cupy_glm_response_domain_validation_stays_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.linear_model import GeneralizedLinearModel + + X = cp.arange(8.0, dtype=cp.float64).reshape(4, 2) + y = cp.asarray([1.0, 2.0, 0.0, 3.0], dtype=cp.float64) + with pytest.raises(ValueError, match="gamma response.*strictly positive"): + GeneralizedLinearModel( + family="gamma", solver="irls", C=0.0, + device="cuda", compute_inference=False, + ).fit(X, y) + assert isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) + + +# PR87_PENALIZED_GLM_RESPONSE_DOMAIN_TESTS +@pytest.mark.parametrize( + "loss,bad_y,message", + [ + ("logistic", [0.0, 1.0, 1.2, 0.5], r"logistic response.*\[0, 1\]"), + ("poisson", [0.0, 1.0, -1.0, 2.0], "poisson response.*non-negative"), + ("gamma", [1.0, 2.0, 0.0, 3.0], "gamma response.*strictly positive"), + ("inverse_gaussian", [1.0, 2.0, -0.1, 3.0], "inverse_gaussian response.*strictly positive"), + ("negative_binomial", [0.0, 1.0, -1.0, 2.0], "negative_binomial response.*non-negative"), + ("tweedie", [0.0, 1.0, -0.1, 2.0], "tweedie response.*non-negative"), + ], +) +def test_penalized_glm_validates_array_like_response_before_solver(loss, bad_y, message): + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + X = np.arange(8.0, dtype=np.float64).reshape(4, 2) + with pytest.raises(ValueError, match=message): + PenalizedGeneralizedLinearModel( + loss=loss, + penalty="l2", + alpha=0.1, + solver="fista", + device="cpu", + compute_inference=False, + ).fit(X, bad_y) + + +def test_penalized_formula_response_validation_uses_retained_rows(): + pd = pytest.importorskip("pandas") + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + data = pd.DataFrame( + {"y": [0.0, 1.0, -3.0, 2.0, 4.0], "x": [0.0, 1.0, np.nan, 3.0, 4.0]} + ) + model = PenalizedGeneralizedLinearModel( + loss="poisson", penalty="l2", alpha=0.1, + solver="fista", max_iter=100, device="cpu", + compute_inference=False, + ).fit(formula="y ~ x", data=data) + assert np.isfinite(model.coef_).all() + + data.loc[1, "y"] = -1.0 + with pytest.raises(ValueError, match="poisson response.*non-negative"): + PenalizedGeneralizedLinearModel( + loss="poisson", penalty="l2", alpha=0.1, + solver="fista", device="cpu", compute_inference=False, + ).fit(formula="y ~ x", data=data) + + +def test_penalized_glm_cv_rejects_invalid_response_before_folds_and_resets_state(monkeypatch): + from statgpu.linear_model.penalized import PenalizedGLM_CV + + X = np.arange(12.0, dtype=np.float64).reshape(6, 2) + y = np.array([0.0, 1.0, 2.0, -1.0, 3.0, 4.0]) + model = PenalizedGLM_CV( + loss="poisson", penalty="l2", alpha_grid=[0.1, 1.0], + cv=2, device="cpu", max_iter=20, + ) + fold_called = False + + def forbidden(*args, **kwargs): + nonlocal fold_called + fold_called = True + raise AssertionError("CV folds must not run for an invalid response") + + monkeypatch.setattr(model, "_fit_standard", forbidden) + model._fitted = True + model.alpha_ = 99.0 + model.coef_ = np.ones(2) + with pytest.raises(ValueError, match="poisson response.*non-negative"): + model.fit(X, y) + assert not fold_called + assert model._fitted is False + assert model.alpha_ is None + assert model.coef_ is None + + +def test_torch_penalized_glm_response_validation_stays_on_device(): + torch = _require_modern_torch_cuda() + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + X = torch.arange(8.0, dtype=torch.float64, device="cuda").reshape(4, 2) + y = torch.tensor([0.0, 1.0, -1.0, 2.0], dtype=torch.float64, device="cuda") + with pytest.raises(ValueError, match="poisson response.*non-negative"): + PenalizedGeneralizedLinearModel( + loss="poisson", penalty="l2", alpha=0.1, + solver="fista", device="torch", compute_inference=False, + ).fit(X, y) + assert X.is_cuda and y.is_cuda + + +def test_cupy_penalized_glm_response_validation_stays_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + X = cp.arange(8.0, dtype=cp.float64).reshape(4, 2) + y = cp.asarray([1.0, 2.0, 0.0, 3.0], dtype=cp.float64) + with pytest.raises(ValueError, match="gamma response.*strictly positive"): + PenalizedGeneralizedLinearModel( + loss="gamma", penalty="l2", alpha=0.1, + solver="fista", device="cuda", compute_inference=False, + ).fit(X, y) + assert isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) + + +# PR87_GLM_RESPONSE_SHAPE_CONTRACT_TESTS +@pytest.mark.parametrize("kind", ["glm", "penalized", "cv"]) +def test_scalar_glm_rejects_multicolumn_response_before_solver(kind, monkeypatch): + from statgpu.linear_model import GeneralizedLinearModel + from statgpu.linear_model.penalized import ( + PenalizedGeneralizedLinearModel, + PenalizedGLM_CV, + ) + + X = np.arange(12.0, dtype=np.float64).reshape(6, 2) + y = np.ones((6, 2), dtype=np.float64) + if kind == "glm": + model = GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ) + elif kind == "penalized": + model = PenalizedGeneralizedLinearModel( + loss="poisson", penalty="l2", alpha=0.1, + solver="fista", device="cpu", compute_inference=False, + ) + else: + model = PenalizedGLM_CV( + loss="poisson", penalty="l2", alpha_grid=[0.1, 1.0], + cv=2, device="cpu", max_iter=20, + ) + monkeypatch.setattr( + model, + "_fit_standard", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("CV must not start for multicolumn y") + ), + ) + with pytest.raises(ValueError, match="response must be one-dimensional"): + model.fit(X, y) + + +@pytest.mark.parametrize("kind", ["glm", "penalized", "cv"]) +def test_scalar_glm_rejects_response_length_mismatch_before_solver(kind, monkeypatch): + from statgpu.linear_model import GeneralizedLinearModel + from statgpu.linear_model.penalized import ( + PenalizedGeneralizedLinearModel, + PenalizedGLM_CV, + ) + + X = np.arange(12.0, dtype=np.float64).reshape(6, 2) + y = np.ones(5, dtype=np.float64) + if kind == "glm": + model = GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ) + elif kind == "penalized": + model = PenalizedGeneralizedLinearModel( + loss="poisson", penalty="l2", alpha=0.1, + solver="fista", device="cpu", compute_inference=False, + ) + else: + model = PenalizedGLM_CV( + loss="poisson", penalty="l2", alpha_grid=[0.1, 1.0], + cv=2, device="cpu", max_iter=20, + ) + monkeypatch.setattr( + model, + "_fit_standard", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("CV must not start for length mismatch") + ), + ) + with pytest.raises(ValueError, match=r"Response length must match (?:X\.shape\[0\]|the number of X rows)"): + model.fit(X, y) + + +def test_scalar_glm_accepts_single_column_response_consistently(): + from statgpu.linear_model import GeneralizedLinearModel + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + X = np.array([[-1.0], [0.0], [1.0], [2.0], [3.0]], dtype=np.float64) + y = np.array([[0.0], [1.0], [1.0], [2.0], [3.0]], dtype=np.float64) + glm = GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ).fit(X, y) + penalized = PenalizedGeneralizedLinearModel( + loss="poisson", penalty="l2", alpha=0.1, + solver="fista", max_iter=100, device="cpu", + compute_inference=False, + ).fit(X, y) + assert np.isfinite(glm.coef_).all() + assert np.isfinite(penalized.coef_).all() + + +def test_direct_irls_rejects_multicolumn_and_length_mismatch(): + from statgpu.glm_core._family import Poisson + from statgpu.glm_core._irls import IRLSSolver + + X = np.ones((4, 1), dtype=np.float64) + with pytest.raises(ValueError, match="response must be one-dimensional"): + IRLSSolver(Poisson()).fit(X, np.ones((4, 2)), backend="numpy") + with pytest.raises(ValueError, match=r"Response length must match (?:X\.shape\[0\]|the number of X rows)"): + IRLSSolver(Poisson()).fit(X, np.ones(3), backend="numpy") + + +def test_glm_rejects_nonnumeric_response_with_public_value_error(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.ones((3, 1), dtype=np.float64) + with pytest.raises(ValueError, match="real numeric values"): + GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ).fit(X, np.array(["0", "one", "2"], dtype=object)) + + +def test_torch_glm_multicolumn_response_rejected_on_device(): + torch = _require_modern_torch_cuda() + from statgpu.linear_model import GeneralizedLinearModel + + X = torch.ones((4, 2), dtype=torch.float64, device="cuda") + y = torch.ones((4, 2), dtype=torch.float64, device="cuda") + with pytest.raises(ValueError, match="response must be one-dimensional"): + GeneralizedLinearModel( + family="poisson", solver="irls", C=0.0, + device="torch", compute_inference=False, + ).fit(X, y) + assert X.is_cuda and y.is_cuda + + +def test_cupy_penalized_glm_multicolumn_response_rejected_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + X = cp.ones((4, 2), dtype=cp.float64) + y = cp.ones((4, 2), dtype=cp.float64) + with pytest.raises(ValueError, match="response must be one-dimensional"): + PenalizedGeneralizedLinearModel( + loss="poisson", penalty="l2", alpha=0.1, + solver="fista", device="cuda", compute_inference=False, + ).fit(X, y) + assert isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) + + +# PR87_GLM_LIST_DESIGN_LENGTH_TEST +def test_penalized_glm_cv_response_length_check_preserves_list_design_input(monkeypatch): + from statgpu.linear_model.penalized import PenalizedGLM_CV + + X = [[0.0, 1.0], [1.0, 2.0], [2.0, 3.0], [3.0, 4.0]] + y = [0.0, 1.0, 2.0, 3.0] + model = PenalizedGLM_CV( + loss="poisson", penalty="l2", alpha_grid=[0.1], + cv=2, device="cpu", max_iter=10, + ) + seen = {} + + def capture(X_arg, y_arg, sample_weight=None): + seen["X"] = X_arg + seen["y"] = y_arg + return model + + monkeypatch.setattr(model, "_fit_standard", capture) + result = model.fit(X, y) + assert result is model + assert seen["X"] is X + assert isinstance(seen["y"], np.ndarray) + assert seen["y"].shape == (len(X),) + + +# PR87_GLM_REAL_NONEMPTY_RESPONSE_TESTS +@pytest.mark.parametrize("kind", ["glm", "penalized", "cv"]) +@pytest.mark.parametrize( + "bad_y", + [ + np.array([0.0 + 0.0j, 1.0 + 0.0j, 2.0 + 1.0j]), + np.array(["2026-01-01", "2026-01-02", "2026-01-03"], dtype="datetime64[D]"), + np.array(["0", "1", "two"], dtype=object), + ], +) +def test_glm_entrypoints_reject_nonreal_response_with_value_error(kind, bad_y, monkeypatch): + from statgpu.linear_model import GeneralizedLinearModel + from statgpu.linear_model.penalized import ( + PenalizedGeneralizedLinearModel, + PenalizedGLM_CV, + ) + + X = np.arange(6.0, dtype=np.float64).reshape(3, 2) + if kind == "glm": + model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ) + elif kind == "penalized": + model = PenalizedGeneralizedLinearModel( + loss="squared_error", penalty="l2", alpha=0.1, + solver="fista", device="cpu", compute_inference=False, + ) + else: + model = PenalizedGLM_CV( + loss="squared_error", penalty="l2", alpha_grid=[0.1], + cv=2, device="cpu", max_iter=10, + ) + monkeypatch.setattr( + model, + "_fit_standard", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("CV must not start for a nonreal response") + ), + ) + with pytest.raises(ValueError, match="response must contain real numeric"): + model.fit(X, bad_y) + + +@pytest.mark.parametrize("kind", ["glm", "penalized", "cv"]) +def test_glm_entrypoints_reject_empty_response_before_solver(kind, monkeypatch): + from statgpu.linear_model import GeneralizedLinearModel + from statgpu.linear_model.penalized import ( + PenalizedGeneralizedLinearModel, + PenalizedGLM_CV, + ) + + X = np.empty((0, 2), dtype=np.float64) + y = np.empty(0, dtype=np.float64) + if kind == "glm": + model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ) + elif kind == "penalized": + model = PenalizedGeneralizedLinearModel( + loss="squared_error", penalty="l2", alpha=0.1, + solver="fista", device="cpu", compute_inference=False, + ) + else: + model = PenalizedGLM_CV( + loss="squared_error", penalty="l2", alpha_grid=[0.1], + cv=2, device="cpu", max_iter=10, + ) + monkeypatch.setattr( + model, + "_fit_standard", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("CV must not start for an empty response") + ), + ) + with pytest.raises(ValueError, match="at least one observation"): + model.fit(X, y) + + +def test_direct_irls_rejects_complex_and_empty_response(): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + with pytest.raises(ValueError, match="real numeric"): + IRLSSolver(Gaussian()).fit( + np.ones((2, 1)), np.array([1.0 + 0.0j, 2.0 + 1.0j]), + backend="numpy", + ) + with pytest.raises(ValueError, match="at least one observation"): + IRLSSolver(Gaussian()).fit( + np.empty((0, 1)), np.empty(0), backend="numpy" + ) + + +def test_torch_glm_complex_response_rejected_on_device(): + torch = _require_modern_torch_cuda() + from statgpu.linear_model import GeneralizedLinearModel + + X = torch.ones((3, 1), dtype=torch.float64, device="cuda") + y = torch.tensor([1.0 + 0.0j, 2.0 + 0.0j, 3.0 + 1.0j], device="cuda") + with pytest.raises(ValueError, match="real numeric"): + GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="torch", compute_inference=False, + ).fit(X, y) + assert X.is_cuda and y.is_cuda + + +def test_cupy_penalized_glm_complex_response_rejected_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + X = cp.ones((3, 1), dtype=cp.float64) + y = cp.asarray([1.0 + 0.0j, 2.0 + 0.0j, 3.0 + 1.0j]) + with pytest.raises(ValueError, match="real numeric"): + PenalizedGeneralizedLinearModel( + loss="squared_error", penalty="l2", alpha=0.1, + solver="fista", device="cuda", compute_inference=False, + ).fit(X, y) + assert isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) + + +# PR87_GLM_DESIGN_AND_WEIGHT_CONTRACT_TESTS +@pytest.mark.parametrize("kind", ["glm", "penalized", "cv"]) +@pytest.mark.parametrize( + "bad_X,message", + [ + (np.ones(4), "two-dimensional design matrix"), + (np.ones((2, 2, 1)), "two-dimensional design matrix"), + (np.empty((0, 2)), "at least one observation"), + (np.ones((4, 2), dtype=np.complex128), "real numeric values"), + (np.array([["a"], ["b"], ["c"], ["d"]], dtype=object), "real numeric values"), + ], +) +def test_glm_entrypoints_reject_invalid_design_before_solver(kind, bad_X, message, monkeypatch): + from statgpu.linear_model import GeneralizedLinearModel + from statgpu.linear_model.penalized import ( + PenalizedGeneralizedLinearModel, + PenalizedGLM_CV, + ) + + y = np.arange(len(bad_X) if getattr(bad_X, "ndim", 0) else 4, dtype=float) + if bad_X.shape[0] == 0: + y = np.empty(0) + if kind == "glm": + model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ) + elif kind == "penalized": + model = PenalizedGeneralizedLinearModel( + loss="squared_error", penalty="l2", alpha=0.1, + solver="fista", device="cpu", compute_inference=False, + ) + else: + model = PenalizedGLM_CV( + loss="squared_error", penalty="l2", alpha_grid=[0.1], + cv=2, device="cpu", max_iter=10, + ) + monkeypatch.setattr( + model, "_fit_standard", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("CV must not start for invalid X") + ), + ) + with pytest.raises(ValueError, match=message): + model.fit(bad_X, y) + + +def test_glm_intercept_only_zero_feature_design_is_supported(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.empty((5, 0), dtype=np.float64) + y = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ).fit(X, y) + assert model.coef_.shape == (0,) + np.testing.assert_allclose(model.intercept_, np.mean(y)) + + +@pytest.mark.parametrize("kind", ["glm", "penalized", "cv"]) +@pytest.mark.parametrize( + "bad_weight,message", + [ + (np.ones((4, 1)), "one-dimensional"), + (np.ones(3), "length n_samples"), + (np.array([1.0, 1.0j, 1.0, 1.0]), "real numeric values"), + (np.array(["1", "1", "1", "1"], dtype=object), "real numeric values"), + ], +) +def test_glm_entrypoints_reject_invalid_sample_weight_consistently(kind, bad_weight, message, monkeypatch): + from statgpu.linear_model import GeneralizedLinearModel + from statgpu.linear_model.penalized import ( + PenalizedGeneralizedLinearModel, + PenalizedGLM_CV, + ) + + X = np.arange(8.0).reshape(4, 2) + y = np.arange(4.0) + if kind == "glm": + model = GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="cpu", compute_inference=False, + ) + elif kind == "penalized": + model = PenalizedGeneralizedLinearModel( + loss="squared_error", penalty="l2", alpha=0.1, + solver="fista", device="cpu", compute_inference=False, + ) + else: + model = PenalizedGLM_CV( + loss="squared_error", penalty="l2", alpha_grid=[0.1], + cv=2, device="cpu", max_iter=10, + ) + monkeypatch.setattr( + model, "_fit_standard", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("CV must not start for invalid weights") + ), + ) + with pytest.raises(ValueError, match=message): + model.fit(X, y, sample_weight=bad_weight) + + +def test_direct_irls_validates_design_and_weights_before_backend_math(): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + with pytest.raises(ValueError, match="two-dimensional design matrix"): + IRLSSolver(Gaussian()).fit(np.ones(4), np.ones(4), backend="numpy") + with pytest.raises(ValueError, match="real numeric values"): + IRLSSolver(Gaussian()).fit( + np.ones((4, 1)), np.ones(4), + sample_weight=np.array([1.0, 1.0j, 1.0, 1.0]), + backend="numpy", + ) + + +def test_penalized_cv_design_validation_preserves_list_X_identity(monkeypatch): + from statgpu.linear_model.penalized import PenalizedGLM_CV + + X = [[0.0, 1.0], [1.0, 2.0], [2.0, 3.0], [3.0, 4.0]] + y = [0.0, 1.0, 2.0, 3.0] + model = PenalizedGLM_CV( + loss="squared_error", penalty="l2", alpha_grid=[0.1], + cv=2, device="cpu", max_iter=10, + ) + seen = {} + def capture(X_arg, y_arg, sample_weight=None): + seen["X"] = X_arg + seen["y"] = y_arg + return model + monkeypatch.setattr(model, "_fit_standard", capture) + model.fit(X, y) + assert seen["X"] is X + assert isinstance(seen["y"], np.ndarray) + + +def test_torch_glm_complex_design_and_weight_rejected_on_device(): + torch = _require_modern_torch_cuda() + from statgpu.linear_model import GeneralizedLinearModel + + X_complex = torch.ones((4, 2), dtype=torch.complex128, device="cuda") + y = torch.arange(4.0, dtype=torch.float64, device="cuda") + with pytest.raises(ValueError, match="real numeric values"): + GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="torch", compute_inference=False, + ).fit(X_complex, y) + + X = torch.ones((4, 2), dtype=torch.float64, device="cuda") + weight = torch.ones(4, dtype=torch.complex128, device="cuda") + with pytest.raises(ValueError, match="real numeric values"): + GeneralizedLinearModel( + family="gaussian", solver="irls", C=0.0, + device="torch", compute_inference=False, + ).fit(X, y, sample_weight=weight) + assert X_complex.is_cuda and weight.is_cuda + + +def test_cupy_penalized_glm_complex_design_and_weight_rejected_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.linear_model.penalized import PenalizedGeneralizedLinearModel + + y = cp.arange(4.0, dtype=cp.float64) + X_complex = cp.ones((4, 2), dtype=cp.complex128) + with pytest.raises(ValueError, match="real numeric values"): + PenalizedGeneralizedLinearModel( + loss="squared_error", penalty="l2", alpha=0.1, + solver="fista", device="cuda", compute_inference=False, + ).fit(X_complex, y) + + X = cp.ones((4, 2), dtype=cp.float64) + weight = cp.ones(4, dtype=cp.complex128) + with pytest.raises(ValueError, match="real numeric values"): + PenalizedGeneralizedLinearModel( + loss="squared_error", penalty="l2", alpha=0.1, + solver="fista", device="cuda", compute_inference=False, + ).fit(X, y, sample_weight=weight) + assert isinstance(X_complex, cp.ndarray) and isinstance(weight, cp.ndarray) + +# PR87_REVIEW_FIX_V37 +def test_direct_fista_validates_weight_length_before_lipschitz(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import fista_solver + + class GuardedSquaredError(SquaredErrorLoss): + def lipschitz(self, *args, **kwargs): + raise AssertionError("lipschitz must not run before weight validation") + + X = np.ones((3, 1), dtype=np.float64) + y = np.arange(3.0) + with pytest.raises(ValueError, match="length n_samples"): + fista_solver( + GuardedSquaredError(), + get_penalty("l2", alpha=0.0), + X, + y, + sample_weight=np.ones(2), + ) + + +def test_solver_weight_validation_does_not_copy_torch_tensor(monkeypatch): + torch = pytest.importorskip("torch") + import statgpu.solvers._utils as solver_utils + + def forbidden(*args, **kwargs): + raise AssertionError("sample_weight must not be copied through _to_numpy") + + monkeypatch.setattr(solver_utils, "_to_numpy", forbidden) + weights = torch.tensor([1.0, 2.0, 3.0], dtype=torch.float64) + solver_utils._validate_sample_weight(weights, 3) + assert torch.equal(weights, torch.tensor([1.0, 2.0, 3.0], dtype=torch.float64)) + + +def test_penalized_cv_uniform_weight_check_does_not_copy_torch_tensor(monkeypatch): + torch = pytest.importorskip("torch") + import statgpu.linear_model.penalized._penalized_cv as penalized_cv + + def forbidden(*args, **kwargs): + raise AssertionError("uniform-weight check must stay backend-native") + + monkeypatch.setattr(penalized_cv, "_to_numpy", forbidden) + assert penalized_cv._is_uniform_weight(torch.ones(4, dtype=torch.float64)) + assert not penalized_cv._is_uniform_weight( + torch.tensor([1.0, 1.0, 2.0, 1.0], dtype=torch.float64) + ) + + +def test_glm_weight_validation_rejects_overflowing_total(): + from statgpu.glm_core._validation import validate_glm_sample_weight + from statgpu.solvers._utils import _validate_sample_weight + + weights = np.array([np.finfo(np.float64).max, np.finfo(np.float64).max]) + with np.errstate(over="ignore"): + with pytest.raises(ValueError, match="finite positive sum"): + validate_glm_sample_weight(weights, 2) + with pytest.raises(ValueError, match="finite positive sum"): + _validate_sample_weight(weights, 2) + + +def test_glm_hc1_analytic_weight_diagnostics_are_scale_invariant(): + from statgpu.linear_model import GeneralizedLinearModel + + X = np.array([[-1.0], [0.0], [2.0], [4.0], [5.0]], dtype=np.float64) + y = np.array([-0.4, 0.5, 2.2, 5.1, 5.8], dtype=np.float64) + weights = np.array([0.5, 1.5, 2.0, 4.0, 3.0], dtype=np.float64) + + def fit(current_weights): + return GeneralizedLinearModel( + family="gaussian", + solver="irls", + C=0.0, + max_iter=100, + tol=1e-12, + device="cpu", + compute_inference=True, + cov_type="hc1", + ).fit(X, y, sample_weight=current_weights) + + weighted = fit(weights) + scaled = fit(29.0 * weights) + np.testing.assert_allclose(weighted._bse, scaled._bse, rtol=1e-11, atol=1e-11) + +# PR87_REVIEW_FIX_V38 +def test_solver_weight_reduction_is_computed_once(): + from pathlib import Path + + source = Path("statgpu/solvers/_utils.py").read_text(encoding="utf-8") + block = source.split("def _validated_sample_weight", 1)[1].split( + "def _validate_uniform_sample_weight", 1 + )[0] + assert block.count("xp.sum(values)") == 1 + + +def test_direct_fista_bb_validates_weight_length_before_lipschitz(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import fista_bb_solver + + class GuardedSquaredError(SquaredErrorLoss): + def lipschitz(self, *args, **kwargs): + raise AssertionError("lipschitz must not run before weight validation") + + X = np.ones((3, 1), dtype=np.float64) + y = np.arange(3.0) + with pytest.raises(ValueError, match="length n_samples"): + fista_bb_solver( + GuardedSquaredError(), + get_penalty("l1", alpha=0.1), + X, + y, + sample_weight=np.ones(2), + ) + + +def test_newton_does_not_mask_non_singular_solve_errors(monkeypatch): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import newton_solver + + X = np.column_stack([np.ones(4), np.arange(4.0)]) + y = np.arange(4.0) + + def oom(*args, **kwargs): + raise RuntimeError("CUDA out of memory") + + def forbidden(*args, **kwargs): + raise AssertionError("lstsq must not mask infrastructure failures") + + monkeypatch.setattr(np.linalg, "solve", oom) + monkeypatch.setattr(np.linalg, "lstsq", forbidden) + with pytest.raises(RuntimeError, match="out of memory"): + newton_solver( + SquaredErrorLoss(), get_penalty("l2", alpha=0.1), X, y, max_iter=2 + ) + + +def test_newton_validates_weights_before_constant_hessian(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import newton_solver + + class GuardedSquaredError(SquaredErrorLoss): + def hessian(self, *args, **kwargs): + raise AssertionError("hessian must not run before weight validation") + + with pytest.raises(ValueError, match="length n_samples"): + newton_solver( + GuardedSquaredError(), + get_penalty("l2", alpha=0.1), + np.ones((3, 1)), + np.ones(3), + sample_weight=np.ones(2), + ) + + +def test_admm_does_not_mask_non_singular_cholesky_errors(monkeypatch): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import admm_solver + + def oom(*args, **kwargs): + raise RuntimeError("CUDA out of memory") + + monkeypatch.setattr(np.linalg, "cholesky", oom) + with pytest.raises(RuntimeError, match="out of memory"): + admm_solver( + SquaredErrorLoss(), + get_penalty("l1", alpha=0.1), + np.ones((4, 1)), + np.arange(4.0), + max_iter=2, + ) + + +def test_proximal_newton_validates_weight_length_before_curvature(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + class GuardedSquaredError(SquaredErrorLoss): + def fused_gradient_and_hessian(self, *args, **kwargs): + raise AssertionError("curvature must not run before weight validation") + + with pytest.raises(ValueError, match="length n_samples"): + proximal_newton_solver( + GuardedSquaredError(), + get_penalty("l1", alpha=0.1), + np.ones((3, 1)), + np.ones(3), + sample_weight=np.ones(2), + ) + + +def test_proximal_newton_preserves_torch_float32_dtype(): + torch = pytest.importorskip("torch") + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + X = torch.tensor([[1.0], [2.0], [3.0], [4.0]], dtype=torch.float32) + y = torch.tensor([1.0, 2.0, 3.0, 4.0], dtype=torch.float32) + coef, _ = proximal_newton_solver( + SquaredErrorLoss(), + get_penalty("l1", alpha=0.01), + X, + y, + max_iter=3, + ) + assert coef.dtype == torch.float32 + assert bool(torch.all(torch.isfinite(coef)).item()) + + +def test_lbfgs_steepest_descent_uses_squared_norm_slope(): + from pathlib import Path + + lbfgs = Path("statgpu/solvers/_lbfgs.py").read_text(encoding="utf-8") + lbfgsb = Path("statgpu/solvers/_lbfgs_b.py").read_text(encoding="utf-8") + assert "gdd = -gn * gn" in lbfgs + assert "direction = -proj_grad" in lbfgsb + assert "gdd = -pg_norm * pg_norm" in lbfgsb + + +def test_lbfgsb_torch_bounds_and_projection_are_backend_native(): + torch = pytest.importorskip("torch") + from statgpu.solvers._lbfgs_b import _clip_to_bounds, _projected_gradient + + params = torch.tensor([-2.0, 0.5, 3.0], dtype=torch.float32) + lb = torch.tensor([-1.0, 0.0, 0.0], dtype=torch.float32) + ub = torch.tensor([1.0, 1.0, 2.0], dtype=torch.float32) + clipped = _clip_to_bounds(params, lb, ub, "torch") + torch.testing.assert_close(clipped, torch.tensor([-1.0, 0.5, 2.0])) + grad = torch.tensor([1.0, -1.0, -1.0], dtype=torch.float32) + projected = _projected_gradient(grad, clipped, lb, ub, "torch") + torch.testing.assert_close(projected, torch.tensor([0.0, -1.0, 0.0])) + + +def test_lbfgsb_cupy_bounds_and_projection_are_backend_native(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires a working CuPy CUDA backend") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.solvers._lbfgs_b import _clip_to_bounds, _projected_gradient + + params = cp.asarray([-2.0, 0.5, 3.0], dtype=cp.float32) + lb = cp.asarray([-1.0, 0.0, 0.0], dtype=cp.float32) + ub = cp.asarray([1.0, 1.0, 2.0], dtype=cp.float32) + clipped = _clip_to_bounds(params, lb, ub, "cupy") + cp.testing.assert_allclose(clipped, cp.asarray([-1.0, 0.5, 2.0])) + grad = cp.asarray([1.0, -1.0, -1.0], dtype=cp.float32) + projected = _projected_gradient(grad, clipped, lb, ub, "cupy") + cp.testing.assert_allclose(projected, cp.asarray([0.0, -1.0, 0.0])) + +# PR87_REVIEW_FIX_V39 +def test_admm_legitimate_cholesky_failure_initializes_iterative_fallback(monkeypatch): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import admm_solver + + def not_positive_definite(*args, **kwargs): + raise np.linalg.LinAlgError("not positive definite") + + monkeypatch.setattr(np.linalg, "cholesky", not_positive_definite) + coef, n_iter = admm_solver( + SquaredErrorLoss(), + get_penalty("l1", alpha=0.05), + np.column_stack([np.ones(6), np.arange(6.0)]), + np.arange(6.0), + max_iter=2, + ) + assert n_iter >= 0 + assert np.all(np.isfinite(coef)) + + +def test_proximal_newton_max_iter_zero_returns_initialized_coefficients(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + init = np.array([0.25, -0.5]) + coef, n_iter = proximal_newton_solver( + SquaredErrorLoss(), + get_penalty("l2", alpha=0.1), + np.column_stack([np.ones(4), np.arange(4.0)]), + np.arange(4.0), + init_coef=init, + max_iter=0, + ) + np.testing.assert_allclose(coef, init) + assert n_iter == 0 + + +def test_proximal_newton_l2_matches_declared_closed_form_objective(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + X = np.array( + [[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 4.0]], + dtype=np.float64, + ) + y = np.array([0.2, 1.0, 2.1, 2.7, 5.3], dtype=np.float64) + alpha = 0.35 + expected = np.linalg.solve( + X.T @ X / X.shape[0] + alpha * np.eye(X.shape[1]), + X.T @ y / X.shape[0], + ) + coef, _ = proximal_newton_solver( + SquaredErrorLoss(), + get_penalty("l2", alpha=alpha), + X, + y, + max_iter=20, + tol=1e-12, + ) + np.testing.assert_allclose(coef, expected, rtol=1e-8, atol=1e-9) + + +def test_proximal_newton_nonsmooth_fallback_is_explicit_and_objective_preserving(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import fista_solver, proximal_newton_solver + + X = np.array([[1.0], [2.0], [3.0], [4.0]], dtype=np.float64) + y = np.array([1.0, 1.8, 3.2, 3.9], dtype=np.float64) + penalty = get_penalty("l1", alpha=0.05) + with pytest.warns(RuntimeWarning, match="delegates non-smooth penalties"): + delegated = proximal_newton_solver( + SquaredErrorLoss(), penalty, X, y, max_iter=40, tol=1e-10 + ) + direct = fista_solver( + SquaredErrorLoss(), penalty, X, y, max_iter=40, tol=1e-10 + ) + np.testing.assert_allclose(delegated[0], direct[0], rtol=0.0, atol=0.0) + assert delegated[1] == direct[1] + + +def test_fista_lla_requires_explicit_metric_proximal_newton_capability(): + from pathlib import Path + + source = Path("statgpu/solvers/_fista_lla.py").read_text(encoding="utf-8") + assert "_supports_metric_proximal_newton" in source + + +def test_lbfgsb_projects_quasi_newton_direction_and_rejects_nan_bounds(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import lbfgs_b_solver + from statgpu.solvers._lbfgs_b import _project_direction + + params = np.array([-1.0, 0.5, 2.0]) + lb = np.array([-1.0, 0.0, 0.0]) + ub = np.array([1.0, 1.0, 2.0]) + direction = np.array([-2.0, 0.25, 3.0]) + np.testing.assert_allclose( + _project_direction(direction, params, lb, ub, "numpy"), + np.array([0.0, 0.25, 0.0]), + ) + + with pytest.raises(ValueError, match="must not contain NaN"): + lbfgs_b_solver( + SquaredErrorLoss(), + get_penalty("l2", alpha=0.1), + np.ones((3, 1)), + np.ones(3), + lower_bounds=np.array([np.nan]), + upper_bounds=np.array([1.0]), + ) + +# PR87_REVIEW_FIX_V40 +import warnings + + +def _run_warm_start_solver_matrix(X, y, init): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import ( + admm_solver, + fista_bb_solver, + fista_solver, + lbfgs_b_solver, + lbfgs_solver, + newton_solver, + proximal_newton_solver, + ) + + loss = SquaredErrorLoss() + l2 = get_penalty("l2", alpha=0.05) + l1 = get_penalty("l1", alpha=0.05) + return { + "fista": fista_solver( + loss, l1, X, y, init_coef=init, max_iter=2, tol=1e-8 + )[0], + "fista_bb": fista_bb_solver( + loss, l1, X, y, init_coef=init, max_iter=2, tol=1e-8 + )[0], + "newton": newton_solver( + loss, l2, X, y, init_coef=init, max_iter=2, tol=1e-8 + )[0], + "proximal_newton": proximal_newton_solver( + loss, l2, X, y, init_coef=init, max_iter=2, tol=1e-8 + )[0], + "lbfgs": lbfgs_solver( + loss, l2, X, y, init_coef=init, max_iter=2, tol=1e-8 + )[0], + "lbfgs_b": lbfgs_b_solver( + loss, + l2, + X, + y, + init_coef=init, + lower_bounds=np.full(X.shape[1], -10.0), + upper_bounds=np.full(X.shape[1], 10.0), + max_iter=2, + tol=1e-8, + )[0], + "admm": admm_solver( + loss, l1, X, y, init_coef=init, max_iter=2, tol=1e-8 + )[0], + } + + +def test_solver_numpy_warm_starts_follow_torch_cpu_backend_dtype(): + torch = pytest.importorskip("torch") + + X = torch.tensor( + [[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]], + dtype=torch.float32, + ) + y = torch.tensor([0.0, 1.0, 2.0, 3.0], dtype=torch.float32) + init = np.array([0.2, -0.1], dtype=np.float64) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + outputs = _run_warm_start_solver_matrix(X, y, init) + for name, coef in outputs.items(): + assert isinstance(coef, torch.Tensor), name + assert coef.device == X.device, name + assert coef.dtype == X.dtype, name + assert bool(torch.all(torch.isfinite(coef)).item()), name + + +def test_torch_cuda_solver_numpy_warm_starts_stay_on_device(): + torch = pytest.importorskip("torch") + if not torch.cuda.is_available(): + pytest.skip("requires physical CUDA") + + X = torch.tensor( + [[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]], + dtype=torch.float64, + device="cuda", + ) + y = torch.tensor([0.0, 1.0, 2.0, 3.0], dtype=torch.float64, device="cuda") + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + outputs = _run_warm_start_solver_matrix( + X, y, np.array([0.2, -0.1], dtype=np.float64) + ) + for name, coef in outputs.items(): + assert coef.device.type == "cuda", name + assert coef.dtype == X.dtype, name + assert bool(torch.all(torch.isfinite(coef)).item()), name + + +def test_cupy_solver_numpy_warm_starts_stay_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires physical CUDA") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + + X = cp.asarray( + [[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]], + dtype=cp.float64, + ) + y = cp.asarray([0.0, 1.0, 2.0, 3.0], dtype=cp.float64) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + outputs = _run_warm_start_solver_matrix( + X, y, np.array([0.2, -0.1], dtype=np.float64) + ) + for name, coef in outputs.items(): + assert isinstance(coef, cp.ndarray), name + assert coef.dtype == X.dtype, name + assert bool(cp.all(cp.isfinite(coef)).item()), name + + +def test_proximal_newton_none_penalty_has_no_spurious_warning(): + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.solvers import proximal_newton_solver + + X = np.column_stack([np.ones(4), np.arange(4.0)]) + y = np.arange(4.0) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + coef, _ = proximal_newton_solver( + SquaredErrorLoss(), None, X, y, max_iter=2 + ) + assert np.all(np.isfinite(coef)) + assert not any("has no value" in str(item.message) for item in caught) + +# PR87_REVIEW_FIX_V41 +def test_smooth_solvers_reject_elasticnet_before_numerical_work(): + from statgpu.penalties import get_penalty + from statgpu.solvers import lbfgs_b_solver, lbfgs_solver, newton_solver + + class GuardedLoss: + def preprocess(self, *args, **kwargs): + raise AssertionError("penalty validation must precede preprocessing") + + penalty = get_penalty("elasticnet", alpha=0.2, l1_ratio=0.5) + X = np.ones((3, 1), dtype=np.float64) + y = np.ones(3, dtype=np.float64) + for solver in (newton_solver, lbfgs_solver, lbfgs_b_solver): + with pytest.raises(ValueError, match="supports only l2/none"): + solver(GuardedLoss(), penalty, X, y) + +# PR87_REVIEW_FIX_V43 +def test_solver_weight_validation_does_not_mask_runtime_failures(monkeypatch): + import statgpu.solvers._utils as solver_utils + + class RuntimeFailingXP: + @staticmethod + def all(value): + return value + + @staticmethod + def isfinite(values): + raise RuntimeError("CUDA out of memory") + + monkeypatch.setattr( + solver_utils, + "_native_sample_weight", + lambda sample_weight: ("numpy", RuntimeFailingXP(), np.ones(2)), + ) + with pytest.raises(RuntimeError, match="CUDA out of memory"): + solver_utils._validate_sample_weight(np.ones(2), 2) + + +def test_solver_weight_validation_runtime_catches_are_narrow(): + from pathlib import Path + + source = Path("statgpu/solvers/_utils.py").read_text(encoding="utf-8") + native_block = source.split("def _native_sample_weight", 1)[1].split( + "def _validated_sample_weight", 1 + )[0] + validated_block = source.split("def _validated_sample_weight", 1)[1].split( + "def _validate_uniform_sample_weight", 1 + )[0] + assert "except (TypeError, ValueError, RuntimeError)" not in native_block + assert "except (TypeError, ValueError, RuntimeError)" not in validated_block + + +# PR87_REVIEW_FIX_V44 +def test_newton_line_search_does_not_mask_runtime_failures(): + from statgpu.penalties import get_penalty + from statgpu.solvers import newton_solver + + class RuntimeFailingTrialLoss: + name = "runtime_failing_trial" + _has_constant_hessian = False + + def __init__(self): + self.value_calls = 0 + + def preprocess(self, X, y): + return np.asarray(X), np.asarray(y) + + def gradient(self, X, y, coef): + return np.ones(X.shape[1], dtype=np.float64) + + def hessian(self, X, y, coef): + return np.eye(X.shape[1], dtype=np.float64) + + def fused_value_and_gradient(self, X, y, coef): + self.value_calls += 1 + if self.value_calls == 1: + return np.array(1.0), np.ones(X.shape[1], dtype=np.float64) + raise RuntimeError("CUDA out of memory") + + with pytest.raises(RuntimeError, match="CUDA out of memory"): + newton_solver( + RuntimeFailingTrialLoss(), + get_penalty("l2", alpha=0.1), + np.ones((4, 1), dtype=np.float64), + np.ones(4, dtype=np.float64), + max_iter=2, + ) + + +def test_trial_error_classifier_is_narrow(): + from statgpu.solvers._utils import _trial_error_is_numerical + + assert _trial_error_is_numerical(RuntimeError("invalid value in log")) + assert _trial_error_is_numerical(ValueError("domain error")) + assert not _trial_error_is_numerical(RuntimeError("CUDA out of memory")) + assert not _trial_error_is_numerical(RuntimeError("device-side assert")) + +# PR87_REVIEW_FIX_V45 +def test_fista_family_warm_starts_follow_preprocessed_dtype(): + from statgpu.penalties import get_penalty + from statgpu.solvers import fista_bb_solver, fista_solver + + class Float32PreprocessLoss: + name = "float32_preprocess" + _is_quadratic = False + _prefer_fista_over_bb = False + _lipschitz_uses_y = True + + def preprocess(self, X, y): + return np.asarray(X, dtype=np.float32), np.asarray(y, dtype=np.float32) + + def lipschitz(self, X, coef, y=None, sample_weight=None): + return 1.0 + + def gradient(self, X, y, coef, sample_weight=None): + return np.zeros_like(coef) + + def fused_value_and_gradient(self, X, y, coef, sample_weight=None): + return np.asarray(0.0, dtype=coef.dtype), np.zeros_like(coef) + + def value(self, X, y, coef, sample_weight=None): + return np.asarray(0.0, dtype=coef.dtype) + + X = np.ones((4, 2), dtype=np.float64) + y = np.ones(4, dtype=np.float64) + init = np.array([0.2, -0.1], dtype=np.float64) + penalty = get_penalty("l2", alpha=0.0) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + fista_coef, _ = fista_solver( + Float32PreprocessLoss(), penalty, X, y, init_coef=init, max_iter=0 + ) + bb_coef, _ = fista_bb_solver( + Float32PreprocessLoss(), penalty, X, y, init_coef=init, max_iter=0 + ) + assert fista_coef.dtype == np.float32 + assert bb_coef.dtype == np.float32 + + +def test_proximal_newton_normalizes_weights_to_preprocessed_dtype(): + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + class RecordingLoss: + name = "recording" + has_hessian = True + + def __init__(self): + self.seen_weight = None + + def preprocess(self, X, y): + return np.asarray(X, dtype=np.float32), np.asarray(y, dtype=np.float32) + + def fused_gradient_and_hessian(self, X, y, coef, sample_weight=None): + self.seen_weight = sample_weight + return np.zeros_like(coef), np.eye(coef.shape[0], dtype=coef.dtype) + + loss = RecordingLoss() + coef, _ = proximal_newton_solver( + loss, + get_penalty("l2", alpha=0.0), + np.ones((3, 1), dtype=np.float64), + np.ones(3, dtype=np.float64), + sample_weight=[1.0, 2.0, 3.0], + max_iter=1, + ) + assert coef.dtype == np.float32 + assert isinstance(loss.seen_weight, np.ndarray) + assert loss.seen_weight.dtype == np.float32 + + +def test_torch_cuda_proximal_newton_numpy_weights_stay_on_device(): + torch = pytest.importorskip("torch") + if not torch.cuda.is_available(): + pytest.skip("requires physical CUDA") + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + X = torch.tensor([[1.0], [2.0], [3.0], [4.0]], device="cuda") + y = torch.tensor([1.0, 2.0, 3.0, 4.0], device="cuda") + coef, _ = proximal_newton_solver( + SquaredErrorLoss(), + get_penalty("l2", alpha=0.05), + X, + y, + sample_weight=np.array([1.0, 2.0, 3.0, 4.0]), + max_iter=3, + ) + assert coef.device.type == "cuda" + assert coef.dtype == X.dtype + assert bool(torch.all(torch.isfinite(coef)).item()) + + +def test_cupy_proximal_newton_numpy_weights_stay_on_device(): + cp = pytest.importorskip("cupy") + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip("requires physical CUDA") + except Exception: + pytest.skip("requires a working CuPy CUDA backend") + from statgpu.glm_core._squared import SquaredErrorLoss + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + X = cp.asarray([[1.0], [2.0], [3.0], [4.0]], dtype=cp.float64) + y = cp.asarray([1.0, 2.0, 3.0, 4.0], dtype=cp.float64) + coef, _ = proximal_newton_solver( + SquaredErrorLoss(), + get_penalty("l2", alpha=0.05), + X, + y, + sample_weight=np.array([1.0, 2.0, 3.0, 4.0]), + max_iter=3, + ) + assert isinstance(coef, cp.ndarray) + assert coef.dtype == X.dtype + assert bool(cp.all(cp.isfinite(coef)).item()) + +# PR87_REVIEW_FIX_V46 +def test_numpy_backend_constructors_follow_floating_reference_dtype(): + from statgpu.backends._array_ops import _to_backend, _zeros + + ref32 = np.ones(3, dtype=np.float32) + assert _zeros(3, "numpy", ref_tensor=ref32).dtype == np.float32 + assert _to_backend([1.0, 2.0], "numpy", ref_tensor=ref32).dtype == np.float32 + + ref_int = np.ones(3, dtype=np.int64) + assert _zeros(3, "numpy", ref_tensor=ref_int).dtype == np.float64 + assert _to_backend([1, 2], "numpy", ref_tensor=ref_int).dtype == np.float64 + +# PR87_REVIEW_FIX_V47 +def test_shared_linear_solve_does_not_mask_runtime_failures(monkeypatch): + from statgpu.backends._array_ops import _solve_linear_system + + def oom(*args, **kwargs): + raise RuntimeError("CUDA out of memory") + + def forbidden(*args, **kwargs): + raise AssertionError("lstsq must not mask infrastructure failures") + + monkeypatch.setattr(np.linalg, "solve", oom) + monkeypatch.setattr(np.linalg, "lstsq", forbidden) + with pytest.raises(RuntimeError, match="CUDA out of memory"): + _solve_linear_system(np.eye(2), np.ones(2), backend="numpy") + + +def test_shared_linear_solve_retains_rank_failure_fallback(monkeypatch): + from statgpu.backends._array_ops import _solve_linear_system + + expected = np.array([0.25, -0.5]) + + def singular(*args, **kwargs): + raise np.linalg.LinAlgError("singular matrix") + + monkeypatch.setattr(np.linalg, "solve", singular) + monkeypatch.setattr(np.linalg, "lstsq", lambda *args, **kwargs: (expected, None, None, None)) + result = _solve_linear_system(np.eye(2), np.ones(2), backend="numpy") + np.testing.assert_allclose(result, expected) + + +def test_shared_linear_solve_runtime_classifier_is_narrow(): + from statgpu.backends._array_ops import _linear_solve_runtime_is_rank_failure + + assert _linear_solve_runtime_is_rank_failure(RuntimeError("singular matrix")) + assert _linear_solve_runtime_is_rank_failure(RuntimeError("rank deficient")) + assert not _linear_solve_runtime_is_rank_failure(RuntimeError("CUDA out of memory")) + assert not _linear_solve_runtime_is_rank_failure(RuntimeError("device-side assert")) + +# PR87_REVIEW_FIX_V48 +def test_proximal_newton_backtracks_on_numeric_domain_value_error(): + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + class DomainTrialLoss: + name = "domain_trial" + has_hessian = True + + def __init__(self): + self.value_calls = 0 + + def preprocess(self, X, y): + return np.asarray(X, dtype=np.float64), np.asarray(y, dtype=np.float64) + + def fused_gradient_and_hessian(self, X, y, coef, sample_weight=None): + return np.ones_like(coef), np.eye(coef.shape[0], dtype=coef.dtype) + + def fused_value_and_gradient(self, X, y, coef, sample_weight=None): + self.value_calls += 1 + if self.value_calls == 1: + return np.asarray(1.0), np.ones_like(coef) + if self.value_calls == 2: + raise ValueError("domain error at trial point") + return np.asarray(0.25), np.ones_like(coef) + + coef, n_iter = proximal_newton_solver( + DomainTrialLoss(), + get_penalty("l2", alpha=0.0), + np.ones((4, 1), dtype=np.float64), + np.ones(4, dtype=np.float64), + max_iter=1, + ) + assert n_iter == 1 + assert np.all(np.isfinite(coef)) + assert not np.allclose(coef, 0.0) + +# PR87_REVIEW_FIX_V49 +def test_trial_error_classifier_does_not_mask_index_out_of_range(): + from statgpu.solvers._utils import _trial_error_is_numerical + + assert not _trial_error_is_numerical( + RuntimeError("index out of range in self") + ) + assert not _trial_error_is_numerical( + ValueError("coefficient index out of range") + ) + + +def test_proximal_newton_propagates_index_out_of_range_trial_error(): + from statgpu.penalties import get_penalty + from statgpu.solvers import proximal_newton_solver + + class IndexFailingTrialLoss: + name = "index_failing_trial" + has_hessian = True + + def __init__(self): + self.value_calls = 0 + + def preprocess(self, X, y): + return np.asarray(X, dtype=np.float64), np.asarray(y, dtype=np.float64) + + def fused_gradient_and_hessian(self, X, y, coef, sample_weight=None): + return np.ones_like(coef), np.eye(coef.shape[0], dtype=coef.dtype) + + def fused_value_and_gradient(self, X, y, coef, sample_weight=None): + self.value_calls += 1 + if self.value_calls == 1: + return np.asarray(1.0), np.ones_like(coef) + raise RuntimeError("index out of range in self") + + with pytest.raises(RuntimeError, match="index out of range"): + proximal_newton_solver( + IndexFailingTrialLoss(), + get_penalty("l2", alpha=0.0), + np.ones((4, 1), dtype=np.float64), + np.ones(4, dtype=np.float64), + max_iter=1, + ) + + + +def test_backend_linalg_failure_classifier_is_narrow(): + from statgpu.backends._array_ops import _linalg_exception_is_rank_failure + + assert _linalg_exception_is_rank_failure(np.linalg.LinAlgError("singular matrix")) + assert _linalg_exception_is_rank_failure(RuntimeError("matrix is singular")) + assert _linalg_exception_is_rank_failure(RuntimeError("not positive definite")) + assert not _linalg_exception_is_rank_failure(RuntimeError("CUDA out of memory")) + assert not _linalg_exception_is_rank_failure(RuntimeError("index out of range")) + assert not _linalg_exception_is_rank_failure(ValueError("incompatible dimensions")) + + +def test_glm_response_validation_preserves_backend_runtime_failure(monkeypatch): + from types import SimpleNamespace + from statgpu.glm_core import get_glm_loss + import statgpu.backends._array_ops as array_ops + + fake_xp = SimpleNamespace( + __name__="fake_gpu", + asarray=lambda value: np.asarray(value), + isfinite=np.isfinite, + any=lambda value: (_ for _ in ()).throw(RuntimeError("CUDA out of memory")), + ) + monkeypatch.setattr(array_ops, "_xp", lambda value: fake_xp) + + with pytest.raises(RuntimeError, match="CUDA out of memory"): + get_glm_loss("squared_error").validate_response(np.array([1.0, 2.0])) + + +def test_penalized_exact_torch_preserves_nonrank_runtime_failure(monkeypatch): + torch = pytest.importorskip("torch") + from statgpu.linear_model.penalized._fit_mixin import _PenalizedFitMixin + + owner = object.__new__(_PenalizedFitMixin) + owner._ridge_alpha_for_exact = lambda: 0.1 + monkeypatch.setattr( + torch.linalg, + "solve", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("CUDA out of memory")), + ) + monkeypatch.setattr( + torch.linalg, + "pinv", + lambda *args, **kwargs: pytest.fail("pinv must not run after CUDA OOM"), + ) + + with pytest.raises(RuntimeError, match="CUDA out of memory"): + owner._solve_exact_torch(torch.eye(2), torch.ones(2), normalization=3.0) + + +def test_kernel_ridge_retry_preserves_nonrank_runtime_failure(monkeypatch): + from statgpu.nonparametric.kernel_smoothing._kernel_regression import ( + _solve_linear_system_with_ridge, + ) + + monkeypatch.setattr( + np.linalg, + "solve", + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("CUDA out of memory") + ), + ) + with pytest.raises(RuntimeError, match="CUDA out of memory"): + _solve_linear_system_with_ridge(np.eye(2), np.ones(2), np) + + + +def test_penalized_cv_does_not_substitute_mse_for_non_gaussian_loss(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + owner = object.__new__(cv_mod.PenalizedGLM_CV) + owner.loss = "poisson" + + class Loss: + def value(self, *args, **kwargs): + raise FloatingPointError("generic poisson evaluation failed") + + class Model: + coef_ = np.array([0.2]) + intercept_ = 0.1 + fit_intercept = True + + def predict(self, X): + pytest.fail("non-Gaussian CV must not fall back to MSE predictions") + + monkeypatch.setattr( + cv_mod, + "_evaluate_loss_numpy", + lambda *args, **kwargs: (_ for _ in ()).throw( + FloatingPointError("registered poisson evaluation failed") + ), + ) + + with pytest.raises(RuntimeError, match="Refusing to substitute mean squared error"): + owner._evaluate_single( + Model(), + np.array([[1.0], [2.0]]), + np.array([1.0, 3.0]), + loss_fn=Loss(), + ) + + +def test_penalized_cv_squared_error_emergency_fallback_preserves_weights(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + owner = object.__new__(cv_mod.PenalizedGLM_CV) + owner.loss = "squared_error" + + class Loss: + def value(self, *args, **kwargs): + raise FloatingPointError("generic squared evaluation failed") + + class Model: + coef_ = np.array([0.0]) + intercept_ = 0.0 + fit_intercept = True + + def predict(self, X): + return np.array([0.0, 2.0]) + + monkeypatch.setattr( + cv_mod, + "_evaluate_loss_numpy", + lambda *args, **kwargs: (_ for _ in ()).throw( + FloatingPointError("registered squared evaluation failed") + ), + ) + weights = np.array([1.0, 3.0]) + with pytest.warns(RuntimeWarning, match="weighted-MSE"): + value = owner._evaluate_single( + Model(), + np.array([[1.0], [2.0]]), + np.array([1.0, 4.0]), + loss_fn=Loss(), + sample_weight=weights, + ) + expected = (1.0 * (1.0 - 0.0) ** 2 + 3.0 * (4.0 - 2.0) ** 2) / 4.0 + assert value == pytest.approx(expected) + + +def test_penalized_cv_loss_evaluation_preserves_infrastructure_failure(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + owner = object.__new__(cv_mod.PenalizedGLM_CV) + owner.loss = "poisson" + + class Loss: + def value(self, *args, **kwargs): + pytest.fail("generic loss fallback must not run after CUDA OOM") + + class Model: + coef_ = np.array([0.2]) + intercept_ = 0.1 + fit_intercept = True + + monkeypatch.setattr( + cv_mod, + "_evaluate_loss_numpy", + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("CUDA out of memory") + ), + ) + with pytest.raises(RuntimeError, match="CUDA out of memory"): + owner._evaluate_single( + Model(), + np.array([[1.0], [2.0]]), + np.array([1.0, 3.0]), + loss_fn=Loss(), + ) + + +def test_penalized_cv_infrastructure_classifier_is_narrow(): + from statgpu.linear_model.penalized._penalized_cv import ( + _cv_exception_is_infrastructure_failure, + ) + + assert _cv_exception_is_infrastructure_failure(RuntimeError("CUDA out of memory")) + assert _cv_exception_is_infrastructure_failure(RuntimeError("index out of range")) + assert _cv_exception_is_infrastructure_failure(MemoryError("allocation failed")) + assert not _cv_exception_is_infrastructure_failure( + np.linalg.LinAlgError("singular matrix") + ) + assert not _cv_exception_is_infrastructure_failure( + ValueError("numeric domain error") + ) + + + +def test_penalized_cv_lipschitz_recovery_includes_cupy_linalg_only(): + from statgpu.linear_model.penalized._penalized_cv import ( + _cv_lipschitz_failure_is_recoverable, + ) + + CupyLinAlgError = type( + "LinAlgError", (Exception,), {"__module__": "cupy.linalg._solve"} + ) + assert _cv_lipschitz_failure_is_recoverable( + CupyLinAlgError("singular matrix") + ) + assert _cv_lipschitz_failure_is_recoverable( + np.linalg.LinAlgError("singular matrix") + ) + assert not _cv_lipschitz_failure_is_recoverable( + RuntimeError("CUDA out of memory") + ) + + +def test_penalized_cv_alpha_grid_does_not_hide_memory_failure(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + import statgpu.linear_model.penalized._base as base_mod + + class FailingModel: + def __init__(self, *args, **kwargs): + pass + + def fit(self, *args, **kwargs): + raise MemoryError("host allocation failed") + + monkeypatch.setattr(base_mod, "PenalizedGeneralizedLinearModel", FailingModel) + owner = object.__new__(cv_mod.PenalizedGLM_CV) + owner.loss = "poisson" + owner.penalty = "l1" + owner.l1_ratio = 1.0 + owner._n_alphas = 5 + owner._loss_kwargs = None + owner._penalty_kwargs = None + + with pytest.raises(MemoryError, match="host allocation failed"): + owner._generate_alpha_grid( + np.array([[1.0], [2.0], [3.0]]), + np.array([1.0, 2.0, 3.0]), + ) + + +def _weighted_logistic_fixture(): + X = np.array( + [ + [-1.2, 0.3], + [-0.7, -0.4], + [-0.1, 0.8], + [0.4, -0.6], + [0.9, 0.2], + [1.5, 1.0], + ], + dtype=np.float64, + ) + y = np.array([0, 0, 0, 1, 1, 1], dtype=np.float64) + weight = np.array([1, 3, 2, 4, 1, 2], dtype=np.float64) + return X, y, weight + + +def test_weighted_logistic_cpu_matches_integer_row_replication(): + from statgpu.linear_model.wrappers._logistic import LogisticRegression + + X, y, weight = _weighted_logistic_fixture() + weighted = LogisticRegression( + C=2.5, + max_iter=300, + tol=1e-11, + device="cpu", + compute_inference=True, + ).fit(X, y, sample_weight=weight) + + repeats = weight.astype(np.int64) + replicated = LogisticRegression( + C=2.5, + max_iter=300, + tol=1e-11, + device="cpu", + compute_inference=True, + ).fit(np.repeat(X, repeats, axis=0), np.repeat(y, repeats, axis=0)) + + np.testing.assert_allclose(weighted.intercept_, replicated.intercept_, rtol=1e-8, atol=1e-9) + np.testing.assert_allclose(weighted.coef_, replicated.coef_, rtol=1e-8, atol=1e-9) + np.testing.assert_allclose(weighted._bse, replicated._bse, rtol=1e-8, atol=1e-9) + np.testing.assert_allclose(weighted._loglik, replicated._loglik, rtol=1e-9, atol=1e-9) + np.testing.assert_allclose(weighted._loglik_null, replicated._loglik_null, rtol=1e-9, atol=1e-9) + + +def test_weighted_logistic_torch_matches_cpu(monkeypatch): + torch = pytest.importorskip("torch") + import statgpu.linear_model.wrappers._logistic as logistic_mod + from statgpu.linear_model.wrappers._logistic import LogisticRegression + + X, y, weight = _weighted_logistic_fixture() + cpu = LogisticRegression( + C=2.5, max_iter=300, tol=1e-11, device="cpu", compute_inference=True + ).fit(X, y, sample_weight=weight) + + # Exercise the Torch implementation on a CPU-only hosted runner + # without weakening the public explicit-Torch CUDA contract. + monkeypatch.setattr(logistic_mod, "_get_torch_device_str", lambda: "cpu") + torch_model = LogisticRegression( + C=2.5, max_iter=300, tol=1e-11, device="cpu", compute_inference=True + ) + torch_model._y = y.astype(float) + torch_model._sample_weight = weight.astype(float) + torch_model._weight_sum = float(np.sum(weight)) + torch_model._fit_torch( + torch.as_tensor(X, dtype=torch.float64), + torch.as_tensor(y, dtype=torch.float64), + torch.as_tensor(weight, dtype=torch.float64), + ) + + np.testing.assert_allclose( + torch_model.intercept_, cpu.intercept_, rtol=1e-8, atol=1e-9 + ) + np.testing.assert_allclose( + torch_model.coef_, cpu.coef_, rtol=1e-8, atol=1e-9 + ) + np.testing.assert_allclose( + torch_model._bse, cpu._bse, rtol=1e-8, atol=1e-9 + ) + np.testing.assert_allclose( + torch_model._loglik, cpu._loglik, rtol=1e-9, atol=1e-9 + ) + +@pytest.mark.parametrize( + "weight, message", + [ + (np.array([1.0, -1.0, 1.0, 1.0, 1.0, 1.0]), "non-negative"), + (np.zeros(6), "positive sum"), + (np.array([1.0, np.inf, 1.0, 1.0, 1.0, 1.0]), "finite"), + ], +) +def test_weighted_logistic_validates_weights_before_irls(weight, message): + from statgpu.linear_model.wrappers._logistic import LogisticRegression + + X, y, _ = _weighted_logistic_fixture() + with pytest.raises(ValueError, match=message): + LogisticRegression(device="cpu").fit(X, y, sample_weight=weight) + + +def test_alpha_grid_fallback_classifier_is_narrow(): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + assert cv_mod._cv_alpha_grid_failure_is_recoverable( + FloatingPointError("overflow") + ) + assert cv_mod._cv_alpha_grid_failure_is_recoverable( + np.linalg.LinAlgError("singular matrix") + ) + assert not cv_mod._cv_alpha_grid_failure_is_recoverable( + AttributeError("missing gradient") + ) + assert not cv_mod._cv_alpha_grid_failure_is_recoverable( + RuntimeError("unexpected implementation failure") + ) + + +def test_exact_cupy_ridge_does_not_mask_cuda_oom(monkeypatch): + import sys + import types + from types import SimpleNamespace + from statgpu.linear_model.penalized._base import PenalizedGeneralizedLinearModel + + fake_cp = types.ModuleType("cupy") + fake_cp.eye = np.eye + + class FakeLinalg: + @staticmethod + def cholesky(_): + raise RuntimeError("CUDA out of memory") + + @staticmethod + def solve(*_): + raise AssertionError("solve must not run after CUDA OOM") + + @staticmethod + def pinv(*_): + raise AssertionError("pinv must not run after CUDA OOM") + + fake_cp.linalg = FakeLinalg() + fake_cupyx = types.ModuleType("cupyx") + fake_cupyx_scipy = types.ModuleType("cupyx.scipy") + fake_cupyx_linalg = types.ModuleType("cupyx.scipy.linalg") + fake_cupyx_linalg.solve_triangular = lambda *args, **kwargs: np.asarray(args[1]) + + monkeypatch.setitem(sys.modules, "cupy", fake_cp) + monkeypatch.setitem(sys.modules, "cupyx", fake_cupyx) + monkeypatch.setitem(sys.modules, "cupyx.scipy", fake_cupyx_scipy) + monkeypatch.setitem(sys.modules, "cupyx.scipy.linalg", fake_cupyx_linalg) + + model = PenalizedGeneralizedLinearModel.__new__(PenalizedGeneralizedLinearModel) + model._penalty = SimpleNamespace(alpha=0.1) + model.alpha = 0.1 + with pytest.raises(RuntimeError, match="CUDA out of memory"): + model._solve_exact_cupy(np.eye(2), np.ones(2), 4.0) + + +def test_dedicated_cv_device_resolution_preserves_explicit_backend(monkeypatch): + import numpy as np + import statgpu.linear_model.cv._device as cv_device + from statgpu._config import Device + + calls = [] + + class NumpyBackend: + pass + + class CuPyBackend: + pass + + class TorchBackend: + pass + + classes = { + "numpy": NumpyBackend, + "cupy": CuPyBackend, + "torch": TorchBackend, + "auto": NumpyBackend, + } + + def fake_get_backend(*, backend, device): + calls.append((backend, device)) + return classes[backend]() + + monkeypatch.setattr(cv_device, "get_backend", fake_get_backend) + X = np.zeros((4, 2)) + + result = cv_device.resolve_cv_backend(Device.TORCH, X) + assert result[0] == "torch" + assert result[1] == "torch" + assert result[3] is True + assert calls[-1] == ("torch", "cuda") + + result = cv_device.resolve_cv_backend("cuda", X) + assert result[1] == "cupy" + assert calls[-1] == ("cupy", "cuda") + + +def test_dedicated_cv_device_resolution_rejects_cross_library_switch(): + import statgpu.linear_model.cv._device as cv_device + + FakeTorchCuda = type("FakeTorchCuda", (), {"__module__": "torch"}) + value = FakeTorchCuda() + value.device = "cuda:0" + + with pytest.raises(ValueError, match="selects CuPy"): + cv_device.resolve_cv_backend("cuda", value) + + +@pytest.mark.parametrize( + "selector, kwargs", + [ + ("logistic", {"Cs": [1.0], "cv_folds": 1}), + ("ridge", {"alphas": [1.0], "cv_folds": 1}), + ( + "elasticnet", + {"alphas": [1.0], "l1_ratios": [0.5], "cv_folds": 1}, + ), + ], +) +def test_dedicated_cv_validates_weights_before_degenerate_return(selector, kwargs): + X = np.arange(12.0).reshape(6, 2) + y = np.array([0, 1, 0, 1, 0, 1], dtype=float) + + if selector == "logistic": + from statgpu.linear_model.cv._logistic_cv import _select_logistic_c_cv + fn = _select_logistic_c_cv + elif selector == "ridge": + from statgpu.linear_model.cv._ridge_cv import _select_ridge_alpha_cv + fn = _select_ridge_alpha_cv + else: + from statgpu.linear_model.cv._elasticnet_cv import _select_elasticnet_params_cv + fn = _select_elasticnet_params_cv + + with pytest.raises(ValueError, match="non-negative"): + fn( + X, + y, + sample_weight=np.array([1, 1, 1, 1, 1, -1.0]), + **kwargs, + ) + with pytest.raises(ValueError, match="finite positive sum"): + fn(X, y, sample_weight=np.zeros(6), **kwargs) + + +def test_logistic_default_grid_respects_integer_weight_replication(): + from statgpu.linear_model.cv._logistic_cv import _default_logistic_c_grid + + X = np.array([[0.0, 1.0], [1.0, -1.0], [2.0, 0.5], [-1.0, 2.0]]) + y = np.array([0.0, 1.0, 1.0, 0.0]) + weight = np.array([1, 3, 2, 1]) + weighted = _default_logistic_c_grid(X, y, n_Cs=7, sample_weight=weight) + replicated = _default_logistic_c_grid( + np.repeat(X, weight, axis=0), np.repeat(y, weight), n_Cs=7 + ) + np.testing.assert_allclose(weighted, replicated, rtol=1e-12, atol=1e-12) + + +def test_elasticnet_default_grid_respects_integer_weight_replication(): + from statgpu.linear_model.cv._elasticnet_cv import _default_elasticnet_alpha_grid + + X = np.array([[0.0, 1.0], [1.0, -1.0], [2.0, 0.5], [-1.0, 2.0]]) + y = np.array([0.5, 2.0, -1.0, 1.5]) + weight = np.array([1, 3, 2, 1]) + weighted = _default_elasticnet_alpha_grid( + X, y, l1_ratio=0.6, n_alphas=7, sample_weight=weight + ) + replicated = _default_elasticnet_alpha_grid( + np.repeat(X, weight, axis=0), + np.repeat(y, weight), + l1_ratio=0.6, + n_alphas=7, + ) + np.testing.assert_allclose(weighted, replicated, rtol=1e-12, atol=1e-12) + + +def test_cv_device_inspection_does_not_mask_runtime_failures(): + from statgpu.linear_model.cv._device import _array_gpu_backend + + class BrokenTorchArray: + __module__ = "torch" + + @property + def device(self): + raise RuntimeError("CUDA device query failed") + + with pytest.raises(RuntimeError, match="device query failed"): + _array_gpu_backend(BrokenTorchArray()) + + + +@pytest.mark.parametrize( + "module_name,class_name,selector_name,y", + [ + ( + "statgpu.linear_model.cv._ridge_cv", + "RidgeCV", + "_select_ridge_alpha_cv", + np.arange(6, dtype=np.float64), + ), + ( + "statgpu.linear_model.cv._elasticnet_cv", + "ElasticNetCV", + "_select_elasticnet_params_cv", + np.arange(6, dtype=np.float64), + ), + ( + "statgpu.linear_model.cv._logistic_cv", + "LogisticRegressionCV", + "_select_logistic_c_cv", + np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]), + ), + ], +) +def test_public_dedicated_cv_preserves_auto_device_request( + monkeypatch, module_name, class_name, selector_name, y +): + import importlib + from statgpu.linear_model.cv._device import normalize_cv_device + + module = importlib.import_module(module_name) + observed = [] + + def probe(*args, **kwargs): + observed.append(kwargs["device"]) + raise RuntimeError("device request probe") + + monkeypatch.setattr(module, selector_name, probe) + estimator = getattr(module, class_name)(device="auto", cv=2) + X = np.arange(12, dtype=np.float64).reshape(6, 2) + with pytest.raises(RuntimeError, match="device request probe"): + estimator.fit(X, y) + + assert len(observed) == 1 + assert normalize_cv_device(observed[0]) == "auto" + + +def test_logistic_cv_binary_validation_preserves_torch_response(): + torch = pytest.importorskip("torch") + from statgpu.linear_model.cv._logistic_cv import _validate_binary_cv_response + + y = torch.tensor([0.0, 1.0, 1.0, 0.0], dtype=torch.float64) + assert _validate_binary_cv_response(y) is y + with pytest.raises(ValueError, match="binary y"): + _validate_binary_cv_response( + torch.tensor([0.0, 0.5, 1.0], dtype=torch.float64) + ) + + +def test_auto_cv_router_prefers_gpu_resident_input_backend(monkeypatch): + import statgpu.linear_model.cv._device as device_mod + + class FakeTorchCudaArray: + __module__ = "torch" + device = "cuda:0" + + calls = [] + + class FakeBackend: + pass + + def backend_probe(*, backend, device): + calls.append((backend, device)) + return FakeBackend() + + monkeypatch.setattr(device_mod, "get_backend", backend_probe) + resolved = device_mod.resolve_cv_backend("auto", FakeTorchCudaArray()) + assert resolved[0] == "auto" + assert resolved[1] == "torch" + assert resolved[3] is True + assert calls == [("torch", "cuda")] + + + +def test_cv_refit_device_pins_auto_to_selected_backend(): + from statgpu._config import Device + from statgpu.linear_model.cv._device import cv_refit_device + + assert cv_refit_device("auto", "numpy") == Device.CPU + assert cv_refit_device("auto", "cupy") == Device.CUDA + assert cv_refit_device("auto", "torch") == Device.TORCH + assert cv_refit_device("cpu", "torch") == Device.CPU + assert cv_refit_device("cuda", "torch") == Device.CUDA + with pytest.raises(ValueError, match="Unknown CV backend"): + cv_refit_device("auto", "mystery") + + +@pytest.mark.parametrize( + "module_name,class_name,selector_name,model_name,y,details", + [ + ( + "statgpu.linear_model.cv._ridge_cv", + "RidgeCV", + "_select_ridge_alpha_cv", + "Ridge", + np.arange(6, dtype=np.float64), + { + "alpha": 0.5, + "alphas": np.array([0.5]), + "mse_path": np.array([[1.0]]), + "mean_mse": np.array([1.0]), + }, + ), + ( + "statgpu.linear_model.cv._logistic_cv", + "LogisticRegressionCV", + "_select_logistic_c_cv", + "LogisticRegression", + np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]), + { + "C": 1.0, + "Cs": np.array([1.0]), + "loss_path": np.array([[0.5]]), + "mean_loss": np.array([0.5]), + }, + ), + ], +) +def test_public_cv_auto_refit_uses_cv_selected_backend( + monkeypatch, + module_name, + class_name, + selector_name, + model_name, + y, + details, +): + import importlib + from statgpu._config import Device + + module = importlib.import_module(module_name) + backend = object() + monkeypatch.setattr( + module, + "resolve_cv_backend", + lambda device, X: ("auto", "torch", backend, True, False, True), + ) + monkeypatch.setattr(module, selector_name, lambda *args, **kwargs: details) + observed = [] + + class FakeModel: + def __init__(self, *args, device=None, **kwargs): + observed.append(device) + self.coef_ = np.zeros(2) + self.intercept_ = 0.0 + self.n_iter_ = 1 + + def fit(self, X, y, sample_weight=None): + return self + + monkeypatch.setattr(module, model_name, FakeModel) + estimator = getattr(module, class_name)(device="auto", cv=2) + X = np.arange(12, dtype=np.float64).reshape(6, 2) + estimator.fit(X, y) + + assert observed == [Device.TORCH] + assert estimator.cv_selected_device_ == Device.TORCH + + +def test_elasticnet_cv_auto_refit_uses_cv_selected_backend(monkeypatch): + import statgpu.linear_model.cv._elasticnet_cv as module + from statgpu._config import Device + + backend = object() + monkeypatch.setattr( + module, + "resolve_cv_backend", + lambda device, X: ("auto", "cupy", backend, True, True, False), + ) + details = { + "mse_path": np.array([[[1.0]]]), + "mean_mse": np.array([[1.0]]), + "std_mse": np.array([[0.0]]), + "alphas": {0: np.array([0.5])}, + "l1_ratios": np.array([0.5]), + "best_mse": 1.0, + } + monkeypatch.setattr( + module, + "_select_elasticnet_params_cv", + lambda *args, **kwargs: (0.5, 0.5, details), + ) + observed = [] + + class FakeElasticNet: + def __init__(self, *args, device=None, **kwargs): + observed.append(device) + self.coef_ = np.zeros(2) + self.intercept_ = 0.0 + self.n_iter_ = 1 + + def fit(self, X, y, sample_weight=None): + return self + + monkeypatch.setattr(module, "ElasticNet", FakeElasticNet) + estimator = module.ElasticNetCV(device="auto", cv=2) + X = np.arange(12, dtype=np.float64).reshape(6, 2) + estimator.fit(X, np.arange(6, dtype=np.float64)) + + assert observed == [Device.CUDA] + assert estimator.cv_selected_device_ == Device.CUDA + + + +@pytest.mark.parametrize( + "module_name,class_name,selector_name,y,selected_attrs", + [ + ( + "statgpu.linear_model.cv._ridge_cv", + "RidgeCV", + "_select_ridge_alpha_cv", + np.arange(6, dtype=np.float64), + ("alpha_", "alphas_", "mean_mse_"), + ), + ( + "statgpu.linear_model.cv._elasticnet_cv", + "ElasticNetCV", + "_select_elasticnet_params_cv", + np.arange(6, dtype=np.float64), + ("alpha_", "l1_ratio_"), + ), + ( + "statgpu.linear_model.cv._logistic_cv", + "LogisticRegressionCV", + "_select_logistic_c_cv", + np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]), + ("C_", "Cs_", "mean_loss_"), + ), + ], +) +def test_dedicated_cv_failed_refit_clears_previous_state( + monkeypatch, module_name, class_name, selector_name, y, selected_attrs +): + import importlib + from statgpu._config import Device + + module = importlib.import_module(module_name) + estimator = getattr(module, class_name)(device="cpu", cv=2) + estimator._fitted = True + estimator.estimator_ = object() + estimator.coef_ = np.array([9.0, 8.0]) + estimator.intercept_ = 7.0 + estimator.best_score_ = 6.0 + estimator.cv_results_ = {"stale": True} + estimator.cv_selected_device_ = Device.TORCH + for name in selected_attrs: + setattr(estimator, name, np.array([5.0]) if name.endswith("s_") else 5.0) + + monkeypatch.setattr( + module, + selector_name, + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("selection failed") + ), + ) + X = np.arange(12, dtype=np.float64).reshape(6, 2) + with pytest.raises(RuntimeError, match="selection failed"): + estimator.fit(X, y) + + assert estimator._fitted is False + assert estimator.estimator_ is None + assert estimator.coef_ is None + assert estimator.intercept_ is None + assert estimator.best_score_ is None + assert estimator.cv_results_ is None + assert estimator.cv_selected_device_ is None + for name in selected_attrs: + assert getattr(estimator, name) is None + + +def test_ridge_cv_final_refit_failure_does_not_publish_partial_state(monkeypatch): + import statgpu.linear_model.cv._ridge_cv as module + + details = { + "alpha": 0.5, + "alphas": np.array([0.5]), + "mse_path": np.array([[1.0]]), + "mean_mse": np.array([1.0]), + } + monkeypatch.setattr(module, "_select_ridge_alpha_cv", lambda *a, **k: details) + + class FailingRidge: + def __init__(self, *args, **kwargs): + pass + + def fit(self, *args, **kwargs): + raise RuntimeError("final refit failed") + + monkeypatch.setattr(module, "Ridge", FailingRidge) + estimator = module.RidgeCV(device="cpu", cv=2) + X = np.arange(12, dtype=np.float64).reshape(6, 2) + with pytest.raises(RuntimeError, match="final refit failed"): + estimator.fit(X, np.arange(6, dtype=np.float64)) + assert estimator._fitted is False + assert estimator.alpha_ is None + assert estimator.estimator_ is None + assert estimator.cv_selected_device_ is None + + +def test_elasticnet_cv_final_refit_failure_does_not_publish_partial_state(monkeypatch): + import statgpu.linear_model.cv._elasticnet_cv as module + + details = { + "mse_path": np.array([[[1.0]]]), + "mean_mse": np.array([[1.0]]), + "std_mse": np.array([[0.0]]), + "alphas": {0: np.array([0.5])}, + "l1_ratios": np.array([0.5]), + "best_mse": 1.0, + } + monkeypatch.setattr( + module, + "_select_elasticnet_params_cv", + lambda *a, **k: (0.5, 0.5, details), + ) + + class FailingElasticNet: + def __init__(self, *args, **kwargs): + pass + + def fit(self, *args, **kwargs): + raise RuntimeError("final refit failed") + + monkeypatch.setattr(module, "ElasticNet", FailingElasticNet) + estimator = module.ElasticNetCV(device="cpu", cv=2) + X = np.arange(12, dtype=np.float64).reshape(6, 2) + with pytest.raises(RuntimeError, match="final refit failed"): + estimator.fit(X, np.arange(6, dtype=np.float64)) + assert estimator._fitted is False + assert estimator.alpha_ is None + assert estimator.l1_ratio_ is None + assert estimator.estimator_ is None + assert estimator.cv_selected_device_ is None + + +def test_logistic_cv_final_refit_failure_does_not_publish_partial_state(monkeypatch): + import statgpu.linear_model.cv._logistic_cv as module + + details = { + "C": 1.0, + "Cs": np.array([1.0]), + "loss_path": np.array([[0.5]]), + "mean_loss": np.array([0.5]), + } + monkeypatch.setattr(module, "_select_logistic_c_cv", lambda *a, **k: details) + + class FailingLogistic: + def __init__(self, *args, **kwargs): + pass + + def fit(self, *args, **kwargs): + raise RuntimeError("final refit failed") + + monkeypatch.setattr(module, "LogisticRegression", FailingLogistic) + estimator = module.LogisticRegressionCV(device="cpu", cv=2) + X = np.arange(12, dtype=np.float64).reshape(6, 2) + y = np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + with pytest.raises(RuntimeError, match="final refit failed"): + estimator.fit(X, y) + assert estimator._fitted is False + assert estimator.C_ is None + assert estimator.estimator_ is None + assert estimator.cv_selected_device_ is None + + + +@pytest.mark.parametrize( + "module_name,class_name,y,selected_name", + [ + ( + "statgpu.linear_model.cv._ridge_cv", + "RidgeCV", + np.arange(6, dtype=np.float64), + "alpha_", + ), + ( + "statgpu.linear_model.cv._elasticnet_cv", + "ElasticNetCV", + np.arange(6, dtype=np.float64), + "alpha_", + ), + ( + "statgpu.linear_model.cv._logistic_cv", + "LogisticRegressionCV", + np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]), + "C_", + ), + ], +) +def test_cv_finite_guard_resets_stale_state_before_rejecting_input( + module_name, class_name, y, selected_name +): + import importlib + from statgpu._config import Device + + module = importlib.import_module(module_name) + estimator = getattr(module, class_name)(device="cpu", cv=2) + estimator._fitted = True + estimator.estimator_ = object() + estimator.coef_ = np.array([3.0, 4.0]) + estimator.intercept_ = 2.0 + estimator.best_score_ = 1.0 + estimator.cv_results_ = {"stale": True} + estimator.cv_selected_device_ = Device.TORCH + setattr(estimator, selected_name, 0.5) + + X = np.arange(12, dtype=np.float64).reshape(6, 2) + X[0, 0] = np.nan + with pytest.raises(ValueError, match="finite"): + estimator.fit(X, y) + + assert estimator._fitted is False + assert estimator.estimator_ is None + assert estimator.coef_ is None + assert estimator.intercept_ is None + assert estimator.best_score_ is None + assert estimator.cv_results_ is None + assert estimator.cv_selected_device_ is None + assert getattr(estimator, selected_name) is None + + +def test_finite_guard_does_not_reset_cv_state_on_prediction_failure(): + from statgpu.linear_model.cv._ridge_cv import RidgeCV + + estimator = RidgeCV(device="cpu", cv=2) + estimator._fitted = True + + class FittedModel: + def predict(self, X): + return np.zeros(int(X.shape[0]), dtype=np.float64) + + estimator.estimator_ = FittedModel() + estimator.alpha_ = 0.5 + estimator.coef_ = np.array([1.0]) + estimator.intercept_ = 0.0 + + with pytest.raises(ValueError, match="finite"): + estimator.predict(np.array([[np.nan]], dtype=np.float64)) + + assert estimator._fitted is True + assert estimator.alpha_ == pytest.approx(0.5) + assert estimator.estimator_ is not None + + + +def _elasticnet_inference_fixture(seed=20260805): + rng = np.random.default_rng(seed) + X = rng.normal(size=(96, 3)) + beta = np.array([1.4, -0.9, 0.65]) + y = 0.35 + X @ beta + rng.normal(scale=0.35, size=X.shape[0]) + return X.astype(np.float64), y.astype(np.float64) + + +def _assert_elasticnet_inference_contract(model, n_features=3): + n_params = n_features + 1 + assert model._compute_inference_enabled is True + assert model._inference_result is not None + assert model._inference_result.method == "debiased" + assert np.asarray(model._params).shape == (n_params,) + assert np.asarray(model._bse).shape == (n_params,) + assert np.asarray(model._pvalues).shape == (n_params,) + assert np.asarray(model._conf_int).shape == (n_params, 2) + assert np.all(np.isfinite(np.asarray(model._params))) + assert np.all(np.isfinite(np.asarray(model._bse))) + assert np.all(np.isfinite(np.asarray(model._pvalues))) + assert np.all(np.isfinite(np.asarray(model._conf_int))) + model.summary() + + +def test_elasticnet_wrapper_cpu_debiased_inference_contract(): + from statgpu.linear_model import ElasticNet + + X, y = _elasticnet_inference_fixture() + model = ElasticNet( + alpha=0.02, + l1_ratio=0.5, + max_iter=2000, + tol=1e-7, + device="cpu", + compute_inference=True, + inference_method="debiased", + ).fit(X, y) + + _assert_elasticnet_inference_contract(model) + params = model.get_params(deep=False) + assert params["compute_inference"] is True + assert params["inference_method"] == "debiased" + assert params["cov_type"] == "nonrobust" + assert params["hac_maxlags"] is None + + +def test_elasticnet_cv_compute_inference_runs_on_final_refit(): + from statgpu.linear_model import ElasticNetCV + + X, y = _elasticnet_inference_fixture(seed=20260806) + model = ElasticNetCV( + l1_ratio=[0.5], + alphas=[0.02], + cv=2, + max_iter=1500, + tol=1e-7, + device="cpu", + compute_inference=True, + random_state=17, + ).fit(X, y) + + assert model.estimator_ is not None + _assert_elasticnet_inference_contract(model.estimator_) + model.summary() + + +def test_elasticnet_cv_passes_inference_flag_to_final_model(monkeypatch): + import statgpu.linear_model.cv._elasticnet_cv as module + + details = { + "mse_path": np.array([[[1.0]]]), + "mean_mse": np.array([[1.0]]), + "std_mse": np.array([[0.0]]), + "alphas": {0: np.array([0.02])}, + "l1_ratios": np.array([0.5]), + "best_mse": 1.0, + } + monkeypatch.setattr( + module, + "_select_elasticnet_params_cv", + lambda *args, **kwargs: (0.02, 0.5, details), + ) + observed = [] + + class FakeElasticNet: + def __init__(self, *args, **kwargs): + observed.append(kwargs) + self.coef_ = np.zeros(3) + self.intercept_ = 0.0 + self.n_iter_ = 1 + + def fit(self, X, y, sample_weight=None): + return self + + def predict(self, X): + return np.zeros(int(X.shape[0])) + + monkeypatch.setattr(module, "ElasticNet", FakeElasticNet) + X, y = _elasticnet_inference_fixture(seed=20260807) + model = module.ElasticNetCV( + l1_ratio=[0.5], + alphas=[0.02], + cv=2, + device="cpu", + compute_inference=True, + n_jobs=3, + ).fit(X, y) + + assert len(observed) == 1 + assert observed[0]["compute_inference"] is True + assert observed[0]["inference_method"] == "debiased" + assert observed[0]["n_jobs"] == 3 + assert model.estimator_ is not None + + +def test_torch_cuda_elasticnet_inference_contract(): + torch = _require_modern_torch_cuda() + from statgpu.linear_model import ElasticNet, ElasticNetCV + + X_np, y_np = _elasticnet_inference_fixture(seed=20260808) + X = torch.as_tensor(X_np, dtype=torch.float64, device="cuda") + y = torch.as_tensor(y_np, dtype=torch.float64, device="cuda") + + direct = ElasticNet( + alpha=0.02, + l1_ratio=0.5, + max_iter=1200, + tol=1e-6, + device="torch", + compute_inference=True, + ).fit(X, y) + _assert_elasticnet_inference_contract(direct) + + cv_model = ElasticNetCV( + l1_ratio=[0.5], + alphas=[0.02], + cv=2, + max_iter=800, + tol=1e-6, + device="torch", + compute_inference=True, + random_state=19, + ).fit(X, y) + _assert_elasticnet_inference_contract(cv_model.estimator_) + + +def _require_physical_cupy_v61(): + cp = pytest.importorskip("cupy") + try: + count = int(cp.cuda.runtime.getDeviceCount()) + except Exception as exc: + pytest.skip(f"requires a physical CuPy CUDA backend: {exc}") + if count < 1: + pytest.skip("requires a physical CuPy CUDA backend") + return cp + + +def test_cupy_elasticnet_inference_contract(): + cp = _require_physical_cupy_v61() + from statgpu.linear_model import ElasticNet, ElasticNetCV + + X_np, y_np = _elasticnet_inference_fixture(seed=20260809) + X = cp.asarray(X_np) + y = cp.asarray(y_np) + + direct = ElasticNet( + alpha=0.02, + l1_ratio=0.5, + max_iter=1200, + tol=1e-6, + device="cuda", + compute_inference=True, + ).fit(X, y) + _assert_elasticnet_inference_contract(direct) + + cv_model = ElasticNetCV( + l1_ratio=[0.5], + alphas=[0.02], + cv=2, + max_iter=800, + tol=1e-6, + device="cuda", + compute_inference=True, + random_state=23, + ).fit(X, y) + _assert_elasticnet_inference_contract(cv_model.estimator_) + + + +def test_elasticnet_zero_l1_ratio_matches_same_alpha_ridge(): + from statgpu.linear_model import ElasticNet, Ridge + + rng = np.random.default_rng(20260810) + X = rng.normal(size=(80, 4)) + y = 0.45 + X @ np.array([1.2, -0.8, 0.0, 0.55]) + y = y + rng.normal(scale=0.2, size=X.shape[0]) + alpha = 0.17 + + elastic = ElasticNet( + alpha=alpha, + l1_ratio=0.0, + fit_intercept=True, + solver="fista", + max_iter=5000, + tol=1e-10, + device="cpu", + compute_inference=False, + ).fit(X, y) + ridge = Ridge( + alpha=alpha, + fit_intercept=True, + solver="exact", + device="cpu", + compute_inference=False, + ).fit(X, y) + + np.testing.assert_allclose( + elastic.coef_, ridge.coef_, rtol=2e-7, atol=2e-8 + ) + np.testing.assert_allclose( + elastic.intercept_, ridge.intercept_, rtol=2e-7, atol=2e-8 + ) diff --git a/dev/tests/test_pr80_cv_fit_boundary.py b/dev/tests/test_pr80_cv_fit_boundary.py index 00b30cee6..2d5a3f7d5 100644 --- a/dev/tests/test_pr80_cv_fit_boundary.py +++ b/dev/tests/test_pr80_cv_fit_boundary.py @@ -127,7 +127,8 @@ def test_cv_controls_use_private_canonical_fit_snapshot(): assert model.inference_mode == "STRICT" assert model.compute_inference == 0 assert model.gpu_memory_cleanup == 0 - assert model.device is Device.CPU + assert model.device == "cpu" + assert model._device is Device.CPU assert model._fit_controls.ties == "efron" assert model._fit_controls.cov_type == "nonrobust" assert model._fit_controls.inference_mode == "strict" diff --git a/dev/tests/test_pr80_fit_boundary.py b/dev/tests/test_pr80_fit_boundary.py index 4e91111cf..ae700abd1 100644 --- a/dev/tests/test_pr80_fit_boundary.py +++ b/dev/tests/test_pr80_fit_boundary.py @@ -180,7 +180,8 @@ def test_mutated_controls_use_private_canonical_fit_snapshot(): assert model.compute_inference == 0 assert model.compute_cindex == 1 assert model.gpu_memory_cleanup == 0 - assert model.device is Device.CPU + assert model.device == "cpu" + assert model._device is Device.CPU assert model._fit_controls.ties == "efron" assert model._fit_controls.cov_type == "hc1" assert model._fit_controls.inference_mode == "strict" diff --git a/dev/tests/test_pr87_classifier_output_contracts.py b/dev/tests/test_pr87_classifier_output_contracts.py new file mode 100644 index 000000000..376b0a023 --- /dev/null +++ b/dev/tests/test_pr87_classifier_output_contracts.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import numpy as np +import pytest + + +def _cpu_logistic_fixture(): + from statgpu.linear_model import LogisticRegression + + X = np.array( + [[-2.0], [-1.0], [-0.25], [0.25], [1.0], [2.0]], + dtype=np.float64, + ) + y = np.array([0, 0, 0, 1, 1, 1], dtype=np.int64) + model = LogisticRegression( + C=1.0, + max_iter=200, + tol=1e-10, + device="cpu", + compute_inference=False, + ).fit(X, y) + return model, X, y + + +def test_logistic_predict_returns_integer_labels_on_numpy(): + model, X, _ = _cpu_logistic_fixture() + prediction = model.predict(X) + + assert prediction.dtype == np.int64 + assert prediction.shape == (X.shape[0],) + assert set(np.unique(prediction)).issubset({0, 1}) + + +def test_logistic_score_flattens_single_column_binary_response(): + model, X, y = _cpu_logistic_fixture() + expected = float(np.mean(model.predict(X) == y)) + + assert model.score(X, y) == pytest.approx(expected) + assert model.score(X, y[:, None]) == pytest.approx(expected) + + +@pytest.mark.parametrize( + "threshold", + [np.nan, np.inf, -np.inf, -0.01, 1.01, True, "0.5"], +) +def test_logistic_predict_threshold_rejects_invalid_controls(threshold): + model, X, _ = _cpu_logistic_fixture() + + with pytest.raises( + ValueError, match=r"finite real number in \[0, 1\]" + ): + model.predict_with_threshold(X, threshold=threshold) + + +def test_logistic_predict_threshold_accepts_numpy_real_scalar(): + model, X, _ = _cpu_logistic_fixture() + prediction = model.predict_with_threshold( + X, threshold=np.float64(0.5) + ) + + assert prediction.dtype == np.int64 + assert prediction.shape == (X.shape[0],) + + +def test_logistic_torch_prediction_labels_are_int64(monkeypatch): + torch = pytest.importorskip("torch") + + from statgpu._config import Device + from statgpu.linear_model import LogisticRegression + + model = LogisticRegression( + device="cpu", compute_inference=False + ) + model._fitted = True + model.coef_ = np.array([1.0], dtype=np.float64) + model.intercept_ = 0.0 + + monkeypatch.setattr( + model, "_get_compute_device", lambda: Device.TORCH + ) + monkeypatch.setattr( + model, + "_to_array", + lambda value, *args, **kwargs: torch.as_tensor( + value, dtype=torch.float64 + ), + ) + + X = np.array([[-1.0], [1.0]], dtype=np.float64) + prediction = model.predict(X) + thresholded = model.predict_with_threshold(X, threshold=0.5) + + assert prediction.dtype == torch.int64 + assert thresholded.dtype == torch.int64 + assert prediction.shape == (2,) + assert thresholded.shape == (2,) + + +@pytest.mark.parametrize( + "method_name", + ["confusion_matrix", "classification_table", "evaluate_classification"], +) +@pytest.mark.parametrize("threshold", [np.nan, np.inf, -0.01, 1.01, True, "0.5"]) +def test_logistic_evaluation_threshold_contract_is_consistent( + method_name, threshold +): + model, X, y = _cpu_logistic_fixture() + method = getattr(model, method_name) + + with pytest.raises( + ValueError, match=r"finite real number in \[0, 1\]" + ): + method(X, y, threshold=threshold) + + +@pytest.mark.parametrize( + "method_name", + ["confusion_matrix", "classification_table", "evaluate_classification"], +) +def test_logistic_evaluation_threshold_accepts_numpy_real(method_name): + model, X, y = _cpu_logistic_fixture() + method = getattr(model, method_name) + + result = method(X, y, threshold=np.float64(0.5)) + assert result is not None + + +def test_logistic_confusion_and_table_support_single_class_targets(): + model, X, _ = _cpu_logistic_fixture() + y_single = np.zeros(X.shape[0], dtype=np.int64) + + matrix = model.confusion_matrix(X, y_single) + table = model.classification_table(X, y_single) + + assert matrix.shape == (2, 2) + assert int(matrix.sum()) == X.shape[0] + assert table["support_negative"] == X.shape[0] + assert table["support_positive"] == 0 + + +def test_logistic_failed_backend_fit_clears_partial_publication(monkeypatch): + model, X, y = _cpu_logistic_fixture() + + def fail_after_partial_publication(*args, **kwargs): + model.coef_ = np.array([99.0]) + model.intercept_ = 99.0 + model._params = np.array([99.0, 99.0]) + model._loglik = -1.0 + raise RuntimeError("synthetic backend failure") + + monkeypatch.setattr(model, "_fit_cpu", fail_after_partial_publication) + with pytest.raises(RuntimeError, match="synthetic backend failure"): + model.fit(X, y) + + assert model._fitted is False + assert model.coef_ is None + assert model.intercept_ is None + assert model._params is None + assert model._loglik is None + + +def test_logistic_failed_inference_clears_fitted_outputs(monkeypatch): + from statgpu.linear_model import LogisticRegression + + _, X, y = _cpu_logistic_fixture() + model = LogisticRegression( + C=1.0, + max_iter=200, + tol=1e-10, + device="cpu", + compute_inference=True, + ) + + def fail_inference(): + model._bse = np.array([1.0, 1.0]) + raise RuntimeError("synthetic inference failure") + + monkeypatch.setattr(model, "_compute_inference", fail_inference) + with pytest.raises(RuntimeError, match="synthetic inference failure"): + model.fit(X, y) + + assert model._fitted is False + assert model.coef_ is None + assert model.intercept_ is None + assert model._params is None + assert model._bse is None + assert model._loglik is None + + +def test_logistic_torch_fit_does_not_copy_weights_to_numpy(monkeypatch): + torch = pytest.importorskip("torch") + import statgpu.linear_model.wrappers._logistic as module + from statgpu.backends import get_backend + + monkeypatch.setattr(module, "_get_torch_device_str", lambda: "cpu") + X = torch.tensor( + [[-2.0], [-1.0], [-0.25], [0.25], [1.0], [2.0]], + dtype=torch.float64, + ) + y = torch.tensor([0, 0, 0, 1, 1, 1], dtype=torch.float64) + weights = torch.tensor( + [1.0, 2.0, 1.5, 3.0, 0.75, 4.0], dtype=torch.float64 + ) + model = module.LogisticRegression( + C=1.0, + max_iter=200, + tol=1e-10, + device="cpu", + compute_inference=False, + ) + monkeypatch.setattr( + model, + "_get_backend", + lambda backend="auto": get_backend("torch", device="cpu"), + ) + original_to_numpy = model._to_numpy + + def guarded_to_numpy(value): + if torch.is_tensor(value) and value.data_ptr() == weights.data_ptr(): + raise AssertionError("sample weights were copied to NumPy") + return original_to_numpy(value) + + monkeypatch.setattr(model, "_to_numpy", guarded_to_numpy) + model.fit(X, y, sample_weight=weights) + + assert model._sample_weight is None + assert np.isfinite(model.coef_).all() + assert np.isfinite(model.intercept_) + + +def test_logistic_cpu_fit_retains_weight_cache_for_cpu_inference(): + from statgpu.linear_model import LogisticRegression + + X = np.array([[-1.5], [-0.5], [0.25], [1.0], [1.75]], dtype=float) + y = np.array([0.0, 0.0, 1.0, 1.0, 1.0]) + weights = np.array([1.0, 2.0, 3.0, 1.5, 4.0]) + model = LogisticRegression( + C=2.0, + max_iter=200, + tol=1e-10, + device="cpu", + compute_inference=True, + ).fit(X, y, sample_weight=weights) + + np.testing.assert_array_equal(model._sample_weight, weights) + assert model._bse is not None + + +def test_logistic_training_hard_metrics_support_one_class_targets(capsys): + from statgpu.linear_model import LogisticRegression + + X = np.ones((8, 1), dtype=np.float64) + y = np.zeros(8, dtype=np.int64) + model = LogisticRegression( + fit_intercept=False, + C=1.0, + max_iter=200, + tol=1e-10, + device="cpu", + compute_inference=True, + ).fit(X, y) + + assert model.accuracy == pytest.approx(1.0) + assert model.precision == pytest.approx(0.0) + assert model.recall == pytest.approx(0.0) + assert model.f1 == pytest.approx(0.0) + with pytest.raises(ValueError, match="only one class"): + _ = model.auc + with pytest.raises(ValueError, match="no positive class"): + _ = model.average_precision + + model.summary() + output = capsys.readouterr().out.lower() + assert "roc-auc:" in output + assert "avg precision:" in output + assert "nan" in output + + +def test_logistic_training_metric_caches_are_independent(monkeypatch): + model, _, _ = _cpu_logistic_fixture() + calls = {"auc": 0, "ap": 0} + original_auc = model.roc_auc_score + original_ap = model.average_precision_score + + def counted_auc(X, y): + calls["auc"] += 1 + return original_auc(X, y) + + def counted_ap(X, y): + calls["ap"] += 1 + return original_ap(X, y) + + monkeypatch.setattr(model, "roc_auc_score", counted_auc) + monkeypatch.setattr(model, "average_precision_score", counted_ap) + + assert model.accuracy is not None + assert calls == {"auc": 0, "ap": 0} + first_auc = model.auc + first_ap = model.average_precision + assert model.auc == first_auc + assert model.average_precision == first_ap + assert calls == {"auc": 1, "ap": 1} + + +@pytest.mark.parametrize("metric", ["auc", "average_precision"]) +def test_logistic_summary_propagates_unrelated_metric_value_errors( + monkeypatch, metric +): + from statgpu.linear_model import LogisticRegression + + X = np.array( + [[-2.0], [-1.0], [-0.25], [0.25], [1.0], [2.0]], + dtype=np.float64, + ) + y = np.array([0, 0, 0, 1, 1, 1], dtype=np.int64) + model = LogisticRegression( + C=1.0, + max_iter=200, + tol=1e-10, + device="cpu", + compute_inference=True, + ).fit(X, y) + + if metric == "auc": + monkeypatch.setattr( + model, + "roc_auc_score", + lambda *args, **kwargs: (_ for _ in ()).throw( + ValueError("programming shape bug") + ), + ) + else: + _ = model.auc + monkeypatch.setattr( + model, + "average_precision_score", + lambda *args, **kwargs: (_ for _ in ()).throw( + ValueError("programming shape bug") + ), + ) + + with pytest.raises(ValueError, match="programming shape bug"): + model.summary() diff --git a/dev/tests/test_pr87_code_review_fix_cycle.py b/dev/tests/test_pr87_code_review_fix_cycle.py new file mode 100644 index 000000000..5006f5139 --- /dev/null +++ b/dev/tests/test_pr87_code_review_fix_cycle.py @@ -0,0 +1,856 @@ +from __future__ import annotations + +import numpy as np +import pytest + + +def test_binomial_probit_irls_matches_direct_bernoulli_optimum(): + scipy = pytest.importorskip("scipy") + from scipy.optimize import minimize + from scipy.special import ndtr + + from statgpu.glm_core._family import Binomial, ProbitLink + from statgpu.glm_core._irls import IRLSSolver + + X = np.column_stack([np.ones(10), np.linspace(-1.8, 1.8, 10)]) + y = np.array([0, 0, 0, 0, 1, 0, 1, 1, 1, 1], dtype=float) + family = Binomial(link=ProbitLink()) + params, _ = IRLSSolver(family, max_iter=100, tol=1e-9).fit( + X, y, backend="numpy" + ) + + def objective(beta): + mu = np.clip(ndtr(X @ beta), 1e-10, 1 - 1e-10) + return float(np.sum(-y * np.log(mu) - (1 - y) * np.log(1 - mu))) + + reference = minimize(objective, np.zeros(X.shape[1]), method="BFGS") + assert reference.success + np.testing.assert_allclose(params, reference.x, rtol=2e-4, atol=2e-4) + assert objective(params) <= objective(np.zeros(X.shape[1])) + + +def test_direct_logistic_rejects_soft_and_out_of_range_labels(): + from statgpu.linear_model import LogisticRegression + + X = np.arange(12, dtype=float).reshape(6, 2) + with pytest.raises(ValueError, match="binary y"): + LogisticRegression(device="cpu").fit( + X, np.array([0.0, 1.0, 0.5, 0.0, 1.0, 0.0]) + ) + with pytest.raises(ValueError, match="binary y"): + LogisticRegression(device="cpu").fit( + X, np.array([0.0, 1.0, 2.0, 0.0, 1.0, 0.0]) + ) + + +def test_irls_numpy_warm_start_is_normalized_to_torch_backend(): + torch = pytest.importorskip("torch") + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + X = torch.tensor( + [[1.0, -1.0], [1.0, 0.0], [1.0, 1.0]], dtype=torch.float64 + ) + y = torch.tensor([-1.0, 0.0, 1.0], dtype=torch.float64) + params, _ = IRLSSolver(Gaussian(), max_iter=5, tol=1e-12).fit( + X, y, init_coef=np.zeros(2), backend="torch" + ) + assert torch.is_tensor(params) + assert params.device == X.device + assert params.dtype == X.dtype + np.testing.assert_allclose(params.detach().cpu().numpy(), [0.0, 1.0], atol=1e-10) + + +def test_irls_rejects_wrong_length_warm_start(): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + with pytest.raises(ValueError, match="init_coef"): + IRLSSolver(Gaussian()).fit( + np.ones((4, 2)), np.arange(4.0), init_coef=np.zeros(3) + ) + + +@pytest.mark.parametrize( + "exc", + [ + IndexError("index 5 is out of bounds for axis 0 with size 5"), + TypeError("bad call signature"), + AttributeError("missing coefficient state"), + KeyError("scores"), + AssertionError("unexpected path state"), + ], +) +def test_cv_candidate_programming_errors_are_never_converted_to_nan(exc): + from statgpu.linear_model.penalized._penalized_cv import ( + _raise_unless_recoverable_cv_candidate_failure, + ) + + with pytest.raises(type(exc)): + _raise_unless_recoverable_cv_candidate_failure(exc) + + +def test_cv_candidate_numeric_failure_remains_explicitly_recoverable(): + from statgpu.linear_model.penalized._penalized_cv import ( + _raise_unless_recoverable_cv_candidate_failure, + ) + + _raise_unless_recoverable_cv_candidate_failure( + np.linalg.LinAlgError("singular matrix") + ) + _raise_unless_recoverable_cv_candidate_failure( + FloatingPointError("non-finite iterate") + ) + + +def test_integer_weight_sum_uses_float64_accumulator(): + from statgpu.glm_core._validation import _safe_weight_sum + + weights = np.full(5, 2**62, dtype=np.int64) + expected = float(5 * (2**62)) + assert _safe_weight_sum(weights) == pytest.approx(expected, rel=1e-15) + + +def test_reduce_overhead_repeated_cuda_calls_are_correct_or_visible_fallback(monkeypatch): + torch = pytest.importorskip("torch") + if not torch.cuda.is_available(): + pytest.skip("requires a physical Torch CUDA backend") + if torch.cuda.get_device_capability()[0] < 7: + pytest.skip("torch.compile requires CUDA capability >= 7") + + from statgpu.backends._torch_compile import compile_torch + + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "reduce-overhead") + + def update(x): + return x.square() + 1.0 + + guarded = compile_torch(update, workload="iterative") + x = torch.linspace(-2.0, 2.0, 128, device="cuda", dtype=torch.float64) + for _ in range(8): + result = guarded(x) + torch.cuda.synchronize() + assert torch.allclose(result, update(x)) + assert guarded.__statgpu_compile_status__ in { + "compiled", "runtime-fallback" + } + + +def test_integral_glm_weights_are_promoted_before_downstream_normalization(): + from statgpu.glm_core._validation import validate_glm_sample_weight + + raw = np.full(5, 2**62, dtype=np.int64) + validated = validate_glm_sample_weight(raw, raw.size) + assert validated.dtype == np.float64 + assert np.isfinite(validated.sum()) + assert validated.sum() == pytest.approx(float(5 * 2**62), rel=1e-15) + + +def test_integral_solver_weights_are_promoted_before_uniform_checks(): + from statgpu.solvers._utils import _validated_sample_weight + + raw = np.full(5, 2**62, dtype=np.int64) + backend, _, validated = _validated_sample_weight(raw, raw.size) + assert backend == "numpy" + assert validated.dtype == np.float64 + assert np.isfinite(validated.sum()) + + +def test_weighted_glm_objective_with_integral_weights_stays_finite(): + from statgpu.glm_core._logistic import LogisticLoss + from statgpu.glm_core._validation import validate_glm_sample_weight + + X = np.column_stack([np.ones(4), np.arange(4.0)]) + y = np.array([0.0, 0.0, 1.0, 1.0]) + coef = np.array([-1.0, 0.5]) + weights = validate_glm_sample_weight( + np.full(4, 2**62, dtype=np.int64), 4 + ) + value, gradient = LogisticLoss().fused_value_and_gradient( + X, y, coef, sample_weight=weights + ) + assert np.isfinite(float(value)) + assert np.isfinite(np.asarray(gradient)).all() + + +def test_torch_tensor_warm_start_does_not_use_copy_constructor_warning(): + import warnings + + torch = pytest.importorskip("torch") + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + X = torch.tensor( + [[1.0, -1.0], [1.0, 0.0], [1.0, 1.0]], dtype=torch.float64 + ) + y = torch.tensor([-1.0, 0.0, 1.0], dtype=torch.float64) + init = torch.zeros(2, dtype=torch.float32) + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + params, _ = IRLSSolver(Gaussian(), max_iter=5, tol=1e-12).fit( + X, y, init_coef=init, backend="torch" + ) + assert not any("copy construct from a tensor" in str(w.message) for w in captured) + assert params.dtype == torch.float64 + + +def _fitted_logistic_fixture(): + from statgpu.linear_model import LogisticRegression + + X = np.array( + [[-2.0], [-1.0], [-0.25], [0.25], [1.0], [2.0]], dtype=float + ) + y = np.array([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]) + model = LogisticRegression( + device="cpu", max_iter=100, compute_inference=False + ).fit(X, y) + return model, X, y + + +def _assert_logistic_state_cleared(model): + assert model._fitted is False + assert model.coef_ is None + assert model.intercept_ is None + assert model.n_iter_ is None + assert model._params is None + assert model._X_design is None + assert model._y is None + assert model._accuracy is None + with pytest.raises(RuntimeError, match="fitted"): + model.predict(np.zeros((1, 1))) + + +def test_logistic_invalid_binary_refit_clears_stale_state(): + model, X, _ = _fitted_logistic_fixture() + with pytest.raises(ValueError, match="binary y"): + model.fit(X, np.array([0.0, 0.0, 0.5, 1.0, 1.0, 1.0])) + _assert_logistic_state_cleared(model) + + +def test_logistic_nonfinite_refit_clears_stale_state_before_shared_guard(): + model, X, y = _fitted_logistic_fixture() + bad_y = y.copy() + bad_y[0] = np.nan + with pytest.raises(ValueError, match="finite"): + model.fit(X, bad_y) + _assert_logistic_state_cleared(model) + + +@pytest.mark.parametrize( + "bad_X, message", + [ + (np.asarray(1.0), "two-dimensional design matrix"), + (np.arange(6.0), "two-dimensional design matrix"), + (np.empty((0, 1)), "at least one observation"), + ], +) +def test_logistic_design_boundary_has_public_error_and_clears_state( + bad_X, message +): + model, _, _ = _fitted_logistic_fixture() + with pytest.raises(ValueError, match=message): + model.fit(bad_X, np.empty(0)) + _assert_logistic_state_cleared(model) + + +def test_base_fit_guard_prefers_general_transaction_hook(): + from statgpu._base import BaseEstimator + + class TransactionalEstimator(BaseEstimator): + def __init__(self): + super().__init__(device="cpu") + self.reset_calls = 0 + self.body_calls = 0 + + def _reset_fit_state(self): + self.reset_calls += 1 + self._fitted = False + + def fit(self, X, y=None): + self.body_calls += 1 + self._fitted = True + return self + + def predict(self, X): + return np.zeros(len(X)) + + model = TransactionalEstimator() + with pytest.raises(ValueError, match="finite"): + model.fit(np.array([[np.nan]]), np.array([0.0])) + assert model.reset_calls == 1 + assert model.body_calls == 0 + assert model._fitted is False + + +def test_shared_irls_convergence_on_last_iteration_emits_no_false_warning(): + import warnings + + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + from statgpu.solvers import ConvergenceWarning + + X = np.column_stack([np.ones(5), np.linspace(-1.0, 1.0, 5)]) + y = 0.5 + 2.0 * X[:, 1] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + params, n_iter = IRLSSolver(Gaussian(), max_iter=1, tol=1e-12).fit( + X, y, backend="numpy" + ) + assert n_iter == 1 + assert not any(isinstance(w.message, ConvergenceWarning) for w in caught) + np.testing.assert_allclose(params, [0.5, 2.0], atol=1e-12) + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ({"max_iter": 0}, "max_iter"), + ({"max_iter": True}, "max_iter"), + ({"tol": 0.0}, "tol"), + ({"tol": np.nan}, "tol"), + ({"ridge_alpha": -1.0}, "ridge_alpha"), + ({"ridge_penalize_intercept": 1}, "ridge_penalize_intercept"), + ({"backend": "mystery"}, "backend"), + ], +) +def test_shared_irls_rejects_invalid_controls(kwargs, message): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + solver_kwargs = {k: v for k, v in kwargs.items() if k in {"max_iter", "tol"}} + fit_kwargs = {k: v for k, v in kwargs.items() if k not in solver_kwargs} + with pytest.raises(ValueError, match=message): + IRLSSolver(Gaussian(), **solver_kwargs).fit( + np.ones((4, 1)), np.arange(4.0), **fit_kwargs + ) + + +def test_shared_irls_rejects_bad_penalty_matrix_shape(): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + with pytest.raises(ValueError, match="penalty_matrix"): + IRLSSolver(Gaussian()).fit( + np.ones((4, 2)), np.arange(4.0), penalty_matrix=np.eye(3) + ) + + +def test_logistic_nonconvergence_is_visible_and_state_is_published(): + from statgpu.linear_model import LogisticRegression + from statgpu.solvers import ConvergenceWarning + + X = np.linspace(-2.0, 2.0, 20)[:, None] + y = (X[:, 0] > 0).astype(float) + model = LogisticRegression( + C=1.0, max_iter=1, tol=1e-14, device="cpu", + compute_inference=False, + ) + with pytest.warns(ConvergenceWarning, match="did not converge"): + model.fit(X, y) + assert model._fitted is True + assert model.converged_ is False + assert model.n_iter_ == 1 + + +def test_logistic_zero_C_legacy_unregularized_path_stays_finite(): + from statgpu.linear_model import LogisticRegression + + rng = np.random.default_rng(20260806) + X = rng.normal(size=(80, 2)) + probability = 1.0 / (1.0 + np.exp(-(0.2 + X @ np.array([0.7, -0.4])))) + y = (rng.random(80) < probability).astype(float) + model = LogisticRegression( + C=0.0, max_iter=100, device="cpu", compute_inference=False + ).fit(X, y) + assert np.isfinite(model.coef_).all() + assert np.isfinite(model.intercept_) + + +@pytest.mark.parametrize( + "name, value, message", + [ + ("fit_intercept", "False", "fit_intercept"), + ("C", -1.0, "C"), + ("C", np.inf, "C"), + ("max_iter", 0, "max_iter"), + ("tol", 0.0, "tol"), + ("compute_inference", "False", "compute_inference"), + ("gpu_memory_cleanup", "False", "gpu_memory_cleanup"), + ("cov_type", "invalid", "cov_type"), + ("hac_maxlags", 1.5, "hac_maxlags"), + ], +) +def test_logistic_invalid_mutated_control_clears_stale_state( + name, value, message +): + model, X, y = _fitted_logistic_fixture() + setattr(model, name, value) + with pytest.raises(ValueError, match=message): + model.fit(X, y) + _assert_logistic_state_cleared(model) + + +def test_logistic_direct_control_mutation_is_used_by_refit(): + from statgpu.linear_model import LogisticRegression + + rng = np.random.default_rng(20260807) + X = rng.normal(size=(120, 2)) + p = 1.0 / (1.0 + np.exp(-(0.3 + X @ np.array([0.8, -0.5])))) + y = (rng.random(120) < p).astype(float) + model = LogisticRegression( + C=1.0, max_iter=100, fit_intercept=True, device="cpu", + compute_inference=False, + ).fit(X, y) + model.fit_intercept = False + model.C = 0.0 + model.max_iter = 200 + model.tol = 1e-8 + model.fit(X, y) + assert model._fit_intercept is False + assert model._C == 0.0 + assert model._max_iter == 200 + assert model._tol == pytest.approx(1e-8) + assert model.intercept_ == 0.0 + + +def _cv_evaluation_owner(loss_name): + from statgpu.linear_model.penalized._penalized_cv import PenalizedGLM_CV + + owner = object.__new__(PenalizedGLM_CV) + owner.loss = loss_name + return owner + + +class _CVScoreModel: + fit_intercept = True + intercept_ = 0.25 + coef_ = np.array([0.5]) + + def predict(self, X): + return self.intercept_ + np.asarray(X) @ self.coef_ + + +def test_cv_primary_scoring_programming_error_is_not_silently_retried(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + class Loss: + def value(self, *args, **kwargs): + raise AssertionError("generic scorer must not run") + + monkeypatch.setattr( + cv_mod, '_evaluate_loss_numpy', + lambda *args, **kwargs: (_ for _ in ()).throw(TypeError('bad scoring signature')), + ) + with pytest.raises(TypeError, match='bad scoring signature'): + _cv_evaluation_owner('poisson')._evaluate_single( + _CVScoreModel(), np.ones((3, 1)), np.ones(3), loss_fn=Loss() + ) + + +def test_cv_recoverable_primary_scoring_fallback_is_visible(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + calls = {'generic': 0} + + class Loss: + def value(self, X, y, coef, sample_weight=None): + calls['generic'] += 1 + return 2.75 + + monkeypatch.setattr( + cv_mod, '_evaluate_loss_numpy', + lambda *args, **kwargs: (_ for _ in ()).throw( + NotImplementedError('optimized scorer unavailable') + ), + ) + with pytest.warns(RuntimeWarning, match='generic loss interface'): + value = _cv_evaluation_owner('poisson')._evaluate_single( + _CVScoreModel(), np.ones((3, 1)), np.ones(3), loss_fn=Loss() + ) + assert value == pytest.approx(2.75) + assert calls == {'generic': 1} + + +def test_cv_generic_scoring_programming_error_is_not_converted_to_mse(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + class Loss: + def value(self, *args, **kwargs): + raise TypeError('generic scorer bug') + + monkeypatch.setattr( + cv_mod, '_evaluate_loss_numpy', + lambda *args, **kwargs: (_ for _ in ()).throw( + NotImplementedError('optimized scorer unavailable') + ), + ) + with pytest.warns(RuntimeWarning, match='generic loss interface'): + with pytest.raises(TypeError, match='generic scorer bug'): + _cv_evaluation_owner('squared_error')._evaluate_single( + _CVScoreModel(), np.ones((3, 1)), np.ones(3), loss_fn=Loss() + ) + + +def test_cv_squared_error_numeric_failure_uses_visible_equivalent_mse(monkeypatch): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + class Loss: + def value(self, *args, **kwargs): + raise FloatingPointError('generic non-finite score') + + monkeypatch.setattr( + cv_mod, '_evaluate_loss_numpy', + lambda *args, **kwargs: (_ for _ in ()).throw( + FloatingPointError('optimized non-finite score') + ), + ) + X = np.arange(3.0)[:, None] + y = np.array([0.0, 1.0, 2.0]) + with pytest.warns(RuntimeWarning) as caught: + value = _cv_evaluation_owner('squared_error')._evaluate_single( + _CVScoreModel(), X, y, loss_fn=Loss() + ) + assert len(caught) == 2 + expected = np.mean((y - _CVScoreModel().predict(X)) ** 2) + assert value == pytest.approx(expected) + + +def test_irls_cupy_rank_failure_uses_lstsq_and_oom_propagates(monkeypatch): + import sys + import types + from statgpu.glm_core._irls import _solve + + fake = types.ModuleType('cupy') + calls = {'lstsq': 0} + + class Linalg: + @staticmethod + def solve(A, b): + raise RuntimeError('singular matrix') + + @staticmethod + def lstsq(A, b): + calls['lstsq'] += 1 + return (np.array([3.0]), None, None, None) + + fake.linalg = Linalg() + monkeypatch.setitem(sys.modules, 'cupy', fake) + result = _solve(np.eye(1), np.ones(1), backend='cupy') + np.testing.assert_allclose(result, [3.0]) + assert calls == {'lstsq': 1} + + def oom(A, b): + raise RuntimeError('CUDA out of memory') + + fake.linalg.solve = oom + with pytest.raises(RuntimeError, match='out of memory'): + _solve(np.eye(1), np.ones(1), backend='cupy') + assert calls == {'lstsq': 1} + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ({"cov_type": 1}, "cov_type"), + ({"hac_maxlags": 1.5}, "hac_maxlags"), + ({"hac_maxlags": True}, "hac_maxlags"), + ({"gpu_memory_cleanup": "False"}, "gpu_memory_cleanup"), + ], +) +def test_logistic_constructor_rejects_silently_coerced_types(kwargs, message): + from statgpu.linear_model import LogisticRegression + + with pytest.raises(ValueError, match=message): + LogisticRegression(**kwargs) + + +def test_irls_penalty_matrix_matches_quadratic_contract(): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + X = np.column_stack([np.ones(6), np.linspace(-1.0, 1.0, 6)]) + y = 0.4 + 1.2 * X[:, 1] + penalty = np.diag([0.0, 0.5]) + params, _ = IRLSSolver(Gaussian(), max_iter=10, tol=1e-12).fit( + X, y, penalty_matrix=penalty + ) + expected = np.linalg.solve(X.T @ X + penalty, X.T @ y) + np.testing.assert_allclose(params, expected, rtol=1e-12, atol=1e-12) + + +@pytest.mark.parametrize( + "penalty, message", + [ + (np.array([[0.0, 1.0], [0.0, 0.0]]), "symmetric"), + (np.diag([0.0, -1.0]), "positive semidefinite"), + (np.array([[0.0, np.nan], [np.nan, 1.0]]), "finite"), + (np.array([[0.0, 1.0j], [-1.0j, 1.0]]), "real numeric"), + ], +) +def test_irls_rejects_invalid_quadratic_penalty_matrix(penalty, message): + from statgpu.glm_core._family import Gaussian + from statgpu.glm_core._irls import IRLSSolver + + with pytest.raises(ValueError, match=message): + IRLSSolver(Gaussian()).fit( + np.ones((5, 2)), np.arange(5.0), penalty_matrix=penalty + ) + + +def _patch_sparse_cv_loss(monkeypatch, loss): + import statgpu.linear_model.penalized._fit_mixin as fit_mixin + + monkeypatch.setattr( + fit_mixin, '_resolve_loss_name', lambda *args, **kwargs: loss + ) + + +def test_sparse_cv_lipschitz_programming_value_error_propagates(monkeypatch): + from statgpu.linear_model.penalized._penalized_cv import _glm_sparse_cv_path + + class Loss: + _lipschitz_at_init = False + + def lipschitz(self, *args, **kwargs): + raise ValueError('programming shape bug') + + _patch_sparse_cv_loss(monkeypatch, Loss()) + with pytest.raises(ValueError, match='programming shape bug'): + _glm_sparse_cv_path( + 'logistic', np.ones((6, 1)), np.array([0, 0, 0, 1, 1, 1]), + np.array([0.1]), 'l1', 1.0, 5, 1e-4, 'cpu' + ) + + +def test_sparse_cv_recoverable_lipschitz_fallback_is_visible(monkeypatch): + import statgpu.solvers as solvers + from statgpu.linear_model.penalized._penalized_cv import _glm_sparse_cv_path + + class Loss: + _lipschitz_at_init = False + + def lipschitz(self, *args, **kwargs): + raise NotImplementedError('no closed-form hint') + + def fake_solver(loss, penalty, X, y, **kwargs): + assert 'lipschitz_L' not in kwargs + return np.zeros(X.shape[1]), 1 + + _patch_sparse_cv_loss(monkeypatch, Loss()) + monkeypatch.setattr(solvers, 'fista_solver', fake_solver) + with pytest.warns(RuntimeWarning, match='solver will estimate'): + result = _glm_sparse_cv_path( + 'logistic', np.ones((6, 1)), np.array([0, 0, 0, 1, 1, 1]), + np.array([0.1]), 'l1', 1.0, 5, 1e-4, 'cpu', return_path=True + ) + assert result['n_iter'].tolist() == [1] + assert result['coef'].shape == (1, 1) + + +def test_sparse_cv_invalid_lipschitz_value_fallback_is_visible(monkeypatch): + import statgpu.solvers as solvers + from statgpu.linear_model.penalized._penalized_cv import _glm_sparse_cv_path + + class Loss: + _lipschitz_at_init = False + + def lipschitz(self, *args, **kwargs): + return np.nan + + def fake_solver(loss, penalty, X, y, **kwargs): + assert 'lipschitz_L' not in kwargs + return np.zeros(X.shape[1]), 1 + + _patch_sparse_cv_loss(monkeypatch, Loss()) + monkeypatch.setattr(solvers, 'fista_solver', fake_solver) + with pytest.warns(RuntimeWarning, match='non-finite or non-positive'): + result = _glm_sparse_cv_path( + 'logistic', np.ones((6, 1)), np.array([0, 0, 0, 1, 1, 1]), + np.array([0.1]), 'l1', 1.0, 5, 1e-4, 'cpu', return_path=True + ) + assert result['n_iter'].tolist() == [1] + + +def test_logistic_failed_refit_clears_gpu_accuracy_shadow(): + model, X, _ = _fitted_logistic_fixture() + model._accuracy = 0.875 + with pytest.raises(ValueError, match="binary y"): + model.fit(X, np.array([0.0, 0.0, 0.5, 1.0, 1.0, 1.0])) + assert model._accuracy is None + + +def test_logistic_cpu_likelihood_diagnostics_do_not_require_inference(): + from statgpu.linear_model import LogisticRegression + + X = np.array([[-1.5], [-0.5], [0.25], [1.0], [1.75]], dtype=float) + y = np.array([0.0, 0.0, 1.0, 1.0, 1.0]) + weights = np.array([1.0, 2.0, 3.0, 1.5, 4.0]) + model = LogisticRegression( + C=2.0, max_iter=200, tol=1e-10, device="cpu", + compute_inference=False, + ).fit(X, y, sample_weight=weights) + eta = model.intercept_ + X @ model.coef_ + probability = np.clip(1.0 / (1.0 + np.exp(-eta)), 1e-15, 1.0 - 1e-15) + expected = np.sum( + weights * (y * np.log(probability) + (1.0 - y) * np.log(1.0 - probability)) + ) + y_mean = np.average(y, weights=weights) + expected_null = np.sum( + weights * (y * np.log(y_mean) + (1.0 - y) * np.log(1.0 - y_mean)) + ) + assert model.loglikelihood == pytest.approx(expected, rel=1e-12, abs=1e-12) + assert model.loglikelihood_null == pytest.approx(expected_null, rel=1e-12, abs=1e-12) + assert np.isfinite(model.aic) + assert np.isfinite(model.bic) + assert np.isfinite(model.pseudo_rsquared) + assert model._bse is None + + +@pytest.mark.parametrize( + "exc", + [ + ValueError("programming shape bug"), + TypeError("programming signature bug"), + ], +) +def test_cv_scoring_programming_errors_stay_fatal(monkeypatch, exc): + import statgpu.linear_model.penalized._penalized_cv as cv_mod + + class Model: + coef_ = np.array([0.0]) + intercept_ = 0.0 + fit_intercept = True + + def predict(self, X): + return np.zeros(len(X)) + + class Loss: + def value(self, *args, **kwargs): + pytest.fail("programming errors must not retry generic scoring") + + owner = object.__new__(cv_mod.PenalizedGLM_CV) + owner.loss = "poisson" + monkeypatch.setattr( + cv_mod, + "_evaluate_loss_numpy", + lambda *args, **kwargs: (_ for _ in ()).throw(exc), + ) + with pytest.raises(type(exc), match="programming"): + owner._evaluate_single( + Model(), np.ones((2, 1)), np.ones(2), loss_fn=Loss() + ) + + +def test_logistic_wrapper_reuses_registered_objective_on_all_backends(): + import inspect + import statgpu.linear_model.wrappers._logistic as module + + source = inspect.getsource(module.LogisticRegression) + assert source.count("LogisticLoss().per_sample_value") == 3 + assert "log(p + 1e-10)" not in source + assert "log(1 - p + 1e-10)" not in source + + +def test_logistic_cpu_likelihood_matches_stable_registered_objective(): + from statgpu.glm_core._logistic import LogisticLoss + from statgpu.linear_model import LogisticRegression + + X = np.array([[-30.0], [-10.0], [10.0], [30.0]], dtype=float) + y = np.array([0.0, 0.0, 1.0, 1.0]) + weights = np.array([1.0, 2.0, 3.0, 4.0]) + model = LogisticRegression( + C=0.5, max_iter=200, tol=1e-10, device="cpu", + compute_inference=False, + ).fit(X, y, sample_weight=weights) + eta = model.intercept_ + X @ model.coef_ + expected = -np.sum(weights * LogisticLoss().per_sample_value(eta, y)) + assert model.loglikelihood == pytest.approx(expected, rel=1e-13, abs=1e-13) + + +def test_logistic_private_torch_path_matches_registered_objective(monkeypatch): + torch = pytest.importorskip("torch") + import statgpu.linear_model.wrappers._logistic as module + from statgpu.glm_core._logistic import LogisticLoss + + monkeypatch.setattr(module, "_get_torch_device_str", lambda: "cpu") + X = torch.tensor([[-8.0], [-2.0], [2.0], [8.0]], dtype=torch.float64) + y = torch.tensor([0.0, 0.0, 1.0, 1.0], dtype=torch.float64) + weights = torch.tensor([1.0, 2.0, 3.0, 4.0], dtype=torch.float64) + model = module.LogisticRegression( + C=1.0, max_iter=200, tol=1e-10, device="torch", + compute_inference=False, + ) + model._validate_fit_controls() + model._fit_torch(X, y, sample_weight=weights) + params = torch.as_tensor(model._params, dtype=torch.float64) + design = torch.cat([torch.ones((X.shape[0], 1), dtype=X.dtype), X], dim=1) + eta = design @ params + expected = -torch.sum( + weights * LogisticLoss().per_sample_value(eta, y) + ).item() + assert model._loglik == pytest.approx(expected, rel=1e-13, abs=1e-13) + + +def test_logistic_inference_does_not_overwrite_stable_likelihood(): + from statgpu.linear_model import LogisticRegression + + X = np.array([[-30.0], [-10.0], [10.0], [30.0]], dtype=float) + y = np.array([0.0, 0.0, 1.0, 1.0]) + weights = np.array([1.0, 2.0, 3.0, 4.0]) + no_inference = LogisticRegression( + C=0.5, max_iter=200, tol=1e-10, device="cpu", + compute_inference=False, + ).fit(X, y, sample_weight=weights) + with_inference = LogisticRegression( + C=0.5, max_iter=200, tol=1e-10, device="cpu", + compute_inference=True, + ).fit(X, y, sample_weight=weights) + + np.testing.assert_allclose( + with_inference.coef_, no_inference.coef_, rtol=0.0, atol=0.0 + ) + assert with_inference.intercept_ == no_inference.intercept_ + assert with_inference.loglikelihood == no_inference.loglikelihood + assert with_inference.loglikelihood_null == no_inference.loglikelihood_null + assert with_inference._bse is not None + + +def test_logistic_inference_source_does_not_recompute_likelihood(): + import inspect + import statgpu.linear_model.wrappers._logistic as module + + source = inspect.getsource(module.LogisticRegression._compute_inference) + assert "self._loglik =" not in source + assert "self._loglik_null =" not in source + assert "Inference must not overwrite" in source + + +def test_unknown_cv_loss_preserves_validation_sample_weight(): + from statgpu.linear_model.penalized._penalized_cv import _evaluate_loss_numpy + + observed = {} + + class CustomLoss: + def value(self, X, y, coef, sample_weight=None): + observed["sample_weight"] = np.asarray(sample_weight).copy() + residual = np.asarray(y) - np.asarray(X) @ np.asarray(coef) + weights = np.asarray(sample_weight, dtype=np.float64) + return float(np.dot(weights, residual ** 2) / weights.sum()) + + X = np.array([[1.0], [2.0], [4.0]]) + y = np.array([1.0, 1.0, 5.0]) + weights = np.array([1.0, 3.0, 7.0]) + value = _evaluate_loss_numpy( + "custom_weighted_loss", + CustomLoss(), + X, + y, + np.array([1.0]), + 0.0, + False, + sample_weight=weights, + ) + + expected = np.dot(weights, (y - X[:, 0]) ** 2) / weights.sum() + assert value == pytest.approx(expected) + np.testing.assert_array_equal(observed["sample_weight"], weights) diff --git a/dev/tests/test_second_full_review.py b/dev/tests/test_second_full_review.py index 3acbc4576..46cf540ee 100644 --- a/dev/tests/test_second_full_review.py +++ b/dev/tests/test_second_full_review.py @@ -281,16 +281,6 @@ def test_constructor_inputs_are_copied(self): assert composite.weights == (0.25, 0.75) class TestEstimatorCloneAndFeatureSelectionBackend: - @pytest.mark.xfail( - condition=_SKLEARN_LT_13, - reason=( - "Pre-existing sklearn<=1.2 clone incompatibility: 26 estimators " - "canonicalize or copy public constructor parameters (cov_type, " - "solver, loss, penalty, kernel, alphas, etc.). " - + _SKLEARN_CLONE_ISSUE - ), - strict=True, - ) def test_all_default_public_estimators_clone(self): import inspect import statgpu diff --git a/dev/tests/test_torch_compile_benchmark_contract.py b/dev/tests/test_torch_compile_benchmark_contract.py new file mode 100644 index 000000000..dd4cdc197 --- /dev/null +++ b/dev/tests/test_torch_compile_benchmark_contract.py @@ -0,0 +1,77 @@ +"""Regression tests for the physical torch.compile benchmark evidence policy.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +_BENCHMARK_PATH = ( + Path(__file__).resolve().parents[2] + / "dev" + / "benchmarks" + / "benchmark_torch_compile_maintenance.py" +) +_SPEC = importlib.util.spec_from_file_location( + "benchmark_torch_compile_maintenance", _BENCHMARK_PATH +) +_BENCHMARK = importlib.util.module_from_spec(_SPEC) +assert _SPEC.loader is not None +_SPEC.loader.exec_module(_BENCHMARK) + + +def test_compile_evidence_accepts_new_and_cached_compiled_callables(): + assert ( + _BENCHMARK._validate_compile_evidence( + "default", "scad", ({"status": "compiled"},), 2 + ) + == "compiled-diagnostic-and-dynamo-graph" + ) + assert ( + _BENCHMARK._validate_compile_evidence( + "default", + "mcp", + (), + 2, + cached_callable_observed=True, + ) + == "cached-callable-and-dynamo-graph" + ) + + +def test_compile_evidence_requires_prior_diagnostic_for_cache_reuse(): + with pytest.raises(RuntimeError, match="compiled diagnostic"): + _BENCHMARK._validate_compile_evidence("default", "mcp", (), 2) + + +def test_compile_evidence_is_not_required_when_compilation_is_disabled(): + assert ( + _BENCHMARK._validate_compile_evidence("disable", "mcp", (), 0) + == "not-applicable" + ) + + +@pytest.mark.parametrize("graph_delta", [0, -1]) +def test_compile_evidence_requires_case_local_dynamo_graph(graph_delta): + with pytest.raises(RuntimeError, match="did not create a Dynamo graph"): + _BENCHMARK._validate_compile_evidence( + "default", "mcp", (), graph_delta + ) + + +@pytest.mark.parametrize( + "events, message", + [ + (({"status": "runtime-fallback"},), "entered fallback"), + (({"status": "construction-fallback"},), "entered fallback"), + (({"status": "disabled"},), "no compiled event"), + (({"status": "unavailable"},), "no compiled event"), + ], +) +def test_compile_evidence_rejects_noncompiled_diagnostics(events, message): + with pytest.raises(RuntimeError, match=message): + _BENCHMARK._validate_compile_evidence( + "default", "mcp", events, 1 + ) diff --git a/dev/tests/test_torch_compile_default_policy.py b/dev/tests/test_torch_compile_default_policy.py new file mode 100644 index 000000000..f9f5de149 --- /dev/null +++ b/dev/tests/test_torch_compile_default_policy.py @@ -0,0 +1,34 @@ +"""Regression coverage for the opt-in torch.compile policy.""" + +from __future__ import annotations + + +def test_auto_mode_returns_observable_eager_wrapper(monkeypatch): + from statgpu.backends._torch_compile import ( + compile_torch, + get_torch_compile_diagnostics, + ) + + monkeypatch.delenv("STATGPU_TORCH_COMPILE_MODE", raising=False) + get_torch_compile_diagnostics(clear=True) + + wrapped = compile_torch(lambda value: value + 1, workload="iterative") + + assert wrapped(2) == 3 + assert wrapped.__statgpu_compile_mode__ is None + assert wrapped.__statgpu_compile_status__ == "disabled" + events = get_torch_compile_diagnostics(clear=True) + assert events[-1]["status"] == "disabled" + assert events[-1]["mode"] is None + + +def test_requested_mode_cannot_silently_override_auto(monkeypatch): + from statgpu.backends._torch_compile import resolve_torch_compile_mode + + monkeypatch.setenv("STATGPU_TORCH_COMPILE_MODE", "auto") + assert resolve_torch_compile_mode( + workload="general", requested_mode="default" + ) is None + assert resolve_torch_compile_mode( + workload="general", requested_mode="reduce-overhead" + ) is None diff --git a/dev/tests/test_torch_compile_scale_benchmark_contract.py b/dev/tests/test_torch_compile_scale_benchmark_contract.py new file mode 100644 index 000000000..42e210f65 --- /dev/null +++ b/dev/tests/test_torch_compile_scale_benchmark_contract.py @@ -0,0 +1,138 @@ +"""Regression tests for the torch.compile scale-crossover benchmark.""" + +from __future__ import annotations + +import argparse +import importlib.util +from pathlib import Path + +import pytest + + +_BENCHMARK_PATH = ( + Path(__file__).resolve().parents[2] + / "dev" + / "benchmarks" + / "benchmark_torch_compile_scale.py" +) +_SPEC = importlib.util.spec_from_file_location( + "benchmark_torch_compile_scale", _BENCHMARK_PATH +) +_BENCHMARK = importlib.util.module_from_spec(_SPEC) +assert _SPEC.loader is not None +_SPEC.loader.exec_module(_BENCHMARK) + + +def test_parse_scales_deduplicates_and_preserves_order(): + assert _BENCHMARK._parse_scales("1024x64, 4096×64,1024x64") == ( + (1024, 64), + (4096, 64), + ) + + +@pytest.mark.parametrize("value", ["", "1024", "0x64", "abcx64", "64x-1"]) +def test_parse_scales_rejects_invalid_values(value): + with pytest.raises(argparse.ArgumentTypeError): + _BENCHMARK._parse_scales(value) + + +def test_parse_cases_validates_and_deduplicates(): + assert _BENCHMARK._parse_cases("lasso,group_scad,lasso") == ( + "lasso", + "group_scad", + ) + with pytest.raises(argparse.ArgumentTypeError, match="unknown case"): + _BENCHMARK._parse_cases("ridge") + + +def test_standard_preset_separates_n_and_p_scaling(): + scales, cases, repeats = _BENCHMARK._resolve_plan( + "standard", None, None, None + ) + assert (1024, 64) in scales + assert (4096, 64) in scales + assert (4096, 256) in scales + assert cases == _BENCHMARK._CASE_NAMES + assert repeats == 7 + + +def test_scale_benchmark_requires_cold_and_multiple_warm_observations(): + with pytest.raises(ValueError, match="at least 3 repeats"): + _BENCHMARK._resolve_plan("quick", None, None, 2) + + +def test_timing_summary_reports_dispersion_speedup_and_break_even(): + summary = _BENCHMARK._summarize_timings( + eager_values=[10.0, 11.0, 12.0], + compiled_values=[30.0, 5.0, 6.0, 5.0], + ) + assert summary["eager"]["median"] == 11.0 + assert summary["eager"]["min"] == 10.0 + assert summary["eager"]["max"] == 12.0 + assert summary["eager"]["iqr"] == pytest.approx(1.0) + assert summary["compiled_cold"] == 30.0 + assert summary["compiled_warm"]["median"] == 5.0 + assert summary["warm_speedup"] == pytest.approx(2.2) + assert summary["cold_overhead_ratio"] == pytest.approx(30.0 / 11.0) + assert summary["break_even_additional_warm_fits"] == 4 + assert summary["break_even_total_fits"] == 5 + + +def test_timing_summary_marks_nonamortizable_warm_slowdown(): + summary = _BENCHMARK._summarize_timings( + eager_values=[1.0, 1.1, 0.9], + compiled_values=[4.0, 1.2, 1.3], + ) + assert summary["warm_speedup"] < 1.0 + assert summary["break_even_additional_warm_fits"] is None + assert summary["break_even_total_fits"] is None + + +def test_compile_evidence_distinguishes_eager_and_explicit_compile(): + assert ( + _BENCHMARK._validate_mode_evidence( + "disable", ({"status": "disabled"},), 0 + ) + == "eager-no-dynamo-graph" + ) + assert ( + _BENCHMARK._validate_mode_evidence( + "default", ({"status": "compiled"},), 1 + ) + == "compiled-diagnostic-and-dynamo-graph" + ) + with pytest.raises(RuntimeError, match="did not create"): + _BENCHMARK._validate_mode_evidence( + "default", ({"status": "compiled"},), 0 + ) + + +def test_markdown_summary_exposes_scale_and_break_even(tmp_path): + report = { + "preset": "quick", + "repeats": 3, + "environment": {"gpu": "GPU", "torch": "2.x", "cuda": "12.x"}, + "results": [ + { + "axis": "baseline", + "n_samples": 1024, + "n_features": 64, + "case": "lasso", + "timing_summary": { + "eager": {"median": 1.0}, + "compiled_cold": 4.0, + "compiled_warm": {"median": 0.5}, + "warm_speedup": 2.0, + "cold_overhead_ratio": 4.0, + "break_even_total_fits": 7, + }, + } + ], + } + markdown = _BENCHMARK._render_markdown( + report, tmp_path / "torch_compile_scale.json" + ) + assert "1024" in markdown + assert "`lasso`" in markdown + assert "2.000x" in markdown + assert "break-even total fits" in markdown diff --git a/docs/cn/changelog.md b/docs/cn/changelog.md index ede007d6b..43b270a9e 100644 --- a/docs/cn/changelog.md +++ b/docs/cn/changelog.md @@ -1,10 +1,105 @@ # Changelog +- 将直接 LogisticRegression 的训练集混淆指标与 ROC/PR 评估解耦,使单一类别目标仍可获得 accuracy、precision、recall 与 F1,同时排序指标保留其显式类别支持要求;summary 会将不可用的排序指标显示为 NaN。 + +- 直接 LogisticRegression 的解析权重在 CuPy/Torch 拟合中保持设备原生,不再仅为 CPU 推断缓存将整条权重向量复制到 NumPy。 + +- 闭合直接 LogisticRegression 与惩罚 CV 的后续审查缺口:失败拟合会清除半发布状态,单一类别仍可计算混淆矩阵/分类表,自定义验证损失保留解析权重。 + +- 统一直接 LogisticRegression 在 NumPy、CuPy 与 Torch 下的预测契约:硬标签使用整数 dtype,单列响应评分不再发生广播,非有限决策阈值会被拒绝。 + +- 将拟合似然诊断与协方差推断解耦,开启推断不会改变 AIC、BIC 或伪 R²。 + +- 统一 CPU、CuPy 与 Torch 的拟合对数似然诊断,全部复用数值稳定的 LogisticLoss 注册目标。 + +- 完成标量 GLM 运行时契约的 code-review 修复循环:严格二分类标签与控制参数、事务性重拟合、显式收敛状态,以及跨后端一致的解析权重诊断。 + +- 修正任意 link 的 Binomial IRLS、后端原生 warm start、二次惩罚校验与惩罚 CV 的显式降级语义。 + +- 删除当前 exact-head 环境未能支撑的 ElasticNet 通用后端阈值、统一系数容差与固定加速比;模型文档现要求针对具体工作负载进行 benchmark,并按 dtype/求解路径验证数值一致性。 + +- 修正 ElasticNet/Ridge 的缩放说明,并补充回归测试确认在共享平均损失尺度下 `ElasticNet(alpha, l1_ratio=0)` 与 `Ridge(alpha)` 一致。 + +- 统一 ElasticNet API 文档与实现:修正构造参数默认值、删除不存在的参数,并用实际 FISTA 与拟合后推断语义替换过时的 strict/approx 说明。 + +- 完成公开 ElasticNet 推断契约:独立 wrapper 现暴露并透传推断选项,ElasticNetCV 的最终全数据重拟合会真实执行 `compute_inference=True`,并补充 NumPy/CuPy/Torch 矩阵测试。 + +- 将事务式 CV 重置接入共享的公开有限值校验,使 NaN/Inf 重拟合在抛错前先使旧的 RidgeCV、ElasticNetCV、LogisticRegressionCV 与统一 penalized-CV 状态失效。 + +- 使专用 RidgeCV、ElasticNetCV 与 LogisticRegressionCV 的重拟合具备失败安全语义:每次 fit 均先清除旧拟合状态,仅在最终模型重拟合成功后发布 CV 选择结果。 + +- 将 AUTO 模式的 RidgeCV、ElasticNetCV 与 LogisticRegressionCV 最终重拟合固定到 CV 选参时使用的后端,避免选参后在 Torch 与 CuPy 之间静默漂移。 + +- 在公开 RidgeCV、ElasticNetCV 与 LogisticRegressionCV 调度中保留 `device='auto'`,使 GPU 常驻输入继续使用其原有后端;LogisticRegressionCV 现可在不完整复制到 CPU 的情况下验证 0/1 响应。 + +- Logistic 与 ElasticNet CV 的默认正则化网格现在纳入解析权重并满足整数权重的行复制等价性;CV 的 GPU 数组设备检查不再掩盖运行时错误。 + +- 专用 Ridge、ElasticNet 与 Logistic CV 现在严格保留显式 Torch/CuPy 后端选择,统一规范化 Device 枚举,并在生成网格或提前返回前验证解析权重。 + +- 修正 NumPy、CuPy 与 Torch 下解析权重 LogisticRegression 的 IRLS:权重仅进入 WLS 曲率而不进入工作响应分母,且加权似然与推断保持同一目标;同时收窄 penalized-CV alpha 网格与 CuPy 精确 Ridge 的降级范围,使编程错误、CUDA OOM 与设备错误继续抛出。 + +- 完成惩罚 CV 降级边界加固:可选 Lipschitz 提示统一识别 NumPy/CuPy/Torch 的秩失败,而 alpha 网格估计不再隐藏内存或 GPU 基础设施错误。 + +- 保持惩罚 CV 的声明验证目标:非 Gaussian 损失不再静默退化为 MSE,平方损失应急路径保留验证权重,GPU 基础设施错误会穿透多层 CV 降级并原样抛出。 + +- 收窄 GPU 线性代数降级条件:仅真实的秩亏/非正定失败可转用最小二乘、伪逆、ridge 或零块恢复;CUDA OOM、设备、索引与实现错误将原样抛出。 + > 语言:中文
-> 最后更新:2026-08-04
+> 最后更新:2026-08-06
> 页面定位:变更记录
> 切换:[English](../en/changelog.md) +## 未发布 — PyTorch、输入校验与 sklearn 兼容性维护 + +### 运行时安全 + +- 通过将 `CoxPartialLikelihoodLoss` 改为惰性导出,移除了 `statgpu.glm_core` 与 Cox loss 之间的包初始化循环;在全新解释器中,GLM 内部模块与 `LogisticRegression` 不再依赖特定导入顺序。 +- Armijo 回溯不再把通用 `out of range` 错误当作可恢复数值 trial,因此 index/device 编程错误会原样抛出。 +- proximal-Newton 现在会对明确的数值域 ValueError trial 执行回溯,同时保留无关的契约与 runtime failure。 +- shared backend 线性方程求解现在仅对明确的秩失败使用 least-squares 降级,并保留 CUDA OOM/device RuntimeError。 +- shared NumPy constructor 现在与 CuPy/Torch 一样跟随浮点 reference dtype;整数 reference 仍采用 float64 数值默认值。 +- FISTA 系列 warm start 现在跟随预处理设计矩阵;smooth proximal-Newton 权重会在 loss 计算前转换到当前 backend/device/dtype。 +- Newton 系列 Armijo 回溯现在仅忽略明确的数值域 trial failure,并保留 CUDA OOM/device/runtime 基础设施错误。 +- solver sample-weight 校验现在会保留 CUDA OOM/device 等 backend RuntimeError,不再将其掩盖为普通输入 ValueError。 +- 可执行 solver matrix 现在将 Elastic Net 视为非光滑惩罚,并通过 FISTA 而不是仅支持光滑目标的 solver 验证其精度。 +- Newton、L-BFGS 与 L-BFGS-B 现在会对 Elastic Net 和其他非光滑惩罚显式失败,不再只优化其中的光滑部分。 +- Newton 系列、L-BFGS 系列与 ADMM 的 warm start 现在统一跟随预处理设计矩阵的 backend、device 与 dtype,不再保留调用方原始数组的位置。 +- 删除会重复计入光滑惩罚、从而优化错误目标的 Euclidean-prox Newton 快捷路径;光滑目标保留 Newton,非光滑目标在 Hessian-metric proximal 求解器完成前显式使用 FISTA。 +- 补全 ADMM 的 Cholesky 降级初始化,并强化 L-BFGS-B 的可行方向与 NaN bounds 校验。 +- 相邻的 Newton、proximal-Newton、ADMM、FISTA-BB、L-BFGS 与 L-BFGS-B 路径现在会在曲率计算前校验权重,仅对真正的奇异系统降级,保持 proximal Newton 与 CuPy bounds 的 dtype/device,并采用正确的梯度平方 Armijo 斜率。 +- direct solver 与 penalized-CV 的 sample-weight 检查现在保持在所选 backend,并在 weighted Lipschitz 运算前执行;权重总和溢出会被拒绝,HC1 analytic-weight inference 对全局权重缩放保持不变。 +- statgpu 内部迭代式 Torch kernel 统一通过显式 opt-in 的集中式 compile policy。 + 当 `STATGPU_TORCH_COMPILE_MODE` 未设置、设为 `auto` 或 `disable` 时, + 默认保持 eager;用户可显式选择 `default` 或 `reduce-overhead`。 + 遇到已知 CUDA Graph 输出生命周期错误时,对应 callable 会永久回退 + eager;其他运行时错误不会被吞掉。 +- 维护矩阵覆盖的公共 estimator 数值入口采用 NumPy、CuPy 或 Torch 原生 + reduction 检查 NaN/Inf,不把完整 GPU 数组搬回 CPU;矩阵覆盖 + fit/predict/transform、inverse-transform、scoring、初始化数组和 panel ID, + 同时保留 formula 路径对缺失行的专属语义。 +- formula sample weight 在 Patsy 确定保留行之后才进行对齐,并检查一维形状、 + finite、非负性与正权重和;Torch/CuPy 的对齐及 inference 权重保持在设备端。 +- Gaussian GLM 的 FISTA 路径改用加权的特征均值与响应均值 profile intercept; + 在零惩罚时与闭式 weighted least squares 一致,不再优化错误的未加权中心化目标。 +- GLM 的 sample weight 统一采用 analytic-weight 语义,覆盖 IRLS ridge scaling、 + line search、归一化 pseudo-loglikelihood、AIC/BIC、dispersion 与 sandwich inference; + 对全部权重作统一倍数缩放不会改变估计量或报告的诊断量。 +- 所有支持的 GLM family(包括 penalized 与 CV estimator)都在 solver 或 fold + dispatch 之前执行 backend-native response-domain validation;scalar GLM response + 支持非空实数的一维或单列输入,并在 solver/fold dispatch 前拒绝非实数、多列或长度不匹配; + design matrix 与 analytic sample weight 也在 model、formula、CV 和 direct IRLS + 路径中共享 backend-native 的实数、finite、shape 与 length 契约;active IRLS/FISTA 编译 + 统一走 centralized compile policy,且不再把无关的 + 线性代数、显存或 device 错误伪装成 fallback。 + +### Estimator 与测试契约 + +- 构造函数原始参数与运行时标准化属性分开保存,使旧版 scikit-learn 的 + constructor identity clone 检查也能通过。 +- `.gitignore` 不再隐藏应维护的 `test_*.py`;手工 GPU 诊断脚本使用独立目录 + 和明确的 ownership policy。 + +关联:Issue #45、Issue #81、Issue #82、Issue #83。 ## 0.2.3 — 2026-08-04 ### 生存分析 @@ -51,4 +146,4 @@ ## 更早的历史记录 截至 2026-08-03 的详细条目保留在 -[归档 changelog](changelog-history-through-2026-08-03.markdown)。 +[归档 changelog](changelog-history-through-2026-08-03.markdown)。 \ No newline at end of file diff --git a/docs/cn/guides/cross-validation.md b/docs/cn/guides/cross-validation.md index 0052a30e6..c8ae1516d 100644 --- a/docs/cn/guides/cross-validation.md +++ b/docs/cn/guides/cross-validation.md @@ -139,6 +139,9 @@ print(f"准确率: {model.score(X_test, y_test):.4f}") | `l1_ratio` | float/list | `0.5` | L1 混合比。传列表可搜索多个值。 | | `alphas` | array | `None` | Alpha 网格。 | | `n_alphas` | int | `100` | Alpha 数量。 | +| `compute_inference` | bool | `False` | 对最终全数据 ElasticNet 重拟合执行 debiased 推断。 | + +各折拟合仍仅用于估计;只有在所选 `alpha` 与 `l1_ratio` 使用全部观测重拟合后才计算推断。 ### PenalizedGLM_CV 专用 diff --git a/docs/cn/guides/solver-algorithms.md b/docs/cn/guides/solver-algorithms.md index 760178708..7bddbeb63 100644 --- a/docs/cn/guides/solver-algorithms.md +++ b/docs/cn/guides/solver-algorithms.md @@ -5,20 +5,22 @@ ## 概述 -statgpu 提供 10 种求解器用于惩罚损失最小化。本文档记录每种求解器的算法、收敛条件、后端支持和超参数。 +statgpu 提供 11 种求解器用于惩罚损失最小化。本文档记录每种求解器的算法、收敛条件、后端支持和超参数。 ## 求解器总览 | 求解器 | 最佳用途 | 后端支持 | |--------|----------|:---:| | Proximal IRLS-CD | quantile + SCAD/MCP | numpy, cupy, torch | -| Proximal Newton | Huber/Bisquare/Cox + SCAD/MCP | numpy, cupy, torch | +| Proximal Newton | 光滑损失 + 光滑惩罚;非光滑情形显式使用 FISTA | numpy, cupy, torch | | FISTA | 一般非光滑惩罚 | numpy, cupy, torch | | FISTA-BB | GLM + 稀疏惩罚 | numpy, cupy, torch | | FISTA-LLA | 非凸惩罚(continuation path) | numpy, cupy, torch | | IRLS | 光滑损失 + L2 | numpy, cupy, torch | | Newton | 光滑损失 + L2 | numpy, cupy, torch | | L-BFGS | 光滑损失,中低维度 | numpy, cupy, torch | +| L-BFGS-B | box-constrained 问题 | numpy, cupy, torch | +| ADMM | 可分惩罚 | numpy, cupy, torch | | exact | squared_error + L2(闭式解) | numpy, cupy, torch | --- @@ -55,17 +57,19 @@ statgpu 提供 10 种求解器用于惩罚损失最小化。本文档记录每 **文件**: `statgpu/solvers/_proximal_newton.py` -**用途**: 有 Hessian 的光滑损失(Huber、Bisquare、Cox PH)+ 非光滑惩罚(SCAD/MCP 通过 LLA)。5-10 次迭代收敛。 +**用途**: 对光滑损失与 L2/无惩罚目标执行 Newton 更新。 + +一般非光滑 proximal-Newton 需要求解 Hessian metric 下的 proximal 子问题; +旧的 Euclidean-prox 快捷路径会优化错误目标。现在 direct 非光滑调用会明确告警并 +使用 FISTA;FISTA-LLA 也保持 backend-native FISTA,直到实现并显式声明正确的 +metric proximal 能力。 ### 算法 -1. 计算 Hessian H = X'WX 和梯度 g = X'ψ / n -2. Newton 方向 d = -H⁻¹·g -3. Armijo 线搜索(最多 25 次回退): - a. 尝试点: β_try = proximal(β − step·d, step) - b. 检查复合 Armijo: f(β_try) + g(β_try) ≤ f(β) + g(β) + c·step·g'd - c. 不满足则步长减半 -4. Hessian 奇异或 g'd ≤ 0 → 回退到梯度下降 +1. 对损失和光滑惩罚各计入一次梯度与 Hessian。 +2. 仅在真正的秩失败时使用 least-squares 降级。 +3. 对完整声明目标执行 Armijo 回溯。 +4. Newton 方向不是下降方向时使用最速下降。 --- @@ -133,8 +137,8 @@ SCAD/MCP/group MCP/group SCAD 禁用 BB 步长。LLA 重加权引起的 subgradi 2. **LLA 外层**(每步 2-5 次): a. 在当前 β 处计算 LLA 权重 b. **内层求解器**: - - 有 Hessian → Proximal Newton(5-10 次迭代) - - 无 Hessian → FISTA(300+ 次迭代) + - 复合 LLA 子问题统一使用 backend-native FISTA + - 未来的 proximal-Newton 路径必须显式提供正确的 Hessian-metric proximal 能力 c. LLA 收敛 ||β − β_before_lla||₁ < lla_tol ### 融合 Kernel(GPU) diff --git a/docs/cn/guides/solver-penalty-matrix.md b/docs/cn/guides/solver-penalty-matrix.md index da7a1b5c2..3567c046b 100644 --- a/docs/cn/guides/solver-penalty-matrix.md +++ b/docs/cn/guides/solver-penalty-matrix.md @@ -36,13 +36,13 @@ |--------|------|------|------| | `exact` | 仅 l2 + squared_error | 其他所有 | 特征分解闭式解 | | `irls` | 光滑 l2 路径 | 非光滑惩罚 | IRLS | -| `newton` | 光滑目标 | l1、非凸及全部 group penalty | Newton + 线搜索 | -| `lbfgs` | 光滑目标 | l1、非凸及全部 group penalty | L-BFGS | +| `newton` | l2 / none | l1、elasticnet、非凸及全部 group penalty | Newton + 线搜索 | +| `lbfgs` | l2 / none | l1、elasticnet、非凸及全部 group penalty | L-BFGS | | `fista` | 支持 proximal 的惩罚 | — | Nesterov FISTA | | `fista_bb` | 支持的稀疏组合 | 不支持的组合明确失败 | BB 自适应步长 | | `admm` | 支持的 proximal 组合 | 不支持的组合明确失败 | ADMM | | `irls_cd` | 标量 scad/mcp/adaptive_l1 | 全部 group penalty | IRLS + 坐标下降 | -| `proximal_newton` | 支持的标量非凸 Hessian 路径 | 全部 group penalty | Newton + Armijo + proximal | +| `proximal_newton` | l2 / none 使用 Newton;非光滑 direct 调用显式转到 FISTA | 全部 group penalty 与不支持组合 | 不再静默使用 Euclidean-prox 近似 | 不支持的组合在数值拟合前抛出 `ValueError`。 diff --git a/docs/cn/models/elastic-net.md b/docs/cn/models/elastic-net.md index 538078806..52dbae5fa 100644 --- a/docs/cn/models/elastic-net.md +++ b/docs/cn/models/elastic-net.md @@ -1,7 +1,7 @@ # Elastic Net 弹性网络 > Language: Chinese (中文) -> Last updated: 2026-07-24
+> Last updated: 2026-08-05
> This page: 模型文档 > Language switch: [English](../../en/models/elastic-net.md) @@ -26,7 +26,7 @@ $$ - `l1_ratio` (λ) 混合 L1 和 L2:λ=1 为 Lasso,λ=0 为 Ridge - 损失函数缩放因子 `1/(2n)` 使 `alpha` 的解释与样本量无关 -**正则化缩放说明**:当 `l1_ratio=0` 时,`ElasticNet(alpha)` 等价于 `Ridge(n_samples * alpha)`,这是由于损失函数的缩放约定。 +**正则化缩放说明**:`ElasticNet` 与 `Ridge` 均采用相同的平均损失尺度。因此当 `l1_ratio=0` 时,`ElasticNet(alpha)` 等价于 `Ridge(alpha)`;公开参数 `alpha` 不需要再乘以样本量。 ## 估计方程 @@ -81,18 +81,27 @@ w = soft_threshold(w_tilde, alpha * l1_ratio * step) / (1 + alpha * (1 - l1_rati ## 参数 | 参数 | 默认值 | 说明 | -|------|--------:|------| -| `alpha` | `1.0` | 正则化强度 (α) | -| `l1_ratio` | `0.5` | L1 混合参数 (λ):0=Ridge, 1=Lasso | -| `device` | `"cpu"` | 设备:`cpu` / `cuda` | -| `backend` | `None` | 后端:`numpy` / `cupy` / `torch`(自动检测) | -| `max_iter` | `5000` | 最大迭代次数 | -| `tol` | `1e-6` | 收敛容忍度 | -| `fit_intercept` | `True` | 是否拟合截距 | -| `stopping` | `"coef_delta"` | 停止规则:`coef_delta` / `kkt` | -| `warm_start` | `False` | 复用前一次拟合结果作为初始化 | -| `random_state` | `None` | 随机种子 | -| `gpu_memory_cleanup` | `False` | 拟合后清理 GPU 内存(仅 CuPy) | +|------|--------|------| +| `alpha` | `1.0` | 总体正则化强度 | +| `l1_ratio` | `0.5` | L1 混合比例:0=Ridge,1=Lasso | +| `fit_intercept` | `True` | 拟合不受惩罚的截距 | +| `max_iter` | `1000` | 最大求解迭代次数 | +| `tol` | `1e-4` | 收敛容差 | +| `stopping` | `"coef_delta"` | `"coef_delta"` 或 `"kkt"` 停止准则 | +| `device` | `"auto"` | `"auto"`、`"cpu"`、`"cuda"`(CuPy)或 `"torch"` | +| `n_jobs` | `None` | 适用 CPU 路径的并行度 | +| `solver` | `"fista"` | 后端感知的优化方法 | +| `cpu_solver` | `"fista"` | CPU 求解器覆盖选项 | +| `lipschitz_L` | `None` | 可选的用户指定 Lipschitz 常数 | +| `gpu_memory_cleanup` | `False` | 在支持的后端上于拟合后释放内存池 | +| `compute_inference` | `False` | 计算拟合后系数推断 | +| `inference_method` | `"debiased"` | `"debiased"`、`"cpu_ols"` 或 `"bootstrap"` | +| `cov_type` | `"nonrobust"` | 适用方法中的协方差约定 | +| `hac_maxlags` | `None` | 支持 HAC 时使用的滞后阶数 | + +公开 wrapper 不接受单独的 `backend`、`warm_start` 或 `random_state` +构造参数。后端由 `device` 控制;单次拟合的 warm start 可通过 +`fit(initial_coef=...)` 提供。 ## CPU/GPU 示例 @@ -111,41 +120,46 @@ model_gpu_cupy = ElasticNet( ) model_gpu_cupy.fit(X, y) -# GPU (PyTorch,推荐用于 n >= 10,000) +# GPU (PyTorch) model_gpu_torch = ElasticNet( alpha=0.1, l1_ratio=0.5, device="torch" ) model_gpu_torch.fit(X, y) ``` -### 按数据规模选择后端 - -| 数据规模 | 推荐后端 | 相对 sklearn 加速比 | -|----------|----------|---------------------| -| n < 1,000 | CPU (NumPy) | 0.7x - 1.0x | -| 1,000 ≤ n < 10,000 | CPU (NumPy) | 1.5x - 4x | -| 10,000 ≤ n < 50,000 | GPU (Torch) | 2x - 3x | -| n ≥ 50,000 | GPU (Torch) | 3x - 4.4x | +后端性能取决于样本量、特征维数、dtype、硬件、数据驻留位置和传输成本。不要仅依据固定阈值选择后端;应对实际目标工作负载进行 benchmark。 ## 协方差/推断 -ElasticNet 不提供内置推断(标准误、p 值、置信区间),因为 L1 惩罚会引入系数估计的偏误,使基于 OLS 的标准推断无效。 +`ElasticNet` 默认仅进行估计。设置 `compute_inference=True` 后,将通过共享的 +penalized-linear 推断引擎执行拟合后推断。默认 +`inference_method="debiased"` 使用 nodewise Lasso 构造偏误校正估计量、标准误、 +z 统计量、p 值与 95% 置信区间;推断成功后可调用 `summary()`。 + +| 参数 | 默认值 | 含义 | +|------|--------|------| +| `compute_inference` | `False` | 启用拟合后系数推断 | +| `inference_method` | `"debiased"` | `"debiased"`、`"cpu_ols"` 或 `"bootstrap"` | +| `cov_type` | `"nonrobust"` | 在相应推断方法中使用的协方差约定 | +| `hac_maxlags` | `None` | 所选方法支持 HAC 时使用的滞后阶数 | -**计划中的推断支持**: +NumPy、CuPy 与 Torch 拟合路径均已实现 debiased 推断。CPU 验证属于托管测试套件; +每个精确发布候选仍必须通过 CuPy 与 Torch 的物理 CUDA 远程验证。 +Post-selection OLS 只是启发式方法,不保证有效的选择后覆盖率。推断以已选定的正则化 +参数为条件,不会改变原 penalized coefficient。 -| 方法 | 说明 | 状态 | -|------|------|------| -| Debiased Lasso | 通过 nodewise 回归进行偏误校正推断 | 待实现 — `PenalizedGeneralizedLinearModel` with `compute_inference=True` | -| Bootstrap | 通过重抽样获得经验置信区间 | 待实现 | -| Selection inference | 选择后条件推断 | 待实现 | +对于 `ElasticNetCV`,`compute_inference=True` 仅作用于 alpha 与 `l1_ratio` +选定后的全数据最终重拟合;各折模型仍仅用于估计和评分。 -如需 ElasticNet 惩罚的 debiased 推断,请使用 `PenalizedGeneralizedLinearModel(loss='squared_error', penalty='elasticnet')`,实现后将支持 debiased Lasso 路径。 +## 求解器与推断语义 -## strict/approx 区别 +默认估计器使用 FISTA 优化声明的 Elastic Net 目标函数。`stopping` 仅改变 +收敛诊断(`coef_delta` 或 KKT violation),并不定义不同的统计近似模式。 -ElasticNet 使用 **approximate**(默认)求解路径: -- **approx**:固定 Lipschitz 常数的 FISTA,通过系数变化检查收敛。快速但无推断保证。 -- **strict**:不适用于独立 ElasticNet。如需 debiased 推断,请使用 `PenalizedGeneralizedLinearModel` 并设置 `compute_inference=True`,该方法运行 nodewise Lasso 构建 debiasing 矩阵 M。 +`compute_inference=False` 只返回 penalized estimate。设置 +`compute_inference=True` 后,原拟合系数保持不变,并在拟合完成后运行所选推断方法。 +独立 `ElasticNet` wrapper 与 `ElasticNetCV` 的最终全数据重拟合都直接支持该契约; +用户不需要仅为了 debiased inference 而切换到其他估计器类。 ## 输出属性 @@ -153,98 +167,19 @@ ElasticNet 使用 **approximate**(默认)求解路径: | 属性 | 说明 | |------|------| -| `coef_` | 估计的系数 (形状:n_features) | +| `coef_` | 估计的系数(形状:n_features) | | `intercept_` | 拟合的截距 | | `n_iter_` | 收敛所需迭代次数 | -| `aic` | Akaike 信息准则(如可用) | -| `bic` | Bayesian 信息准则(如可用) | +| `aic` | 推断结果提供时的 Akaike 信息准则 | +| `bic` | 推断结果提供时的 Bayesian 信息准则 | 方法:`fit(X, y)`, `predict(X)`, `score(X, y)`, `summary()` -## 数值一致性 - -所有 statgpu 后端(CPU、CuPy、Torch)产生数值一致的结果: - -| 后端对比 | 最大系数差异 | -|----------|--------------| -| CPU vs CuPy | < 3e-8 | -| CPU vs Torch | < 3e-8 | -| 全部 vs sklearn | < 3e-8 | - -## 性能基准测试 - -### vs sklearn (Python) - -| 数据集 | n | p | sklearn (ms) | statgpu CPU (ms) | 加速比 | -|--------|---|---|--------------|------------------|--------| -| small | 200 | 20 | 0.77 | 1.10 | 0.70x | -| medium | 1,000 | 50 | 10.42 | 2.37 | **4.40x** | -| large | 5,000 | 100 | 6.01 | 4.13 | **1.45x** | - -### vs glmnet (R) - -| 数据集 | n | p | R glmnet (ms) | statgpu CPU (ms) | 胜者 | -|--------|---|---|---------------|------------------|------| -| small | 200 | 20 | 8.51 | **1.10** | statgpu | -| medium | 1,000 | 50 | 6.27 | **2.06** | statgpu | -| large | 5,000 | 100 | 10.70 | **6.14** | statgpu | - -statgpu CPU 在与 R glmnet 的 6 项对比中赢得 4 项。 - -### 大规模性能 (n ≥ 10,000) - -| 数据集 | n | p | sklearn | statgpu CPU | statgpu Torch | Torch 加速比 | -|--------|---|---|---------|-------------|---------------|--------------| -| n_10k_p100 | 10,000 | 100 | 11.39 | 11.24 | 12.02 | 0.95x | -| n_10k_p500 | 10,000 | 500 | 82.03 | 100.51 | **30.52** | **2.69x** | -| n_50k_p100 | 50,000 | 100 | 69.74 | 52.01 | **21.74** | **3.21x** | -| n_50k_p500 | 50,000 | 500 | 310.34 | 145.31 | **79.77** | **3.89x** | -| n_100k_p100 | 100,000 | 100 | 118.94 | 60.85 | **33.23** | **3.58x** | -| n_100k_p500 | 100,000 | 500 | 615.59 | 269.45 | **141.05** | **4.36x** | - -**关键发现**: -- statgpu Torch 在 6 项大规模测试中 5 项最快 (83%) -- 最大加速比:**4.36x** (n=100k, p=500 对比 sklearn) -- GPU 加速在 n ≥ 10,000 时开始显现优势 - -## 常见问题 - -**Q: 如何选择 l1_ratio?** -- `l1_ratio=1.0`: 纯 Lasso(稀疏解) -- `l1_ratio=0.0`: 纯 Ridge(密集收缩) -- `l1_ratio=0.5`: 平衡(默认值) -- 可通过交叉验证选择最优预测性能 - -**Q: 为什么 CPU 和 GPU 的迭代次数不同?** -不同的数值路径和浮点运算可能导致略微不同的收敛轨迹。应比较最终系数和 R²而非迭代次数。 - -**Q: 何时使用 GPU vs CPU?** -- n < 10,000: CPU 更快(无数据传输开销) -- n ≥ 10,000: GPU (Torch 后端) 显示 2-4 倍加速 -- n ≥ 50,000: CuPy 和 Torch 都显示显著优势 - -**Q: 为什么系数与 sklearn 有 ~1e-8 的差异?** -这在浮点运算的数值精度范围内。所有后端都以 1e-6 的容忍度求解相同的优化问题。 - -**Q: alpha 与 Ridge/Lasso 的关系?** -- `ElasticNet(l1_ratio=1.0, alpha=X)` ≈ `Lasso(alpha=X)` -- `ElasticNet(l1_ratio=0.0, alpha=X)` ≈ `Ridge(alpha=n_samples * X)` - -## 外部验证 - -基准测试脚本: -- `dev/benchmarks/benchmark_elasticnet_sklearn.py` - sklearn 对比 -- `dev/benchmarks/benchmark_glmnet_full.R` - R glmnet 对比 -- `dev/benchmarks/benchmark_large_scale.py` - 大规模性能测试 -- `dev/benchmarks/run_full_benchmark.py` - 统一基准运行器 +## 数值验证 -测试脚本: -- `dev/scripts/remote_elasticnet_smoke.py` - 基础验证 -- `dev/scripts/remote_stability_en.py` - 数值稳定性测试 +维护中的回归测试会按 dtype 和求解路径选择相应容差,检查支持后端之间以及与参考实现的数值一致性。物理 CUDA 验证仍属于 exact-head handoff;不存在适用于所有工作负载的统一系数误差阈值或加速比。 ## 参考文献 -- Zou, H., & Hastie, T. (2005). Regularization and variable selection via the elastic net. *Journal of the Royal Statistical Society: Series B*, 67(2), 301-320. [https://doi.org/10.1111/j.1467-9868.2005.00503.x](https://doi.org/10.1111/j.1467-9868.2005.00503.x) -- Nesterov, Y. (2005). Smooth minimization of non-smooth functions. *Mathematical Programming*, 103(1), 127-152. [https://doi.org/10.1007/s10107-004-0552-5](https://doi.org/10.1007/s10107-004-0552-5) -- Beck, A., & Teboulle, M. (2009). A fast iterative shrinkage-thresholding algorithm for linear inverse problems. *SIAM Journal on Imaging Sciences*, 2(1), 183-202. [https://doi.org/10.1137/080716542](https://doi.org/10.1137/080716542) -- Friedman, J., Hastie, T., & Tibshirani, R. (2010). Regularization paths for generalized linear models via coordinate descent. *Journal of Statistical Software*, 33(1), 1-22. [https://www.jstatsoft.org/v33/i01/](https://www.jstatsoft.org/v33/i01/) +- Zou, H., & Hastie, T. (2005). Regularization and variable selection via the Elastic Net. *Journal of the Royal Statistical Society: Series B*, 67(2), 301-320. +- Beck, A., & Teboulle, M. (2009). A fast iterative shrinkage-thresholding algorithm for linear inverse problems. *SIAM Journal on Imaging Sciences*, 2(1), 183-202. diff --git a/docs/cn/models/logistic-regression.md b/docs/cn/models/logistic-regression.md index 2cdc4abfe..53e596165 100644 --- a/docs/cn/models/logistic-regression.md +++ b/docs/cn/models/logistic-regression.md @@ -1,7 +1,7 @@ # LogisticRegression > 语言: 中文 -> 最后更新: 2026-05-20 +> 最后更新: 2026-08-06 > 页面定位: 模型文档 > 切换: [English](../../en/models/logistic-regression.md) @@ -35,6 +35,8 @@ - `cov_type="hac"`:Newey-West(Bartlett kernel)自相关稳健协方差 - `hac_maxlags`:仅 `cov_type="hac"` 生效 +似然、AIC、BIC、伪 R² 与 `converged_` 在 `compute_inference=False` 时仍可用;协方差相关字段不可用。 + ## 参数(Parameters) | 参数 | 默认值 | 说明 | diff --git a/docs/en/changelog.md b/docs/en/changelog.md index 8a3288191..48a54cb8f 100644 --- a/docs/en/changelog.md +++ b/docs/en/changelog.md @@ -1,10 +1,112 @@ # Changelog +- Decoupled direct LogisticRegression training confusion metrics from ROC/PR evaluation, so accuracy, precision, recall, and F1 remain available for one-class targets while ranking metrics keep their explicit support requirements; summary renders unavailable ranking metrics as NaN. + +- Kept direct LogisticRegression analytic weights device-native on CuPy/Torch fits instead of copying the full vector to NumPy solely for the CPU inference cache. + +- Closed follow-up review gaps in direct LogisticRegression and penalized CV: failed fits clear partial state, single-class confusion/table metrics remain available, and custom validation losses retain analytic weights. + +- Aligned direct LogisticRegression prediction contracts across NumPy, CuPy, and Torch: hard labels are integer-valued, single-column responses score without broadcasting, and non-finite decision thresholds are rejected. + +- Kept fitted likelihood diagnostics independent of covariance inference, so enabling inference cannot change AIC, BIC, or pseudo-R². + +- Unified CPU, CuPy, and Torch fitted log-likelihood diagnostics with the registered numerically stable LogisticLoss objective. + +- Completed the code-review fix cycle for scalar GLM runtime contracts: strict binary labels and controls, transactional refits, visible convergence, and backend-consistent analytic-weight diagnostics. + +- Corrected arbitrary-link Binomial IRLS, backend-native warm starts, quadratic-penalty validation, and explicit penalized-CV fallback semantics. + +- Removed universal ElasticNet backend thresholds, coefficient tolerances, and fixed speedup claims that were not established for the current exact-head environment; the model guide now requires workload-specific benchmarking and dtype/solver-specific validation. + +- Corrected ElasticNet/Ridge scaling documentation and added a regression test confirming that `ElasticNet(alpha, l1_ratio=0)` matches `Ridge(alpha)` under the shared average-loss convention. + +- Reconciled the ElasticNet API documentation with the implementation by correcting constructor defaults, removing nonexistent parameters, and replacing stale strict/approx guidance with the actual FISTA and post-fit inference semantics. + +- Completed the public ElasticNet inference contract: the standalone wrapper now exposes and forwards inference options, and ElasticNetCV honors `compute_inference=True` on its final full-data refit with NumPy/CuPy/Torch matrix tests. + +- Integrated transactional CV reset with the shared public finite-input guard, so NaN/Inf refit attempts invalidate stale RidgeCV, ElasticNetCV, LogisticRegressionCV, and unified penalized-CV state before validation raises. + +- Made dedicated RidgeCV, ElasticNetCV, and LogisticRegressionCV refits failure-safe: every fit attempt clears stale fitted state, and CV selections are published only after the final model refit succeeds. + +- Pinned AUTO-mode RidgeCV, ElasticNetCV, and LogisticRegressionCV final refits to the backend selected during CV, preventing silent Torch/CuPy backend drift after parameter selection. + +- Preserved `device='auto'` through public RidgeCV, ElasticNetCV, and LogisticRegressionCV dispatch so GPU-resident inputs retain their owning backend; LogisticRegressionCV now validates 0/1 responses without a full GPU-to-CPU copy. + +- Logistic and ElasticNet CV default regularization grids now use analytic weights and satisfy integer-weight row-replication equivalence; CV GPU-array device inspection no longer masks runtime failures. + +- Dedicated Ridge, ElasticNet, and Logistic CV routines now preserve explicit Torch versus CuPy backend requests, normalize Device enum values consistently, and validate analytic weights before grid generation or degenerate returns. + +- Corrected analytic-weight LogisticRegression IRLS across NumPy, CuPy, and Torch: weights now enter WLS curvature rather than the working-response denominator, and weighted likelihood/inference use the same objective. Narrowed penalized-CV alpha-grid and exact CuPy Ridge fallbacks so programming, CUDA OOM, and device errors propagate. + +- Completed penalized-CV fallback hardening: optional Lipschitz recovery now recognizes NumPy/CuPy/Torch rank failures consistently, while alpha-grid estimation no longer hides memory or GPU infrastructure failures. + +- Preserved the declared validation objective in penalized CV: non-Gaussian losses no longer silently fall back to MSE, weighted squared-error fallback retains validation weights, and GPU infrastructure failures propagate through layered CV fallbacks. + +- Narrowed GPU linear-algebra fallbacks so only genuine rank/definiteness failures use least-squares, pseudo-inverse, ridge, or zero-block recovery; CUDA OOM, device, index, and programming errors now propagate. + > Language: English
-> Last updated: 2026-08-04
+> Last updated: 2026-08-06
> This page: Changelog
> Switch: [Chinese](../cn/changelog.md) +## Unreleased — PyTorch, validation, and sklearn compatibility + +### Runtime safety + +- Removed the package-initialization cycle between `statgpu.glm_core` and the Cox loss export by lazily exposing `CoxPartialLikelihoodLoss`; GLM internals and `LogisticRegression` no longer require a particular import order in a fresh interpreter. +- Armijo backtracking no longer treats generic `out of range` errors as recoverable numerical trials, preserving index/device programming errors. +- Proximal-Newton now backtracks on recognized numeric-domain ValueError trials while preserving unrelated contract and runtime failures. +- Shared backend linear solves now use least-squares fallback only for recognized rank failures and preserve CUDA OOM/device RuntimeErrors. +- Shared NumPy constructors now follow floating reference dtypes like the CuPy/Torch implementations, while integer references retain float64 numerical defaults. +- FISTA-family warm starts now follow the preprocessed design, and smooth proximal-Newton weights are normalized to the active backend/device/dtype before loss evaluation. +- Newton-family Armijo backtracking now suppresses only recognized numeric-domain trial failures and propagates CUDA OOM/device/runtime infrastructure errors. +- Solver sample-weight validation now propagates backend RuntimeError failures such as CUDA OOM/device errors instead of masking them as invalid-input ValueError exceptions. +- The executable solver matrix now treats Elastic Net as non-smooth and validates its precision through FISTA rather than a smooth-only solver. +- Newton, L-BFGS, and L-BFGS-B now fail explicitly for Elastic Net and other non-smooth penalties rather than optimizing only their smooth part. +- Newton-family, L-BFGS-family, and ADMM warm starts now follow the preprocessed design backend, device, and dtype rather than retaining the caller's original array placement. +- Removed the wrong Euclidean-prox Newton shortcut that duplicated smooth penalties. Smooth objectives retain Newton; non-smooth objectives explicitly use FISTA until a Hessian-metric proximal solver exists. +- Completed ADMM's Cholesky fallback initialization and hardened L-BFGS-B feasible directions and NaN-bound validation. +- Adjacent Newton, proximal-Newton, ADMM, FISTA-BB, L-BFGS, and L-BFGS-B paths now validate weights before curvature work, narrow singular-system fallbacks, preserve dtype/device for proximal Newton and CuPy bounds, and use the correct squared-gradient Armijo slope. +- Direct solver and penalized-CV sample-weight checks now remain on the selected backend, run before weighted Lipschitz operations, reject overflowing totals, and preserve HC1 analytic-weight scale invariance. +- Internal iterative Torch kernels now use a centralized, opt-in compile policy. + Compilation remains eager when `STATGPU_TORCH_COMPILE_MODE` is unset, + `auto`, or `disable`. Users can explicitly select `default` or + `reduce-overhead`; known CUDA Graph output lifecycle failures then fall + back to eager execution once, while unrelated runtime errors remain visible. +- Maintained public numerical entry points are checked for NaN/Inf using + NumPy, CuPy, or Torch reductions on the selected device. The matrix includes + fit/predict/transform, inverse-transform, scoring, initialization arrays, + and panel identifiers while preserving formula-owned missing-row semantics. +- Formula sample weights are aligned only after Patsy selects retained rows, + then checked for shape, finite values, non-negativity, and positive total + weight. Torch and CuPy alignment and inference weights remain device-native. +- Gaussian GLM FISTA now profiles the intercept with weighted feature and + response means, matching the declared weighted squared-loss objective and + closed-form weighted least squares when the penalty is zero. +- GLM sample weights now follow one analytic-weight convention across IRLS + ridge scaling, line search, normalized pseudo-loglikelihood, AIC/BIC, + dispersion, and sandwich inference. Globally rescaling weights leaves fitted + parameters and reported diagnostics unchanged. +- Every supported GLM family, including penalized and CV estimators, now + enforces its response domain before any solver or fold dispatch, using NumPy, + Torch, or CuPy reductions on the selected backend. Scalar GLM responses + accept non-empty real one-dimensional or single-column input and reject + non-real, multicolumn, or length-mismatched data before solver/fold dispatch. + Design matrices and analytic sample weights now use the same backend-native + real/finite/shape/length contract in model, formula, CV, and direct IRLS paths. + Active IRLS/FISTA helper compilation uses the centralized compile policy, and + unrelated linear-algebra/device failures are no longer masked as fallback. + +### Estimator and test contracts + +- Exact constructor arguments are retained separately from normalized + runtime attributes so `sklearn.base.clone` works under legacy + scikit-learn identity checks. +- Maintained pytest modules can no longer be hidden by broad `.gitignore` + rules; manual GPU diagnostics have an explicit directory and ownership + policy. + +Related: Issue #45, Issue #81, Issue #82, Issue #83. ## 0.2.3 — 2026-08-04 ### Survival analysis diff --git a/docs/en/guides/cross-validation.md b/docs/en/guides/cross-validation.md index 6cae36ff9..5f0addb8a 100644 --- a/docs/en/guides/cross-validation.md +++ b/docs/en/guides/cross-validation.md @@ -144,6 +144,10 @@ print(f"Accuracy: {model.score(X_test, y_test):.4f}") | `l1_ratio` | float/list | `0.5` | L1 mixing. Pass a list to search over multiple values. | | `alphas` | array | `None` | Alpha grid. | | `n_alphas` | int | `100` | Number of alphas. | +| `compute_inference` | bool | `False` | Run debiased inference on the final full-data ElasticNet refit. | + +Fold fits remain estimation-only; inference is computed only after the selected +`alpha` and `l1_ratio` are refit on all observations. #### PenalizedGLM_CV-Specific diff --git a/docs/en/guides/solver-algorithms.md b/docs/en/guides/solver-algorithms.md index ae2b74e94..e9ed3a5da 100644 --- a/docs/en/guides/solver-algorithms.md +++ b/docs/en/guides/solver-algorithms.md @@ -5,21 +5,21 @@ ## Overview -statgpu provides 10 solvers for penalized loss minimization. This page documents the algorithm, convergence criteria, backend support, and hyperparameters for each solver. +statgpu provides 11 solvers for penalized loss minimization. This page documents the algorithm, convergence criteria, backend support, and hyperparameters for each solver. ## Solver Summary | Solver | Best For | Backend Support | |--------|----------|:---:| | Proximal IRLS-CD | quantile + SCAD/MCP | numpy, cupy, torch | -| Proximal Newton | Huber/Bisquare/Cox + SCAD/MCP | numpy, cupy, torch | +| Proximal Newton | smooth loss + smooth penalty; non-smooth explicitly uses FISTA | numpy, cupy, torch | | FISTA | general non-smooth penalties | numpy, cupy, torch | | FISTA-BB | GLM + sparse penalties | numpy, cupy, torch | | FISTA-LLA | nonconvex penalties (continuation path) | numpy, cupy, torch | | IRLS | smooth losses + L2 | numpy, cupy, torch | | Newton | smooth losses + L2 | numpy, cupy, torch | | L-BFGS | smooth losses, moderate dims | numpy, cupy, torch | -| L-BFGS-B | box-constrained problems | numpy | +| L-BFGS-B | box-constrained problems | numpy, cupy, torch | | ADMM | sum of separable penalties | numpy, cupy, torch | | exact | squared_error + L2 (closed-form) | numpy, cupy, torch | @@ -72,17 +72,20 @@ statgpu provides 10 solvers for penalized loss minimization. This page documents **File**: `statgpu/solvers/_proximal_newton.py` -**Use case**: Smooth losses with Hessian (Huber, Bisquare, Cox PH) + non-smooth penalties (SCAD/MCP via LLA). Converges in 5-10 iterations. +**Use case**: Smooth losses with a smooth L2/no penalty Newton system. + +A general non-smooth proximal-Newton update requires a Hessian-metric proximal +subproblem. The previous Euclidean-prox shortcut optimized the wrong composite +objective. Direct non-smooth requests now emit a warning and use FISTA; the +FISTA-LLA path likewise stays on its backend-native FISTA implementation until +a metric proximal subproblem is implemented and explicitly advertised. ### Algorithm -1. Compute Hessian H = X'WX and gradient g = X'ψ / n -2. Newton direction: d = -H⁻¹·g -3. Armijo line search (max 25 retries): - a. Trial point: β_try = proximal(β − step·d, step) - b. Check composite Armijo: f(β_try) + g(β_try) ≤ f(β) + g(β) + c·step·g'd - c. Halve step if not satisfied -4. If Hessian singular or g'd ≤ 0: fall back to gradient descent +1. Compute the loss and smooth-penalty gradient/Hessian exactly once. +2. Solve the Newton system, using least squares only for a genuine rank failure. +3. Run Armijo backtracking on the declared full objective. +4. If the Newton direction is not a descent direction, use steepest descent. ### Convergence @@ -176,8 +179,9 @@ BB steps are disabled for SCAD/MCP/group MCP/group SCAD. The abrupt subgradient 2. **LLA outer** (2-5 iterations per step): a. Compute LLA weights from SCAD/MCP at current β b. **Inner solver**: - - Losses with Hessian → Proximal Newton (5-10 iter) - - Losses without Hessian → FISTA (300+ iter) + - backend-native FISTA for composite LLA subproblems + - a future proximal-Newton path is gated on an explicit, correct + Hessian-metric proximal capability c. LLA convergence: ||β − β_before_lla||₁ < lla_tol ### Fused Kernels (GPU) diff --git a/docs/en/guides/solver-penalty-matrix.md b/docs/en/guides/solver-penalty-matrix.md index e02d86e15..b35674272 100644 --- a/docs/en/guides/solver-penalty-matrix.md +++ b/docs/en/guides/solver-penalty-matrix.md @@ -43,7 +43,7 @@ | `admm` | supported proximal penalties | unsupported combinations fail explicitly | ADMM with proximal z-update | | `irls_cd` | scalar scad, mcp, adaptive_l1 | l1, elasticnet, all group penalties | IRLS outer + coordinate descent inner | | `proximal_irls_cd` | scalar scad, mcp (quantile only) | group penalties and non-quantile losses | IRLS majorization + LLA | -| `proximal_newton` | scalar scad, mcp, adaptive_l1 (Hessian losses) | group penalties and unsupported penalties | Newton direction + Armijo + proximal operator | +| `proximal_newton` | l2 / none use Newton; non-smooth direct calls delegate visibly to FISTA | group penalties and unsupported penalties | no silent Euclidean-prox approximation | Unsupported combinations raise `ValueError` before numerical work. diff --git a/docs/en/models/elastic-net.md b/docs/en/models/elastic-net.md index ac0e4ec95..e1732e156 100644 --- a/docs/en/models/elastic-net.md +++ b/docs/en/models/elastic-net.md @@ -1,7 +1,7 @@ # Elastic Net > Language: English -> Last updated: 2026-07-24
+> Last updated: 2026-08-05
> This page: Model documentation > Language switch: [Chinese](../../cn/models/elastic-net.md) @@ -26,7 +26,7 @@ where: - `l1_ratio` (λ) mixes L1 vs L2: λ=1 gives Lasso, λ=0 gives Ridge - Loss scaling by `1/(2n)` makes `alpha` interpretation scale-invariant to sample size -**Note on regularization scaling**: With `l1_ratio=0`, `ElasticNet(alpha)` is equivalent to `Ridge(n_samples * alpha)` due to the loss scaling convention. +**Note on regularization scaling**: `ElasticNet` and `Ridge` both use the same average-loss convention. Therefore, with `l1_ratio=0`, `ElasticNet(alpha)` is equivalent to `Ridge(alpha)`; no sample-size rescaling of the public `alpha` is required. ## Estimating Equation @@ -81,18 +81,27 @@ For `kkt` mode, the optimality condition is: ## Parameters | Parameter | Default | Description | -|-----------|--------:|-------------| -| `alpha` | `1.0` | Regularization strength (α) | -| `l1_ratio` | `0.5` | L1 mixing parameter (λ): 0=Ridge, 1=Lasso | -| `device` | `"cpu"` | Device: `cpu` / `cuda` | -| `backend` | `None` | Backend: `numpy` / `cupy` / `torch` (auto-detected if None) | -| `max_iter` | `5000` | Maximum iterations | -| `tol` | `1e-6` | Convergence tolerance | -| `fit_intercept` | `True` | Whether to fit intercept | -| `stopping` | `"coef_delta"` | Stopping rule: `coef_delta` / `kkt` | -| `warm_start` | `False` | Reuse previous fit as initialization | -| `random_state` | `None` | Random seed for reproducibility | -| `gpu_memory_cleanup` | `False` | Clean GPU memory after fit (CuPy only) | +|-----------|---------|-------------| +| `alpha` | `1.0` | Overall regularization strength | +| `l1_ratio` | `0.5` | L1 mixing proportion: 0=Ridge, 1=Lasso | +| `fit_intercept` | `True` | Fit an unpenalized intercept | +| `max_iter` | `1000` | Maximum solver iterations | +| `tol` | `1e-4` | Convergence tolerance | +| `stopping` | `"coef_delta"` | `"coef_delta"` or `"kkt"` stopping rule | +| `device` | `"auto"` | `"auto"`, `"cpu"`, `"cuda"` (CuPy), or `"torch"` | +| `n_jobs` | `None` | CPU parallelism where supported | +| `solver` | `"fista"` | Backend-aware optimization method | +| `cpu_solver` | `"fista"` | CPU solver override | +| `lipschitz_L` | `None` | Optional user-supplied Lipschitz constant | +| `gpu_memory_cleanup` | `False` | Release backend memory pools after fit where supported | +| `compute_inference` | `False` | Compute post-fit coefficient inference | +| `inference_method` | `"debiased"` | `"debiased"`, `"cpu_ols"`, or `"bootstrap"` | +| `cov_type` | `"nonrobust"` | Covariance convention where applicable | +| `hac_maxlags` | `None` | HAC lag count where supported | + +The public wrapper does not accept separate `backend`, `warm_start`, or +`random_state` constructor parameters. Backend selection is controlled by +`device`; a one-fit warm start can be supplied through `fit(initial_coef=...)`. ## CPU/GPU Examples @@ -111,41 +120,55 @@ model_gpu_cupy = ElasticNet( ) model_gpu_cupy.fit(X, y) -# GPU with PyTorch (recommended for n >= 10,000) +# GPU with PyTorch model_gpu_torch = ElasticNet( alpha=0.1, l1_ratio=0.5, device="torch" ) model_gpu_torch.fit(X, y) ``` -### Solver Selection by Data Scale - -| Data Scale | Recommended Backend | Expected Speedup vs sklearn | -|------------|---------------------|----------------------------| -| n < 1,000 | CPU (NumPy) | 0.7x - 1.0x | -| 1,000 ≤ n < 10,000 | CPU (NumPy) | 1.5x - 4x | -| 10,000 ≤ n < 50,000 | GPU (Torch) | 2x - 3x | -| n ≥ 50,000 | GPU (Torch) | 3x - 4.4x | +Backend performance depends on sample size, feature dimension, dtype, hardware, +data residency, and transfer costs. Benchmark the actual target workload before +selecting a backend solely for speed. ## Covariance/Inference -ElasticNet does not provide built-in inference (standard errors, p-values, confidence intervals) because the L1 penalty introduces bias in the coefficient estimates, making standard OLS-based inference invalid. - -**Planned inference support**: - -| Method | Description | Status | -|--------|-------------|--------| -| Debiased Lasso | Bias-corrected inference via nodewise regression | 待实现 — `PenalizedGeneralizedLinearModel` with `compute_inference=True` | -| Bootstrap | Empirical confidence intervals via resampling | 待实现 | -| Selection inference | Post-selection conditional inference | 待实现 | - -For debiased inference with ElasticNet penalties, use `PenalizedGeneralizedLinearModel(loss='squared_error', penalty='elasticnet')` which will support the debiased Lasso path once implemented. - -## strict/approx difference - -ElasticNet uses the **approximate** (default) solver path: -- **approx**: FISTA with fixed Lipschitz constant, convergence checked via coefficient delta. Fast but no inference guarantees. -- **strict**: Not applicable for standalone ElasticNet. For debiased inference, use `PenalizedGeneralizedLinearModel` with `compute_inference=True`, which runs nodewise Lasso to construct the debiasing matrix M. +`ElasticNet` is estimation-only by default. Set `compute_inference=True` to run +post-fit inference through the shared penalized-linear inference engine. The +default `inference_method="debiased"` uses nodewise Lasso to construct a +bias-corrected estimator, standard errors, z statistics, p-values, and 95% +confidence intervals. `summary()` is available after inference succeeds. + +| Parameter | Default | Meaning | +|-----------|---------|---------| +| `compute_inference` | `False` | Enable post-fit coefficient inference | +| `inference_method` | `"debiased"` | `"debiased"`, `"cpu_ols"`, or `"bootstrap"` | +| `cov_type` | `"nonrobust"` | Covariance convention where applicable | +| `hac_maxlags` | `None` | HAC lag count where the selected inference method supports HAC | + +Debiased inference is implemented for NumPy, CuPy, and Torch fitting paths. CPU +validation is part of the hosted test suite; physical CUDA validation for CuPy +and Torch remains a required remote gate for each exact release candidate. +Post-selection OLS is a heuristic and does not provide valid selective-inference +coverage. Inference is conditional on the selected regularization parameters +and does not alter the fitted penalized coefficients. + +For `ElasticNetCV`, `compute_inference=True` applies inference only to the final +full-data refit after alpha and `l1_ratio` have been selected. Fold models remain +estimation-only. + +## Solver and Inference Semantics + +The default estimator uses FISTA for the declared Elastic Net objective. The +`stopping` option changes only the convergence diagnostic (`coef_delta` versus +KKT violation); it does not define a separate statistical approximation mode. + +`compute_inference=False` returns the penalized estimate only. With +`compute_inference=True`, the same fitted coefficients are retained and the +selected post-fit inference method is run afterward. The standalone +`ElasticNet` wrapper and the final full-data refit of `ElasticNetCV` both support +this contract directly; users do not need to switch estimator classes merely +to request debiased inference. ## Outputs @@ -156,95 +179,19 @@ After fitting, the following attributes are available: | `coef_` | Estimated coefficients (shape: n_features) | | `intercept_` | Fitted intercept | | `n_iter_` | Number of iterations until convergence | -| `aic` | Akaike Information Criterion (if available) | -| `bic` | Bayesian Information Criterion (if available) | +| `aic` | Akaike Information Criterion (when inference provides it) | +| `bic` | Bayesian Information Criterion (when inference provides it) | Methods: `fit(X, y)`, `predict(X)`, `score(X, y)`, `summary()` -## Numerical Consistency - -All statgpu backends (CPU, CuPy, Torch) produce numerically consistent results: - -| Backend Pair | Max Coefficient Difference | -|--------------|----------------------------| -| CPU vs CuPy | < 3e-8 | -| CPU vs Torch | < 3e-8 | -| All vs sklearn | < 3e-8 | - -## Performance Benchmarks - -### vs sklearn (Python) - -| Dataset | n | p | sklearn (ms) | statgpu CPU (ms) | Speedup | -|---------|---|---|--------------|------------------|---------| -| small | 200 | 20 | 0.77 | 1.10 | 0.70x | -| medium | 1,000 | 50 | 10.42 | 2.37 | **4.40x** | -| large | 5,000 | 100 | 6.01 | 4.13 | **1.45x** | - -### vs glmnet (R) - -| Dataset | n | p | R glmnet (ms) | statgpu CPU (ms) | Winner | -|---------|---|---|---------------|------------------|--------| -| small | 200 | 20 | 8.51 | **1.10** | statgpu | -| medium | 1,000 | 50 | 6.27 | **2.06** | statgpu | -| large | 5,000 | 100 | 10.70 | **6.14** | statgpu | - -statgpu CPU wins 4/6 comparisons against R glmnet. - -### Large-Scale Performance (n ≥ 10,000) - -| Dataset | n | p | sklearn | statgpu CPU | statgpu Torch | Torch Speedup | -|---------|---|---|---------|-------------|---------------|---------------| -| n_10k_p100 | 10,000 | 100 | 11.39 | 11.24 | 12.02 | 0.95x | -| n_10k_p500 | 10,000 | 500 | 82.03 | 100.51 | **30.52** | **2.69x** | -| n_50k_p100 | 50,000 | 100 | 69.74 | 52.01 | **21.74** | **3.21x** | -| n_50k_p500 | 50,000 | 500 | 310.34 | 145.31 | **79.77** | **3.89x** | -| n_100k_p100 | 100,000 | 100 | 118.94 | 60.85 | **33.23** | **3.58x** | -| n_100k_p500 | 100,000 | 500 | 615.59 | 269.45 | **141.05** | **4.36x** | - -**Key findings**: -- statgpu Torch is fastest in 5/6 large-scale tests (83%) -- Maximum speedup: **4.36x** vs sklearn on n=100k, p=500 -- GPU acceleration becomes advantageous at n ≥ 10,000 - -## FAQ - -**Q: How do I choose l1_ratio?** -- `l1_ratio=1.0`: Pure Lasso (sparse solutions) -- `l1_ratio=0.0`: Pure Ridge (dense shrinkage) -- `l1_ratio=0.5`: Balanced (default) -- Tune via cross-validation for best predictive performance - -**Q: Why are CPU and GPU iteration counts different?** -Different numerical paths and floating-point arithmetic can lead to slightly different convergence trajectories. Compare final coefficients and R² rather than iteration counts. - -**Q: When should I use GPU vs CPU?** -- n < 10,000: CPU is faster (no data transfer overhead) -- n ≥ 10,000: GPU (Torch backend) shows 2-4x speedup -- n ≥ 50,000: Both CuPy and Torch show significant advantages - -**Q: Why do coefficients differ from sklearn by ~1e-8?** -This is within numerical precision for floating-point arithmetic. All backends solve the same optimization problem to tolerance 1e-6. - -**Q: How does alpha relate to Ridge/Lasso?** -- `ElasticNet(l1_ratio=1.0, alpha=X)` ≈ `Lasso(alpha=X)` -- `ElasticNet(l1_ratio=0.0, alpha=X)` ≈ `Ridge(alpha=n_samples * X)` - -## External Validation - -Benchmark scripts: -- `dev/benchmarks/benchmark_elasticnet_sklearn.py` - sklearn comparison -- `dev/benchmarks/benchmark_glmnet_full.R` - R glmnet comparison -- `dev/benchmarks/benchmark_large_scale.py` - large-scale performance -- `dev/benchmarks/run_full_benchmark.py` - unified benchmark runner +## Numerical Validation -Test scripts: -- `dev/scripts/remote_elasticnet_smoke.py` - basic validation -- `dev/scripts/remote_stability_en.py` - numerical stability tests +The maintained regression suite checks agreement across supported backends and +against reference implementations at tolerances chosen for each dtype and +solver path. Physical CUDA validation remains part of the exact-head handoff; +no universal coefficient tolerance or speedup applies to every workload. ## References -- Zou, H., & Hastie, T. (2005). Regularization and variable selection via the elastic net. *Journal of the Royal Statistical Society: Series B*, 67(2), 301-320. [https://doi.org/10.1111/j.1467-9868.2005.00503.x](https://doi.org/10.1111/j.1467-9868.2005.00503.x) -- Nesterov, Y. (2005). Smooth minimization of non-smooth functions. *Mathematical Programming*, 103(1), 127-152. [https://doi.org/10.1007/s10107-004-0552-5](https://doi.org/10.1007/s10107-004-0552-5) -- Beck, A., & Teboulle, M. (2009). A fast iterative shrinkage-thresholding algorithm for linear inverse problems. *SIAM Journal on Imaging Sciences*, 2(1), 183-202. [https://doi.org/10.1137/080716542](https://doi.org/10.1137/080716542) -- Friedman, J., Hastie, T., & Tibshirani, R. (2010). Regularization paths for generalized linear models via coordinate descent. *Journal of Statistical Software*, 33(1), 1-22. [https://www.jstatsoft.org/v33/i01/](https://www.jstatsoft.org/v33/i01/) +- Zou, H., & Hastie, T. (2005). Regularization and variable selection via the Elastic Net. *Journal of the Royal Statistical Society: Series B*, 67(2), 301-320. +- Beck, A., & Teboulle, M. (2009). A fast iterative shrinkage-thresholding algorithm for linear inverse problems. *SIAM Journal on Imaging Sciences*, 2(1), 183-202. diff --git a/docs/en/models/logistic-regression.md b/docs/en/models/logistic-regression.md index ab10d1cd7..438ba5bb0 100644 --- a/docs/en/models/logistic-regression.md +++ b/docs/en/models/logistic-regression.md @@ -1,7 +1,7 @@ # LogisticRegression > Language: English -> Last updated: 2026-05-20 +> Last updated: 2026-08-06 > This page: Model documentation > Switch: [Chinese](../../cn/models/logistic-regression.md) @@ -38,6 +38,7 @@ under convergence controls `max_iter` and `tol`. As of v23c (2026-05), `solver=" - `cov_type="hac"`: Newey-West (Bartlett) covariance with optional `hac_maxlags`. - Inference outputs use z-statistic conventions: `_bse`, `_zvalues`, `_pvalues`, `_conf_int`. - `compute_inference=True` is required for inference fields. +- Likelihood, AIC, BIC, pseudo-R², and `converged_` remain available when `compute_inference=False`; covariance-based fields do not. ## Parameters diff --git a/statgpu/__init__.py b/statgpu/__init__.py index 584b81dbe..88a32bf58 100644 --- a/statgpu/__init__.py +++ b/statgpu/__init__.py @@ -261,3 +261,11 @@ "UMAP", "TSNE", ] + + +# Some maintained methods are supplied by mixins or attached after class +# creation. Refresh finite-value guards after the complete public API is bound. +from ._base import refresh_public_finite_validation_contracts as _refresh_finite_contracts + +_refresh_finite_contracts() +del _refresh_finite_contracts diff --git a/statgpu/_base.py b/statgpu/_base.py index 1f7be81c0..b7dd4c260 100644 --- a/statgpu/_base.py +++ b/statgpu/_base.py @@ -8,6 +8,9 @@ from abc import ABC, abstractmethod from typing import Optional, Union, Any +import copy +import functools +import inspect import numpy as np from statgpu._config import Device, get_device @@ -29,6 +32,373 @@ class BaseEstimator(ABC): Provides common functionality for device management and input validation. """ + _FINITE_PUBLIC_METHODS = frozenset({ + "fit", + "partial_fit", + "fit_predict", + "fit_transform", + "predict", + "predict_proba", + "predict_log_proba", + "decision_function", + "transform", + "score", + "survfit", + "predict_survival", + "predict_survival_function", + "predict_cumulative_hazard", + "inverse_transform", + "score_samples", + "bic", + "aic", + "predict_with_threshold", + "confusion_matrix", + "classification_table", + "roc_curve", + "roc_auc_score", + "precision_recall_curve", + "average_precision_score", + }) + _NORMALIZED_PRIVATE_NAMES = { + "compute_inference": "_compute_inference_enabled", + } + + @classmethod + def _normalized_private_name(cls, name): + return cls._NORMALIZED_PRIVATE_NAMES.get(name, f"_{name}") + + _NORMALIZED_CONSTRUCTOR_PARAMS = frozenset({ + "device", + "cov_type", + "hac_maxlags", + "gpu_memory_cleanup", + "solver", + "cpu_solver", + "stopping", + "inference_method", + "simultaneous_method", + "n_bootstrap", + "enable_simultaneous_inference", + "simultaneous_alpha", + "simultaneous_n_bootstrap", + "simultaneous_include_intercept", + "method", + "admm_rho", + "alpha_min_ratio", + "cd_kkt_check_every", + "compute_inference", + "cv", + "fit_intercept", + "gpu_cv_mixed_precision", + "max_iter", + "n_alphas", + "tol", + "n_Cs", + "C_min_ratio", + "penalty_kwargs", + "loss_kwargs", + "epsilon", + "ties", + "acknowledge_approx", + "refine_top_k", + "batch_size", + "min_effective_weight", + "quantile", + "cv_strategy", + }) + + _FINITE_PARAMETER_NAMES = frozenset({ + "X", + "X_new", + "x", + "y", + "sample_weight", + "weights", + "offset", + "exposure", + "entry", + "start", + "stop", + "time", + "event", + "times", + "cluster", + "clusters", + "strata", + "subject", + "subjects", + "groups", + "init", + "init_coef", + "initial_coef", + "time_index", + "entity_ids", + "time_ids", + "pvalues", + "arrays", + "scores", + "thresholds", + "Xk", + "mu", + "Sigma", + }) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + declared = cls.__dict__.get("_estimator_type", ...) + if declared is ...: + inherited_type = next( + ( + base.__dict__.get("_estimator_type") + for base in cls.__mro__[1:] + if base.__dict__.get("_estimator_type") + in {"classifier", "regressor"} + ), + None, + ) + name = cls.__name__.lower() + module = cls.__module__ + internal_module = module.startswith("statgpu.") + nonpredictive_module = module.startswith( + ( + "statgpu.covariance", + "statgpu.unsupervised", + "statgpu.preprocessing", + "statgpu.feature_selection", + ) + ) + classifier_module = module.startswith("statgpu.linear_model") + regression_module = module.startswith( + ( + "statgpu.linear_model", + "statgpu.nonparametric", + "statgpu.panel", + "statgpu.survival", + "statgpu.semiparametric", + ) + ) + if not internal_module: + inferred_type = inherited_type + elif nonpredictive_module: + inferred_type = None + elif ( + "classifier" in name + or "logistic" in name + or "logit" in name + or "probit" in name + or "orderedgeneralizedlinearmodel" in name + ) and classifier_module: + inferred_type = "classifier" + elif ( + "regressor" in name + or "kernelridge" in name + or ( + regression_module + and any( + token in name + for token in ( + "regression", + "generalizedlinearmodel", + "glm", + "ridge", + "lasso", + "elasticnet", + "quantile", + "cox", + "panel", + "ols", + "effects", + "fama", + "gam", + ) + ) + ) + ): + inferred_type = "regressor" + else: + inferred_type = inherited_type + cls._estimator_type = inferred_type + elif declared not in {None, "classifier", "regressor"}: + raise ValueError( + "_estimator_type must be None, 'classifier', or 'regressor'" + ) + cls._install_constructor_capture() + cls._install_public_finite_validation() + + @classmethod + def _install_constructor_capture(cls): + original_init = cls.__dict__.get("__init__") + if original_init is None or getattr( + original_init, "__statgpu_constructor_capture__", False + ): + return + try: + signature = inspect.signature(original_init) + except (TypeError, ValueError): + return + + @functools.wraps(original_init) + def wrapped(self, *args, **kwargs): + try: + bound = signature.bind(self, *args, **kwargs) + bound.apply_defaults() + except TypeError: + return original_init(self, *args, **kwargs) + raw_params = { + name: value + for name, value in bound.arguments.items() + if name != "self" + and signature.parameters[name].kind + not in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ) + } + + depth = int(getattr(self, "_statgpu_constructor_depth", 0)) + if depth == 0: + self._statgpu_constructor_raw_pending = {} + self._statgpu_constructor_depth = depth + 1 + + try: + result = original_init(self, *args, **kwargs) + except BaseException: + if depth == 0: + self.__dict__.pop("_statgpu_constructor_depth", None) + self.__dict__.pop("_statgpu_constructor_raw_pending", None) + else: + self._statgpu_constructor_depth = depth + raise + + pending = self._statgpu_constructor_raw_pending + # Inner wrappers finish first; the most-derived constructor finishes + # last and therefore overrides shared defaults with its actual call. + pending.update(raw_params) + normalized_names = type(self)._NORMALIZED_CONSTRUCTOR_PARAMS + + # Make runtime values available to any outer constructor code without + # restoring public raw values until the complete chain has returned. + for name, raw_value in raw_params.items(): + private_name = type(self)._normalized_private_name(name) + if name in normalized_names: + if name == "device" and hasattr(self, private_name): + runtime_value = getattr(self, private_name) + elif hasattr(self, name): + runtime_value = getattr(self, name) + elif hasattr(self, private_name): + runtime_value = getattr(self, private_name) + else: + runtime_value = raw_value + setattr(self, private_name, runtime_value) + elif not hasattr(self, name): + setattr(self, name, raw_value) + + remaining = depth + if remaining > 0: + self._statgpu_constructor_depth = remaining + return result + + self.__dict__.pop("_statgpu_constructor_depth", None) + merged_raw = dict(pending) + self.__dict__.pop("_statgpu_constructor_raw_pending", None) + + for name, raw_value in merged_raw.items(): + private_name = type(self)._normalized_private_name(name) + if name in normalized_names: + if name == "device" and hasattr(self, private_name): + runtime_value = getattr(self, private_name) + elif hasattr(self, name): + runtime_value = getattr(self, name) + elif hasattr(self, private_name): + runtime_value = getattr(self, private_name) + else: + runtime_value = raw_value + setattr(self, private_name, runtime_value) + # sklearn <=1.2 requires the public attribute to be the exact + # object supplied to the outermost constructor. + setattr(self, name, raw_value) + self._constructor_params_raw = merged_raw + return result + + wrapped.__statgpu_constructor_capture__ = True + cls.__init__ = wrapped + + @classmethod + def _install_public_finite_validation(cls): + from statgpu.backends._validation import check_finite + + def wrap_method(original, method_name): + try: + signature = inspect.signature(original) + except (TypeError, ValueError): + return original + + @functools.wraps(original) + def guarded(self, *args, **kwargs): + try: + bound = signature.bind(self, *args, **kwargs) + except TypeError: + return original(self, *args, **kwargs) + loss_value = getattr(self, "loss", "") + loss_name = str(getattr(loss_value, "name", loss_value)).lower() + formula_active = bound.arguments.get("formula") is not None + try: + for name, value in bound.arguments.items(): + if name == "y" and loss_name in {"cox", "coxph", "cox_ph"}: + continue + formula_owned_pandas = formula_active or ( + method_name != "fit" + and name == "X" + and getattr(self, "_design_info", None) is not None + ) + formula_owned_side_array = ( + formula_active and name == "sample_weight" + ) + if formula_owned_side_array or ( + formula_owned_pandas + and type(value).__module__.startswith("pandas") + ): + continue + if name in self._FINITE_PARAMETER_NAMES and value is not None: + check_finite(value, name=name) + except Exception: + if method_name == "fit": + reset_fit_state = getattr(self, "_reset_fit_state", None) + if callable(reset_fit_state): + reset_fit_state() + else: + reset_cv_state = getattr( + self, "_reset_cv_fit_state", None + ) + if callable(reset_cv_state): + reset_cv_state() + raise + return original(self, *args, **kwargs) + + guarded.__statgpu_finite_validation__ = True + return guarded + + candidate_names = set(cls._FINITE_PUBLIC_METHODS) + candidate_names.update( + name for name in dir(cls) if not name.startswith("_") + ) + for method_name in sorted(candidate_names): + original = getattr(cls, method_name, None) + if not callable(original): + continue + if getattr(original, "__isabstractmethod__", False): + continue + if getattr(original, "__statgpu_finite_validation__", False): + continue + try: + signature = inspect.signature(original) + except (TypeError, ValueError): + continue + numerical_parameters = set(signature.parameters) & cls._FINITE_PARAMETER_NAMES + if method_name not in cls._FINITE_PUBLIC_METHODS and not numerical_parameters: + continue + setattr(cls, method_name, wrap_method(original, method_name)) + def __init__( self, device: Union[str, Device] = Device.AUTO, @@ -45,15 +415,16 @@ def __init__( Number of parallel jobs for CPU computation. -1 means using all processors. """ - self.device = device if isinstance(device, Device) else Device(device) + self.device = device + self._device = device if isinstance(device, Device) else Device(device) self.n_jobs = n_jobs self._fitted = False def _get_compute_device(self) -> Device: """Resolve device for actual computation.""" - if self.device == Device.AUTO: + if self._device == Device.AUTO: return get_device() - return self.device + return self._device def _get_backend(self, backend: str = "auto") -> BackendBase: """ @@ -75,7 +446,7 @@ def _get_backend(self, backend: str = "auto") -> BackendBase: device_str = compute_device.value # 'cpu', 'cuda', or 'torch' if ( - self.device != Device.AUTO + self._device != Device.AUTO and compute_device == Device.CUDA and backend == "auto" ): @@ -516,18 +887,70 @@ def _check_is_fitted(self): "Call 'fit' before using this method." ) + def _statgpu_estimator_type(self): + """Return the class-level sklearn estimator classification.""" + estimator_type = getattr(type(self), "_estimator_type", None) + if estimator_type in {"classifier", "regressor"}: + return estimator_type + return None + + def __sklearn_tags__(self): + """Return public estimator tags when sklearn >= 1.6 is installed.""" + estimator_type = self._statgpu_estimator_type() + try: + from sklearn.utils import ( + ClassifierTags, + RegressorTags, + Tags, + TargetTags, + TransformerTags, + ) + except ImportError: + return self._more_tags() + + has_transform = callable(getattr(self, "transform", None)) + return Tags( + estimator_type=estimator_type, + target_tags=TargetTags(required=estimator_type is not None), + transformer_tags=TransformerTags() if has_transform else None, + classifier_tags=( + ClassifierTags() if estimator_type == "classifier" else None + ), + regressor_tags=( + RegressorTags() if estimator_type == "regressor" else None + ), + requires_fit=True, + ) + + def _more_tags(self): + """Return the legacy sklearn tag dictionary.""" + estimator_type = self._statgpu_estimator_type() + return {"requires_y": estimator_type in {"classifier", "regressor"}} + + def __sklearn_is_fitted__(self): + """Expose statgpu fitted state to sklearn meta-estimators.""" + return bool(getattr(self, "_fitted", False)) + def __sklearn_clone__(self): - """Return an unfitted estimator clone for scikit-learn >= 1.3. + """Return an unfitted recursive clone for scikit-learn >= 1.3. - Several statgpu constructors validate or canonicalize immutable strings - and copy mutable dictionaries. scikit-learn's legacy identity check - treats those defensive copies as constructor mutation. The explicit - clone protocol preserves the public constructor values while discarding - fitted state. + Constructor values are preserved by the public raw-parameter contract, + while estimator-valued parameters must be cloned recursively so fitted + state is never copied into the new estimator. """ from copy import deepcopy - return type(self)(**deepcopy(self.get_params(deep=False))) + params = self.get_params(deep=False) + try: + from sklearn.base import clone as sklearn_clone + except ImportError: + cloned_params = deepcopy(params) + else: + cloned_params = { + name: sklearn_clone(value, safe=False) + for name, value in params.items() + } + return type(self)(**cloned_params) def get_params(self, deep=True): """Get constructor parameters for this estimator. @@ -538,6 +961,7 @@ def get_params(self, deep=True): import inspect params = {} + raw_params = getattr(self, "_constructor_params_raw", {}) try: sig = inspect.signature(type(self).__init__) except (ValueError, TypeError): @@ -549,7 +973,9 @@ def get_params(self, deep=True): inspect.Parameter.VAR_KEYWORD, ): continue - if hasattr(self, name): + if name in raw_params: + params[name] = raw_params[name] + elif hasattr(self, name): params[name] = getattr(self, name) elif hasattr(self, f"_{name}"): params[name] = getattr(self, f"_{name}") @@ -563,43 +989,106 @@ def get_params(self, deep=True): def set_params(self, **params): - """Set estimator parameters, validating names and nesting.""" + """Set parameters transactionally and refresh normalized state.""" if not params: return self - valid_params = self.get_params(deep=True) - nested_params = {} + import copy + from collections.abc import Iterator + valid_deep = self.get_params(deep=True) + direct = self.get_params(deep=False) + direct_updates = {} + nested = {} for key, value in params.items(): root, delimiter, sub_key = key.partition("__") - if root not in valid_params: - valid_names = sorted(name for name in valid_params if "__" not in name) + if key not in valid_deep and root not in direct: + valid_names = sorted( + name for name in valid_deep if "__" not in name + ) raise ValueError( f"Invalid parameter {root!r} for estimator " - f"{self.__class__.__name__}. Valid parameters are: " - f"{', '.join(valid_names)}." + f"{type(self).__name__}. Valid parameters are: {valid_names}." ) - if delimiter: - nested_params.setdefault(root, {})[sub_key] = value - continue + nested.setdefault(root, {})[sub_key] = value + else: + direct[root] = value + direct_updates[root] = value + + explicitly_updated = set(direct_updates) + for key, value in tuple(direct.items()): + if isinstance(value, Iterator) and key not in explicitly_updated: + snapshot = getattr(self, "_cox_cv_split_snapshot", None) + if snapshot is None: + snapshot = list(value) + direct[key] = copy.deepcopy(snapshot) + + if nested: + try: + from sklearn.base import clone as sklearn_clone + except ImportError: + sklearn_clone = None + for root in nested: + nested_value = direct[root] + if sklearn_clone is not None and hasattr(nested_value, "get_params"): + direct[root] = sklearn_clone(nested_value) + else: + direct[root] = copy.deepcopy(nested_value) - if root == "device" and isinstance(value, str): - value = Device(value) - if hasattr(self, root): - setattr(self, root, value) + try: + fresh = type(self)(**direct) + except (TypeError, ValueError): + deferred = set(getattr(type(self), "_DEFERRED_SET_PARAMS", ())) + if nested or not direct_updates or not set(direct_updates).issubset(deferred): + raise + # A small number of estimators intentionally validate selected + # controls at fit time. Apply only those explicitly declared values + # after the complete update has been classified as deferred. + for key, value in direct_updates.items(): + setattr(self, key, value) + if key in self._NORMALIZED_CONSTRUCTOR_PARAMS: + setattr(self, self._normalized_private_name(key), value) + raw_params = getattr(self, "_constructor_params_raw", None) + if raw_params is None: + raw_params = {} + self._constructor_params_raw = raw_params + raw_params[key] = value + reset = getattr(self, "_reset_fit_state", None) + if callable(reset): + reset() else: - setattr(self, f"_{root}", value) + self._fitted = False + return self - for root, sub_params in nested_params.items(): - nested_estimator = getattr(self, root, None) + for root, sub_params in nested.items(): + nested_estimator = getattr(fresh, root, None) if nested_estimator is None: - nested_estimator = getattr(self, f"_{root}", None) + nested_estimator = getattr(fresh, f"_{root}", None) if not hasattr(nested_estimator, "set_params"): raise ValueError( - f"Parameter {root!r} of {self.__class__.__name__} " - "does not support nested parameters." + f"Parameter {root!r} of {type(self).__name__} does not " + "support nested parameters." ) nested_estimator.set_params(**sub_params) + self.__dict__.clear() + self.__dict__.update(fresh.__dict__) return self + + +def refresh_public_finite_validation_contracts(): + """Install finite guards after all estimator mixins and aliases are bound.""" + BaseEstimator._install_public_finite_validation() + seen = set() + stack = list(BaseEstimator.__subclasses__()) + while stack: + estimator_cls = stack.pop() + if estimator_cls in seen: + continue + seen.add(estimator_cls) + stack.extend(estimator_cls.__subclasses__()) + estimator_cls._install_public_finite_validation() + + +BaseEstimator._install_public_finite_validation() diff --git a/statgpu/backends/__init__.py b/statgpu/backends/__init__.py index 120a11c69..e2dc1b655 100644 --- a/statgpu/backends/__init__.py +++ b/statgpu/backends/__init__.py @@ -27,6 +27,12 @@ from ._cupy import CuPyBackend from ._torch import TorchBackend from ._factory import get_backend +from ._torch_compile import ( + compile_torch, + get_torch_compile_diagnostics, + resolve_torch_compile_mode, + torch_compile_available, +) from ._utils import ( _get_xp, _to_numpy, @@ -57,6 +63,10 @@ "CuPyBackend", "TorchBackend", "get_backend", + "compile_torch", + "get_torch_compile_diagnostics", + "resolve_torch_compile_mode", + "torch_compile_available", "_is_cupy_array", "_is_torch_array", "_resolve_backend", diff --git a/statgpu/backends/_array_ops.py b/statgpu/backends/_array_ops.py index 4863b1d28..d837fe00a 100644 --- a/statgpu/backends/_array_ops.py +++ b/statgpu/backends/_array_ops.py @@ -100,7 +100,15 @@ def _zeros(n, backend, ref_tensor=None, dtype=None): """Create a 1-D zeros vector on the requested backend.""" backend = _resolve_backend(backend, ref_tensor) if backend == "numpy": - return np.zeros(n, dtype=dtype) + ref_dtype = getattr(ref_tensor, "dtype", None) + out_dtype = dtype + if out_dtype is None: + out_dtype = ( + ref_dtype + if ref_dtype is not None and np.issubdtype(ref_dtype, np.floating) + else np.float64 + ) + return np.zeros(n, dtype=out_dtype) if backend == "cupy": import cupy as cp out_dtype = ( @@ -182,7 +190,48 @@ def _to_backend(arr, backend="auto", ref_tensor=None, dtype=None): else torch.float64 ) return torch.as_tensor(arr, dtype=out_dtype, device=device) - return np.asarray(arr, dtype=dtype or float) + out_dtype = dtype + if out_dtype is None: + ref_dtype = getattr(ref_tensor, "dtype", None) + out_dtype = ( + ref_dtype + if ref_dtype is not None and np.issubdtype(ref_dtype, np.floating) + else float + ) + return np.asarray(arr, dtype=out_dtype) + + +def _linear_solve_runtime_is_rank_failure(exc): + """Classify backend solve errors that may safely use least squares.""" + message = str(exc).lower() + return any( + marker in message + for marker in ( + "singular", + "not invertible", + "zero pivot", + "rank deficient", + "ill-conditioned", + "not positive-definite", + "not positive definite", + ) + ) + + +def _linalg_exception_is_rank_failure(exc): + """Return whether a backend linalg exception permits a numeric fallback. + + NumPy/CuPy expose dedicated ``LinAlgError`` classes, whereas Torch reports + rank and definiteness failures as ``RuntimeError``. Runtime failures are + therefore message-classified so CUDA OOM, device, index, and programming + errors remain visible to callers. + """ + if isinstance(exc, np.linalg.LinAlgError): + return True + exc_type = type(exc) + if exc_type.__name__ == "LinAlgError" and "linalg" in exc_type.__module__.lower(): + return True + return isinstance(exc, RuntimeError) and _linear_solve_runtime_is_rank_failure(exc) def _solve_linear_system(A, b, backend="auto"): @@ -198,18 +247,19 @@ def _solve_linear_system(A, b, backend="auto"): import cupy as cp return cp.linalg.solve(A, b) return np.linalg.solve(A, b) - except (np.linalg.LinAlgError, RuntimeError): - # LinAlgError for numpy/cupy singular matrices - # RuntimeError for torch singular matrices - if backend == "torch": - import torch - b_col = b.unsqueeze(1) if b.ndim == 1 else b - sol = torch.linalg.lstsq(A, b_col).solution - return sol.squeeze(1) if b.ndim == 1 else sol - if backend == "cupy": - import cupy as cp - return cp.linalg.lstsq(A, b)[0] - return np.linalg.lstsq(A, b, rcond=None)[0] + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise + + if backend == "torch": + import torch + b_col = b.unsqueeze(1) if b.ndim == 1 else b + sol = torch.linalg.lstsq(A, b_col).solution + return sol.squeeze(1) if b.ndim == 1 else sol + if backend == "cupy": + import cupy as cp + return cp.linalg.lstsq(A, b)[0] + return np.linalg.lstsq(A, b, rcond=None)[0] def _eye_like(n, ref): diff --git a/statgpu/backends/_torch_compile.py b/statgpu/backends/_torch_compile.py new file mode 100644 index 000000000..809851075 --- /dev/null +++ b/statgpu/backends/_torch_compile.py @@ -0,0 +1,192 @@ +"""Safe, centralized policy for internal :func:`torch.compile` use. + +statgpu iterative solvers reuse tensors across calls. PyTorch's +``reduce-overhead`` mode enables CUDA Graphs and can therefore expose +overwritten-output lifecycle errors on PyTorch 2.1 and newer. Internal +kernels therefore remain eager by default. Users can explicitly opt in +to ``default`` or ``reduce-overhead`` compilation through +``STATGPU_TORCH_COMPILE_MODE``. +""" + +from __future__ import annotations + +import functools +import os +from collections import deque +import warnings +from typing import Callable, Optional + +_ENV_NAME = "STATGPU_TORCH_COMPILE_MODE" +_ALLOWED_MODES = frozenset({"auto", "default", "reduce-overhead", "disable"}) +_COMPILE_DIAGNOSTICS = deque(maxlen=256) + + +def resolve_torch_compile_mode( + *, + workload: str = "general", + requested_mode: Optional[str] = None, +) -> Optional[str]: + """Resolve an explicitly opted-in mode for a statgpu callable. + + ``auto`` is intentionally eager. ``workload`` and ``requested_mode`` + remain part of the internal call-site contract, but neither can + silently enable compilation when the environment is unset or set to + ``auto``. + """ + configured = os.environ.get(_ENV_NAME, "auto").strip().lower() + if configured not in _ALLOWED_MODES: + allowed = ", ".join(sorted(_ALLOWED_MODES)) + raise ValueError( + f"{_ENV_NAME} must be one of {allowed}; got {configured!r}" + ) + if configured in {"auto", "disable"}: + return None + return configured + + +def torch_compile_available() -> bool: + """Return whether the local Torch installation can compile safely.""" + try: + import torch + except Exception: + return False + if not callable(getattr(torch, "compile", None)): + return False + try: + if torch.cuda.is_available(): + return torch.cuda.get_device_capability()[0] >= 7 + except Exception: + return False + return True + + +def _record_compile_event(*, fn, status, mode, workload, error=None) -> None: + _COMPILE_DIAGNOSTICS.append( + { + "function": getattr( + fn, "__qualname__", getattr(fn, "__name__", repr(fn)) + ), + "status": status, + "mode": mode, + "workload": workload, + "error": error, + } + ) + + +def get_torch_compile_diagnostics(*, clear: bool = False): + """Return snapshots of internal Torch compile decisions. + + The returned dictionaries expose whether a callable is compiled, disabled, + unavailable, or using an explicit construction/runtime eager fallback. + """ + snapshot = tuple(dict(event) for event in _COMPILE_DIAGNOSTICS) + if clear: + _COMPILE_DIAGNOSTICS.clear() + return snapshot + + +def _is_cudagraph_lifecycle_error(exc: BaseException) -> bool: + message = str(exc).lower() + has_cudagraph = "cudagraph" in message + has_overwrite = "overwrit" in message + has_tensor_output = "tensor output" in message or "accessing tensor" in message + return has_cudagraph and has_overwrite and has_tensor_output + + +def compile_torch( + fn: Callable, + *, + workload: str = "general", + mode: Optional[str] = None, + **compile_kwargs, +) -> Callable: + """Compile ``fn`` under the statgpu policy with observable eager fallback. + + A construction failure emits a warning and returns an eager wrapper carrying + diagnostic attributes. At invocation time, only the known CUDA Graph tensor + output lifecycle failure disables compilation; unrelated runtime errors are + re-raised. + """ + resolved_mode = resolve_torch_compile_mode( + workload=workload, + requested_mode=mode, + ) + + def eager_wrapper(status, error=None): + @functools.wraps(fn) + def eager(*args, **kwargs): + return fn(*args, **kwargs) + + eager.__statgpu_compile_mode__ = resolved_mode + eager.__statgpu_compile_workload__ = workload + eager.__statgpu_compile_status__ = status + eager.__statgpu_compile_error__ = error + _record_compile_event( + fn=fn, + status=status, + mode=resolved_mode, + workload=workload, + error=error, + ) + return eager + + if resolved_mode is None: + return eager_wrapper("disabled") + if not torch_compile_available(): + return eager_wrapper("unavailable") + + try: + import torch + compiled = torch.compile(fn, mode=resolved_mode, **compile_kwargs) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + warnings.warn( + "torch.compile construction failed; falling back to eager execution " + f"for this statgpu kernel: {error}", + RuntimeWarning, + stacklevel=2, + ) + return eager_wrapper("construction-fallback", error) + + state = {"disabled": False} + + @functools.wraps(fn) + def guarded(*args, **kwargs): + if state["disabled"]: + return fn(*args, **kwargs) + try: + return compiled(*args, **kwargs) + except RuntimeError as exc: + if not _is_cudagraph_lifecycle_error(exc): + raise + state["disabled"] = True + error = f"{type(exc).__name__}: {exc}" + guarded.__statgpu_compile_status__ = "runtime-fallback" + guarded.__statgpu_compile_error__ = error + _record_compile_event( + fn=fn, + status="runtime-fallback", + mode=resolved_mode, + workload=workload, + error=error, + ) + warnings.warn( + "torch.compile CUDA Graph lifecycle failure; " + "falling back to eager execution for this statgpu kernel", + RuntimeWarning, + stacklevel=2, + ) + return fn(*args, **kwargs) + + guarded.__statgpu_compile_mode__ = resolved_mode + guarded.__statgpu_compile_workload__ = workload + guarded.__statgpu_compile_status__ = "compiled" + guarded.__statgpu_compile_error__ = None + _record_compile_event( + fn=fn, + status="compiled", + mode=resolved_mode, + workload=workload, + ) + return guarded diff --git a/statgpu/backends/_validation.py b/statgpu/backends/_validation.py new file mode 100644 index 000000000..0b84f4e03 --- /dev/null +++ b/statgpu/backends/_validation.py @@ -0,0 +1,99 @@ +"""Backend-native validation helpers for public numerical inputs.""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + + +def _raise_nonfinite(name: str) -> None: + raise ValueError( + f"{name} must contain only finite values; found NaN or infinite values" + ) + + +def check_finite(value: Any, *, name: str = "array") -> Any: + """Reject NaN/Inf without transferring complete GPU arrays to CPU. + + NumPy, CuPy, Torch, scipy/cupyx sparse values, pandas numerical data, + scalars, and nested/object sequences are checked. GPU arrays perform the + reduction on device and synchronize only the final scalar boolean. The + original object is returned unchanged. + """ + if value is None: + return value + + if isinstance(value, (float, np.floating, complex, np.complexfloating)): + real = float(np.real(value)) + imag = float(np.imag(value)) + if not math.isfinite(real) or not math.isfinite(imag): + _raise_nonfinite(name) + return value + if isinstance(value, (int, np.integer, bool, np.bool_)): + return value + + module = type(value).__module__ + + if module.startswith("scipy.sparse") or module.startswith("cupyx.scipy.sparse"): + check_finite(value.data, name=name) + return value + + if module.startswith("torch"): + import torch + + tensor = value + if getattr(tensor, "layout", torch.strided) != torch.strided: + tensor = tensor.values() + if not bool(torch.isfinite(tensor).all().item()): + _raise_nonfinite(name) + return value + + if module.startswith("cupy"): + import cupy as cp + + if not bool(cp.isfinite(value).all().item()): + _raise_nonfinite(name) + return value + + if module.startswith("pandas"): + import pandas as pd + + missing = pd.isna(value) + if hasattr(missing, "to_numpy"): + missing = missing.to_numpy() + if bool(np.asarray(missing).any()): + _raise_nonfinite(name) + try: + array = value.to_numpy() + except Exception: + return value + if array.dtype.kind in "biufc": + if not np.isfinite(array).all(): + _raise_nonfinite(name) + return value + if array.dtype.kind == "O": + for index, item in np.ndenumerate(array): + check_finite(item, name=f"{name}{index}") + return value + + try: + array = np.asarray(value) + except (TypeError, ValueError): + if isinstance(value, (list, tuple)): + for index, item in enumerate(value): + check_finite(item, name=f"{name}[{index}]") + return value + + if array.dtype.kind in "biufc": + if not np.isfinite(array).all(): + _raise_nonfinite(name) + return value + + if array.dtype.kind == "O": + for index, item in np.ndenumerate(array): + if item is value: + continue + check_finite(item, name=f"{name}{index}") + return value diff --git a/statgpu/core/formula/__init__.py b/statgpu/core/formula/__init__.py index 22038530b..9bfe02b4f 100644 --- a/statgpu/core/formula/__init__.py +++ b/statgpu/core/formula/__init__.py @@ -22,12 +22,14 @@ from ._parser import FormulaParser from ._design import parse_formula, parse_formula_safe +from ._alignment import align_formula_sample_weight from ._terms import make_surv_env, _surv __all__ = [ "FormulaParser", "parse_formula", "parse_formula_safe", + "align_formula_sample_weight", "make_surv_env", "_surv", ] diff --git a/statgpu/core/formula/_alignment.py b/statgpu/core/formula/_alignment.py new file mode 100644 index 000000000..8ee498fa1 --- /dev/null +++ b/statgpu/core/formula/_alignment.py @@ -0,0 +1,66 @@ +"""Alignment helpers for formula-owned side arrays.""" + +from __future__ import annotations + +import numpy as np + +from statgpu.glm_core._validation import validate_glm_sample_weight + + +def align_formula_sample_weight( + sample_weight, + *, + data_length: int, + retained_rows, + retained_length: int, +): + """Align and validate sample weights after formula row filtering. + + The input must be one-dimensional and may describe either the original + data rows or the rows retained by the formula parser. Validation occurs + after alignment so non-finite values located only in formula-dropped rows + do not produce false errors. Torch and CuPy indexing/reductions remain on + device. + """ + if sample_weight is None: + return None + + module = type(sample_weight).__module__ + if module.startswith("pandas"): + weights = sample_weight.to_numpy() + module = type(weights).__module__ + else: + weights = sample_weight + + if getattr(weights, "ndim", None) is None: + weights = np.asarray(weights) + module = type(weights).__module__ + if int(weights.ndim) != 1: + raise ValueError("sample_weight must be one-dimensional") + + n_weights = int(weights.shape[0]) + if n_weights == int(data_length): + if module.startswith("torch"): + import torch + + index = torch.as_tensor( + retained_rows, dtype=torch.long, device=weights.device + ) + aligned = torch.index_select(weights, 0, index) + elif module.startswith("cupy"): + import cupy as cp + + aligned = weights[cp.asarray(retained_rows, dtype=cp.int64)] + else: + aligned = np.asarray(weights)[np.asarray(retained_rows, dtype=np.int64)] + elif n_weights == int(retained_length): + aligned = weights + else: + raise ValueError( + "sample_weight must match the original data length or the number " + "of formula rows retained after missing-value filtering" + ) + + return validate_glm_sample_weight( + aligned, retained_length, name="sample_weight" + ) diff --git a/statgpu/covariance/_graphical_lasso.py b/statgpu/covariance/_graphical_lasso.py index 389b85645..742996eac 100644 --- a/statgpu/covariance/_graphical_lasso.py +++ b/statgpu/covariance/_graphical_lasso.py @@ -80,12 +80,12 @@ def fit(self, X, y=None): if not np.isfinite(alpha) or alpha < 0: raise ValueError("alpha must be finite and non-negative") if ( - isinstance(self.max_iter, bool) - or not isinstance(self.max_iter, (int, np.integer)) - or int(self.max_iter) < 1 + isinstance(self._max_iter, bool) + or not isinstance(self._max_iter, (int, np.integer)) + or int(self._max_iter) < 1 ): raise ValueError("max_iter must be a positive integer") - if not np.isfinite(float(self.tol)) or float(self.tol) <= 0: + if not np.isfinite(float(self._tol)) or float(self._tol) <= 0: raise ValueError("tol must be finite and positive") backend_name, xp, X_arr = self._prepare_input(X) @@ -104,7 +104,7 @@ def fit(self, X, y=None): self.n_iter_ = 1 else: covariance = _copy_array(empirical) - inner_tol = min(1e-8, float(self.tol) * 0.1) + inner_tol = min(1e-8, float(self._tol) * 0.1) # A full device-to-host scalar transfer after every coordinate # sweep dominates small GPU solves. Preserve Gauss-Seidel updates, # but check convergence only after a bounded batch of sweeps. @@ -122,7 +122,7 @@ def fit(self, X, y=None): "GraphicalLasso encountered a non-positive covariance diagonal" ) - for outer in range(int(self.max_iter)): + for outer in range(int(self._max_iter)): previous = _copy_array(covariance) self.n_iter_ = outer + 1 @@ -157,7 +157,7 @@ def fit(self, X, y=None): covariance[j, j] = empirical[j, j] outer_delta = _to_float_scalar(xp.max(xp.abs(covariance - previous))) - if outer_delta <= float(self.tol): + if outer_delta <= float(self._tol): break covariance = 0.5 * (covariance + covariance.T) @@ -211,19 +211,19 @@ def __init__( def fit(self, X, y=None): probe = GraphicalLasso( alpha=0.0, - max_iter=self.max_iter, - tol=self.tol, + max_iter=self._max_iter, + tol=self._tol, assume_centered=self.assume_centered, - device=self.device, + device=self._device, ) backend_name, xp, X_arr = probe._prepare_input(X) n, p = int(X_arr.shape[0]), int(X_arr.shape[1]) if ( - isinstance(self.cv, bool) - or not isinstance(self.cv, (int, np.integer)) - or int(self.cv) < 2 - or int(self.cv) > n + isinstance(self._cv, bool) + or not isinstance(self._cv, (int, np.integer)) + or int(self._cv) < 2 + or int(self._cv) > n ): raise ValueError("cv must satisfy 2 <= cv <= n_samples") @@ -237,17 +237,17 @@ def fit(self, X, y=None): raise ValueError("alphas must be finite, non-negative, and non-empty") rng = np.random.RandomState(self.random_state) - folds = np.array_split(rng.permutation(n), int(self.cv)) + folds = np.array_split(rng.permutation(n), int(self._cv)) cv_results = [] best_score = -np.inf best_alpha = float(alpha_grid[0]) for alpha in alpha_grid: scores = [] - for fold_index in range(int(self.cv)): + for fold_index in range(int(self._cv)): test_np = folds[fold_index] train_np = np.concatenate( - [folds[j] for j in range(int(self.cv)) if j != fold_index] + [folds[j] for j in range(int(self._cv)) if j != fold_index] ) train_idx = _index_array(train_np, xp, X_arr) test_idx = _index_array(test_np, xp, X_arr) @@ -256,10 +256,10 @@ def fit(self, X, y=None): model = GraphicalLasso( alpha=float(alpha), - max_iter=self.max_iter, - tol=self.tol, + max_iter=self._max_iter, + tol=self._tol, assume_centered=self.assume_centered, - device=self.device, + device=self._device, ).fit(X_train) scores.append(float(model.score(X_test))) @@ -273,10 +273,10 @@ def fit(self, X, y=None): final = GraphicalLasso( alpha=best_alpha, - max_iter=self.max_iter, - tol=self.tol, + max_iter=self._max_iter, + tol=self._tol, assume_centered=self.assume_centered, - device=self.device, + device=self._device, ).fit(X_arr) self.covariance_ = final.covariance_ diff --git a/statgpu/feature_selection/_knockoff.py b/statgpu/feature_selection/_knockoff.py index dcb653a2d..c45552075 100644 --- a/statgpu/feature_selection/_knockoff.py +++ b/statgpu/feature_selection/_knockoff.py @@ -8,6 +8,7 @@ import numpy as np from statgpu.feature_selection import _knockoff_utils as _kutils +from statgpu.backends._validation import check_finite from statgpu.feature_selection._knockoff_utils import ( _build_fixed_x_knockoffs, _build_model_x_knockoffs, @@ -295,6 +296,10 @@ def fixed_x_knockoff_filter( KnockoffResult Selected feature indices and full knockoff diagnostics. """ + check_finite(X, name="X") + check_finite(y, name="y") + if Xk is not None: + check_finite(Xk, name="Xk") q_f = _validate_q(q) compat = _normalize_compat_mode(compat_mode) lasso_impl = str(lasso_cv_impl).strip().lower() @@ -395,6 +400,10 @@ def model_x_knockoff_filter( This implementation estimates a Gaussian feature model and builds equi-correlated knockoffs from the estimated covariance. """ + check_finite(X, name="X") + check_finite(y, name="y") + if Xk is not None: + check_finite(Xk, name="Xk") q_f = _validate_q(q) compat = _normalize_compat_mode(compat_mode) lasso_impl = str(lasso_cv_impl).strip().lower() @@ -743,7 +752,40 @@ def knockoff_filter( ) -class KnockoffSelector: +class _KnockoffSelectorContract: + """Shared sklearn and finite-input contract for knockoff selectors.""" + + def _more_tags(self): + return {"requires_y": True} + + def __sklearn_tags__(self): + try: + from sklearn.utils import Tags, TargetTags, TransformerTags + except ImportError: + return self._more_tags() + return Tags( + estimator_type=None, + target_tags=TargetTags(required=True), + transformer_tags=TransformerTags(), + requires_fit=True, + ) + + def __sklearn_is_fitted__(self): + return getattr(self, "selected_features_", None) is not None + + @staticmethod + def _validate_fit_inputs(X, y, Xk=None): + check_finite(X, name="X") + check_finite(y, name="y") + if Xk is not None: + check_finite(Xk, name="Xk") + + @staticmethod + def _validate_transform_input(X): + check_finite(X, name="X") + + +class KnockoffSelector(_KnockoffSelectorContract): """Sklearn-like wrapper for unified knockoff feature selection.""" def __init__( @@ -786,6 +828,7 @@ def __init__( self.selected_features_: Optional[np.ndarray] = None def fit(self, X, y, Xk=None): + self._validate_fit_inputs(X, y, Xk) self.result_ = knockoff_filter( X, y, @@ -819,6 +862,7 @@ def get_support(self) -> np.ndarray: return mask def transform(self, X): + self._validate_transform_input(X) if self.selected_features_ is None: raise RuntimeError("Selector has not been fitted yet") module = type(X).__module__ @@ -870,20 +914,25 @@ def get_params(self, deep=True): } def set_params(self, **params): + if not params: + return self valid = self.get_params(deep=False) - for name, value in params.items(): - if name not in valid: - raise ValueError( - f"Invalid parameter {name!r} for KnockoffSelector. " - f"Valid parameters are: {', '.join(sorted(valid))}." - ) - setattr(self, name, value) - self.result_ = None - self.selected_features_ = None + unknown = [name for name in params if name not in valid] + if unknown: + name = unknown[0] + raise ValueError( + f"Invalid parameter {name!r} for KnockoffSelector. " + f"Valid parameters are: {', '.join(sorted(valid))}." + ) + updated = dict(valid) + updated.update(params) + fresh = type(self)(**updated) + self.__dict__.clear() + self.__dict__.update(fresh.__dict__) return self -class FixedXKnockoffSelector: +class FixedXKnockoffSelector(_KnockoffSelectorContract): """Sklearn-like wrapper for fixed-X knockoff feature selection.""" def __init__( @@ -921,6 +970,7 @@ def __init__( self.selected_features_: Optional[np.ndarray] = None def fit(self, X, y, Xk=None): + self._validate_fit_inputs(X, y, Xk) self._selector.fit(X, y, Xk=Xk) self.result_ = self._selector.result_ self.selected_features_ = self._selector.selected_features_ @@ -930,6 +980,7 @@ def get_support(self) -> np.ndarray: return self._selector.get_support() def transform(self, X): + self._validate_transform_input(X) return self._selector.transform(X) def fit_transform(self, X, y, Xk=None): @@ -948,27 +999,29 @@ def get_params(self, deep=True): } def set_params(self, **params): + if not params: + return self valid = self.get_params(deep=False) - for name, value in params.items(): - if name not in valid: - raise ValueError( - f"Invalid parameter {name!r} for FixedXKnockoffSelector. " - f"Valid parameters are: {', '.join(sorted(valid))}." - ) - setattr(self, name, value) - self._selector = KnockoffSelector( - knockoff_type="fixed_x", - q=self.q, - method=self.method, - fdr_control=self.fdr_control, - random_state=self.random_state, - backend=self.backend, - compat_mode=self.compat_mode, - lasso_cv_impl=self.lasso_cv_impl, - lasso_fast_profile=self.lasso_fast_profile, - ) - self.result_ = None - self.selected_features_ = None + unknown = [name for name in params if name not in valid] + if unknown: + name = unknown[0] + raise ValueError( + f"Invalid parameter {name!r} for FixedXKnockoffSelector. " + f"Valid parameters are: {', '.join(sorted(valid))}." + ) + updated = dict(valid) + updated.update(params) + fresh = type(self)(**updated) + self.__dict__.clear() + self.__dict__.update(fresh.__dict__) return self + + +for _selector_cls in (KnockoffSelector, FixedXKnockoffSelector): + for _method_name in ("fit", "fit_transform", "transform"): + _method = getattr(_selector_cls, _method_name, None) + if callable(_method): + _method.__statgpu_finite_validation__ = True +del _selector_cls, _method_name, _method diff --git a/statgpu/feature_selection/_stepwise.py b/statgpu/feature_selection/_stepwise.py index cc3364fb6..6b438e1d1 100644 --- a/statgpu/feature_selection/_stepwise.py +++ b/statgpu/feature_selection/_stepwise.py @@ -14,6 +14,7 @@ from joblib import Parallel, delayed from statgpu.backends import _to_float_scalar +from statgpu.backends._validation import check_finite from statgpu.linear_model import Lasso, LinearRegression, LogisticRegression, Ridge __all__ = ["StepwiseSelector", "stepwise_selection"] @@ -54,6 +55,24 @@ class StepwiseSelector: _VALID_CRITERIA = {"aic", "bic"} _VALID_DIRECTIONS = {"forward", "backward", "both"} + def _more_tags(self): + return {"requires_y": True} + + def __sklearn_tags__(self): + try: + from sklearn.utils import Tags, TargetTags, TransformerTags + except ImportError: + return self._more_tags() + return Tags( + estimator_type=None, + target_tags=TargetTags(required=True), + transformer_tags=TransformerTags(), + requires_fit=True, + ) + + def __sklearn_is_fitted__(self): + return bool(self._fitted and self.best_model_ is not None) + def __init__( self, model_class, @@ -111,6 +130,7 @@ def _reset_fit_state(self) -> None: @staticmethod def _prepare_X(X): + check_finite(X, name="X") if not hasattr(X, "shape") or not hasattr(X, "ndim"): X = np.asarray(X) if int(X.ndim) != 2: @@ -119,6 +139,7 @@ def _prepare_X(X): @staticmethod def _prepare_y(y): + check_finite(y, name="y") if not hasattr(y, "shape") or not hasattr(y, "ndim"): y = np.asarray(y) if int(y.ndim) == 2 and int(y.shape[1]) == 1: @@ -315,11 +336,17 @@ def _check_is_fitted(self) -> None: if not self._fitted or self.best_model_ is None: raise RuntimeError("StepwiseSelector has not been fitted yet") + def transform(self, X): + """Return the columns retained by the fitted selector.""" + self._check_is_fitted() + X = self._prepare_X(X) + return X[:, self.selected_features_] + def predict(self, X): """Predict with the selected feature subset.""" self._check_is_fitted() - X = self._prepare_X(X) - return self.best_model_.predict(X[:, self.selected_features_]) + X_selected = self.transform(X) + return self.best_model_.predict(X_selected) def score(self, X, y): """Return the wrapped estimator's score.""" @@ -362,21 +389,28 @@ def get_params(self, deep=True): return params def set_params(self, **params): - """Set selector or wrapped-model constructor parameters.""" - selector_names = { - "model_class", - "criterion", - "direction", - "max_features", - "n_jobs", - "verbose", + """Set parameters transactionally and clear fitted selection state.""" + if not params: + return self + + selector_values = { + "model_class": self.model_class, + "criterion": self.criterion, + "direction": self.direction, + "max_features": self.max_features, + "n_jobs": self.n_jobs, + "verbose": self.verbose, } + model_kwargs = dict(self.model_kwargs) for name, value in params.items(): - if name in selector_names: - setattr(self, name, value) + if name in selector_values: + selector_values[name] = value else: - self.model_kwargs[name] = value - self._validate_constructor_params() + model_kwargs[name] = value + + fresh = type(self)(**selector_values, **model_kwargs) + self.__dict__.clear() + self.__dict__.update(fresh.__dict__) return self diff --git a/statgpu/glm_core/_base.py b/statgpu/glm_core/_base.py index 2859c1de8..db1f6813c 100644 --- a/statgpu/glm_core/_base.py +++ b/statgpu/glm_core/_base.py @@ -58,6 +58,77 @@ def _mu_from_eta(self, eta): """Link inverse: μ = g⁻¹(η). Override for clipping.""" return eta # default: identity link + def validate_response(self, y): + """Validate the response domain on its current array backend. + + ``y_type`` is the shared public contract for every solver. Only the + final scalar boolean is synchronized; Torch/CuPy response arrays are + never copied to NumPy for validation. + """ + from statgpu.backends._array_ops import _xp + + xp = _xp(y) + if xp.__name__ == "torch": + import torch + + values = y if torch.is_tensor(y) else torch.as_tensor(y) + else: + try: + values = xp.asarray(y) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{self.name} response must be a numeric array-like." + ) from exc + + ndim = int(values.ndim) + if ndim == 2 and int(values.shape[1]) == 1: + values = values.reshape(-1) + elif ndim != 1: + raise ValueError( + f"{self.name} response must be one-dimensional; " + "a single-column (n_samples, 1) response is also accepted." + ) + if int(values.shape[0]) == 0: + raise ValueError( + f"{self.name} response must contain at least one observation." + ) + + if xp.__name__ == "torch": + import torch + + nonreal = torch.is_complex(values) + else: + nonreal = getattr(values.dtype, "kind", "") not in "biuf" + if bool(nonreal.item() if hasattr(nonreal, "item") else nonreal): + raise ValueError( + f"{self.name} response must contain real numeric values." + ) + + try: + invalid = xp.any(~xp.isfinite(values)) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{self.name} response must contain real numeric finite values." + ) from exc + y_type = str(getattr(self, "y_type", "continuous")).lower() + if y_type == "binary": + invalid = invalid | xp.any(values < 0) | xp.any(values > 1) + requirement = "values in [0, 1]" + elif y_type in ("count", "nonnegative"): + invalid = invalid | xp.any(values < 0) + requirement = "non-negative values" + elif y_type == "positive": + invalid = invalid | xp.any(values <= 0) + requirement = "strictly positive values" + else: + requirement = "finite values" + + if bool(invalid.item() if hasattr(invalid, "item") else invalid): + raise ValueError( + f"{self.name} response requires finite {requirement}." + ) + return values + def fused_value_and_gradient(self, X, y, coef, sample_weight=None): """Fused value+gradient using GLM-specific optimized kernels. diff --git a/statgpu/glm_core/_family.py b/statgpu/glm_core/_family.py index 8c8bf1a7d..7e3e23ae9 100644 --- a/statgpu/glm_core/_family.py +++ b/statgpu/glm_core/_family.py @@ -245,12 +245,16 @@ def variance(self, mu): def irls_weights(self, mu, y): mu_c = _clip(mu, 1e-10, 1 - 1e-10) - return mu_c * (1 - mu_c) + if str(getattr(self.link, "name", "")).lower() == "logit": + return mu_c * (1 - mu_c) + return super().irls_weights(mu_c, y) def irls_working_response(self, mu, y, eta): mu_c = _clip(mu, 1e-10, 1 - 1e-10) - var = mu_c * (1 - mu_c) - return eta + (y - mu_c) / var + if str(getattr(self.link, "name", "")).lower() == "logit": + var = mu_c * (1 - mu_c) + return eta + (y - mu_c) / var + return super().irls_working_response(mu_c, y, eta) class Poisson(GLMFamily): diff --git a/statgpu/glm_core/_irls.py b/statgpu/glm_core/_irls.py index bcec305be..901d4ad66 100644 --- a/statgpu/glm_core/_irls.py +++ b/statgpu/glm_core/_irls.py @@ -6,10 +6,13 @@ """ import warnings +from numbers import Integral, Real from typing import Optional import numpy as np +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure + def _infer_backend(X): """Detect backend from array type.""" @@ -22,30 +25,35 @@ def _infer_backend(X): def _solve(A, b, backend="auto"): - """Solve linear system, fallback to lstsq if singular.""" + """Solve a linear system, using least squares only for singular systems.""" if backend == "auto": backend = _infer_backend(A) - try: - if backend == "torch": - import torch - b_col = b.unsqueeze(1) if b.ndim == 1 else b + if backend == "torch": + import torch + + b_col = b.unsqueeze(1) if b.ndim == 1 else b + try: sol = torch.linalg.solve(A, b_col) - return sol.squeeze(1) if b.ndim == 1 else sol - elif backend == "cupy": - import cupy as cp - return cp.linalg.solve(A, b) - else: - return np.linalg.solve(A, b) - except (np.linalg.LinAlgError, ValueError, RuntimeError): - if backend == "torch": - import torch - b_col = b.unsqueeze(1) if b.ndim == 1 else b + except RuntimeError as exc: + if not _linalg_exception_is_rank_failure(exc): + raise sol = torch.linalg.lstsq(A, b_col).solution - return sol.squeeze(1) if b.ndim == 1 else sol - elif backend == "cupy": - import cupy as cp + return sol.squeeze(1) if b.ndim == 1 else sol + + if backend == "cupy": + import cupy as cp + + try: + return cp.linalg.solve(A, b) + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise return cp.linalg.lstsq(A, b)[0] + + try: + return np.linalg.solve(A, b) + except np.linalg.LinAlgError: return np.linalg.lstsq(A, b, rcond=None)[0] @@ -66,6 +74,10 @@ def _norm(x, backend): import torch return float(torch.linalg.norm(x).item()) + if backend == "cupy": + import cupy as cp + + return float(cp.linalg.norm(x).item()) return float(np.linalg.norm(x)) @@ -100,7 +112,11 @@ def _to_backend(arr, backend, ref_tensor): return cp.asarray(arr, dtype=cp.float64) if backend == "torch": import torch - return torch.tensor(arr, dtype=torch.float64, device=ref_tensor.device if ref_tensor is not None else "cpu") + + device = ref_tensor.device if ref_tensor is not None else "cpu" + if torch.is_tensor(arr): + return arr.to(dtype=torch.float64, device=device) + return torch.as_tensor(arr, dtype=torch.float64, device=device) return np.asarray(arr, dtype=float) @@ -121,16 +137,57 @@ def _copy_arr(arr): _IRLS_STEP_COMPILED = None -def _torch_compile_supported(): - """Check if torch.compile is safe (CUDA Capability >= 7.0).""" - try: - import torch - if torch.cuda.is_available(): - cap = torch.cuda.get_device_capability() - return cap[0] >= 7 - except Exception: - pass - return True +from statgpu.backends._torch_compile import compile_torch + + +class _BinomialFamilyObjective: + """Bernoulli negative log-likelihood for an arbitrary Binomial link.""" + + def __init__(self, family): + from statgpu.glm_core._logistic import LogisticLoss + + self.family = family + self._validator = LogisticLoss() + + def validate_response(self, y): + return self._validator.validate_response(y) + + def per_sample_value(self, eta, y): + from statgpu.backends._array_ops import _clip as _array_clip, _log + + mu = _array_clip(self.family.link.inverse(eta), 1e-10, 1 - 1e-10) + return -y * _log(mu) - (1 - y) * _log(1 - mu) + + +def _objective_loss_for_family(family): + """Return the objective matching the exact IRLS family and link.""" + from statgpu.glm_core._base import get_glm_loss + + family_name = str(getattr(family, "name", "")).lower() + if family_name in {"binomial", "logistic"}: + return _BinomialFamilyObjective(family) + loss_names = { + "gaussian": "squared_error", + "squared_error": "squared_error", + "poisson": "poisson", + "gamma": "gamma", + "inverse_gaussian": "inverse_gaussian", + "negative_binomial": "negative_binomial", + "tweedie": "tweedie", + } + if family_name not in loss_names: + raise NotImplementedError( + "IRLS line search requires a registered objective for family " + f"{family_name!r}." + ) + kwargs = {} + if family_name == "gamma": + kwargs["link"] = str(getattr(family.link, "name", "log")).lower() + elif family_name == "negative_binomial": + kwargs["alpha"] = float(getattr(family, "alpha", 1.0)) + elif family_name == "tweedie": + kwargs["power"] = float(getattr(family, "power", 1.5)) + return get_glm_loss(loss_names[family_name], **kwargs) def _get_irls_step_compiled(): @@ -148,28 +205,18 @@ def _irls_weighted_gemm(X, W, z): Xtz = X.T @ (W * z) return XtWX, Xtz - if _torch_compile_supported(): - try: - _IRLS_STEP_COMPILED = torch.compile(_irls_weighted_gemm, dynamic=True, fullgraph=False) - except Exception: - _IRLS_STEP_COMPILED = _irls_weighted_gemm - else: - _IRLS_STEP_COMPILED = _irls_weighted_gemm - + _IRLS_STEP_COMPILED = compile_torch( + _irls_weighted_gemm, + workload="iterative", + dynamic=True, + fullgraph=False, + ) return _IRLS_STEP_COMPILED def _irls_step_call(compiled_fn, *args): - """Call compiled IRLS step, falling back to eager on GPU arch mismatch.""" - try: - return compiled_fn(*args) - except Exception: - def _irls_gemm_eager(X, W, z): - W_col = W.unsqueeze(1) - XtWX = X.T @ (X * W_col) - Xtz = X.T @ (W * z) - return XtWX, Xtz - return _irls_gemm_eager(*args) + """Call the centrally managed compiled IRLS step.""" + return compiled_fn(*args) def irls_solver( @@ -210,9 +257,9 @@ def irls_solver( backend : str 'numpy', 'cupy', 'torch', or 'auto'. penalty_matrix : array, optional - Additional penalty matrix to add to the normal equations. - Shape must be (n_features, n_features). When provided, the - normal equations become: X'WX + ridge_alpha*I + penalty_matrix. + Real, finite, symmetric positive-semidefinite quadratic penalty + with shape ``(n_features, n_features)``. The normal equations and + line-search objective use the same quadratic form. Returns ------- @@ -221,51 +268,102 @@ def irls_solver( n_iter : int Number of iterations. """ - if backend == "auto": - backend = _infer_backend(X) + from statgpu.glm_core._validation import ( + validate_glm_design_matrix, + validate_glm_sample_weight, + ) + if isinstance(max_iter, bool) or not isinstance(max_iter, Integral) or int(max_iter) < 1: + raise ValueError("max_iter must be a positive integer") + if isinstance(tol, bool) or not isinstance(tol, Real): + raise ValueError("tol must be a finite positive real number") + tol = float(tol) + if not np.isfinite(tol) or tol <= 0.0: + raise ValueError("tol must be a finite positive real number") + if isinstance(ridge_alpha, bool) or not isinstance(ridge_alpha, Real): + raise ValueError("ridge_alpha must be a finite non-negative real number") + ridge_alpha = float(ridge_alpha) + if not np.isfinite(ridge_alpha) or ridge_alpha < 0.0: + raise ValueError("ridge_alpha must be a finite non-negative real number") + if not isinstance(ridge_penalize_intercept, (bool, np.bool_)): + raise ValueError("ridge_penalize_intercept must be boolean") + max_iter = int(max_iter) + + X_validated = validate_glm_design_matrix(X) + if backend == "auto": + backend = _infer_backend(X_validated) + backend = str(backend).lower() + backend = {"cpu": "numpy", "cuda": "cupy"}.get(backend, backend) + if backend not in {"numpy", "cupy", "torch"}: + raise ValueError("backend must be one of: 'auto', 'numpy', 'cupy', 'torch'") + X = _to_backend(X_validated, backend, X_validated) + + n_features = int(X.shape[1]) if init_coef is None: - n_features = X.shape[1] params = _zeros(n_features, backend, ref_tensor=X) else: - params = init_coef + params = _to_backend(init_coef, backend, X).reshape(-1) + if int(params.shape[0]) != n_features: + raise ValueError("init_coef must have length X.shape[1].") + params = _copy_arr(params) - y_work = _to_backend(y, backend, X) family_name = getattr(family, "name", "") - if backend == "torch": - import torch - invalid_y = torch.any(~torch.isfinite(y_work)) - if family_name == "gamma": - invalid_y = invalid_y | torch.any(y_work <= 0) - elif family_name == "tweedie": - invalid_y = invalid_y | torch.any(y_work < 0) - elif backend == "cupy": - import cupy as cp - invalid_y = cp.any(~cp.isfinite(y_work)) - if family_name == "gamma": - invalid_y = invalid_y | cp.any(y_work <= 0) - elif family_name == "tweedie": - invalid_y = invalid_y | cp.any(y_work < 0) - else: - invalid_y = np.any(~np.isfinite(y_work)) - if family_name == "gamma": - invalid_y = invalid_y or np.any(y_work <= 0) - elif family_name == "tweedie": - invalid_y = invalid_y or np.any(y_work < 0) - if bool(invalid_y.item() if hasattr(invalid_y, "item") else invalid_y): - requirement = "strictly positive" if family_name == "gamma" else "non-negative" - raise ValueError( - f"{family_name} IRLS requires finite, {requirement} y values." - ) - sw_work = ( - _to_backend(sample_weight, backend, X) + objective_loss = _objective_loss_for_family(family) + y_validated = objective_loss.validate_response(y) + y_work = _to_backend(y_validated, backend, X) + if int(y_work.shape[0]) != int(X.shape[0]): + raise ValueError("Response length must match X.shape[0].") + sw_validated = ( + validate_glm_sample_weight(sample_weight, X.shape[0]) if sample_weight is not None else None ) - penalty_matrix_work = ( - _to_backend(penalty_matrix, backend, X) + sw_work = ( + _to_backend(sw_validated, backend, X) + if sw_validated is not None else None + ) + penalty_matrix_validated = ( + validate_glm_design_matrix(penalty_matrix, name="penalty_matrix") if penalty_matrix is not None else None ) + if penalty_matrix_validated is not None and tuple( + penalty_matrix_validated.shape + ) != (n_features, n_features): + raise ValueError( + "penalty_matrix must have shape (X.shape[1], X.shape[1])" + ) + penalty_matrix_work = ( + _to_backend(penalty_matrix_validated, backend, X) + if penalty_matrix_validated is not None else None + ) + if penalty_matrix_work is not None: + if backend == "torch": + import torch + + symmetric = bool(torch.allclose( + penalty_matrix_work, penalty_matrix_work.T, rtol=1e-10, atol=1e-12 + )) + min_eig = float(torch.linalg.eigvalsh(penalty_matrix_work).min().item()) + scale = max(1.0, float(torch.max(torch.abs(penalty_matrix_work)).item())) + elif backend == "cupy": + import cupy as cp + + symmetric = bool(cp.allclose( + penalty_matrix_work, penalty_matrix_work.T, rtol=1e-10, atol=1e-12 + ).item()) + min_eig = float(cp.linalg.eigvalsh(penalty_matrix_work).min().item()) + scale = max(1.0, float(cp.max(cp.abs(penalty_matrix_work)).item())) + else: + symmetric = bool(np.allclose( + penalty_matrix_work, penalty_matrix_work.T, rtol=1e-10, atol=1e-12 + )) + min_eig = float(np.linalg.eigvalsh(penalty_matrix_work).min()) + scale = max(1.0, float(np.max(np.abs(penalty_matrix_work)))) + if not symmetric: + raise ValueError("penalty_matrix must be symmetric") + if min_eig < -1e-10 * scale: + raise ValueError("penalty_matrix must be positive semidefinite") line_search_failed = False + converged = False iteration = 0 for iteration in range(max_iter): params_old = _copy_arr(params) @@ -323,115 +421,22 @@ def irls_solver( params_new = _solve(XtWX, Xtz, backend) - # Armijo backtracking line search: find step in (0, 1] that - # gives sufficient decrease in the loss (deviance). - _fname = family_name - _tweedie_power = float(getattr(family, 'power', 1.5)) if _fname == "tweedie" else 0.0 - _nb_alpha = float(getattr(family, 'alpha', 1.0)) if _fname == "negative_binomial" else 0.0 - - def _dev_val(mu_arr): - """Compute family-specific deviance (lower is better). - - Returns device-side value (no GPU→CPU sync) for torch/cupy. - Correct Tweedie deviance for power p (p != 1, p != 2): - d(y, mu) = y*(y^(1-p) - mu^(1-p))/(1-p) - (y^(2-p) - mu^(2-p))/(2-p) - """ - _y = y_work + # Backtracking line search on the same registered loss used by the + # public GLM objective. Loss classes own link/domain clipping, so the + # identity-link Gaussian path is never spuriously clipped to [-30, 30]. + def _loss_val(eta_arr): + terms = objective_loss.per_sample_value(eta_arr, y_work) + if sw_work is not None: + terms = terms * sw_work if backend == "torch": import torch - if _fname in ("gaussian", "squared_error"): - return 0.5 * torch.sum((_y - mu_arr) ** 2) - elif _fname in ("binomial", "logistic"): - _mu_c = torch.clamp(mu_arr, min=1e-10, max=1.0 - 1e-10) - return torch.sum(-_y * torch.log(_mu_c) - - (1.0 - _y) * torch.log1p(-_mu_c)) - elif _fname == "gamma": - return torch.sum(_y / mu_arr + torch.log(mu_arr)) - elif _fname == "inverse_gaussian": - return torch.sum(_y / (2.0 * mu_arr ** 2) - 1.0 / mu_arr) - elif _fname == "negative_binomial": - _mu_c = torch.clamp(mu_arr, min=1e-10) - _y_c = torch.clamp(_y, min=1e-10) - _a = _nb_alpha - return torch.sum( - _y_c * torch.log(_y_c / _mu_c) - - (_y_c + 1.0 / _a) * torch.log((1.0 + _a * _y_c) / (1.0 + _a * _mu_c)) - ) - elif _fname == "tweedie": - p = _tweedie_power - if abs(p - 1.0) < 0.01: - return torch.sum(mu_arr - _y * torch.log(mu_arr)) - elif abs(p - 2.0) < 0.01: - return torch.sum(_y / mu_arr - torch.log(_y / mu_arr) - 1.0) - else: - return torch.sum( - -_y * torch.pow(mu_arr, 1.0 - p) / (1.0 - p) - + torch.pow(mu_arr, 2.0 - p) / (2.0 - p)) - else: - return torch.sum(mu_arr - _y * torch.log(mu_arr)) - elif backend == "cupy": + + return torch.sum(terms) + if backend == "cupy": import cupy as cp - if _fname in ("gaussian", "squared_error"): - return 0.5 * cp.sum((_y - mu_arr) ** 2) - elif _fname in ("binomial", "logistic"): - _mu_c = cp.clip(mu_arr, 1e-10, 1.0 - 1e-10) - return cp.sum(-_y * cp.log(_mu_c) - - (1.0 - _y) * cp.log1p(-_mu_c)) - elif _fname == "gamma": - return cp.sum(_y / mu_arr + cp.log(mu_arr)) - elif _fname == "inverse_gaussian": - return cp.sum(_y / (2.0 * mu_arr ** 2) - 1.0 / mu_arr) - elif _fname == "negative_binomial": - _mu_c = cp.clip(mu_arr, 1e-10) - _y_c = cp.clip(_y, 1e-10) - _a = _nb_alpha - return cp.sum( - _y_c * cp.log(_y_c / _mu_c) - - (_y_c + 1.0 / _a) * cp.log((1.0 + _a * _y_c) / (1.0 + _a * _mu_c)) - ) - elif _fname == "tweedie": - p = _tweedie_power - if abs(p - 1.0) < 0.01: - return cp.sum(mu_arr - _y * cp.log(mu_arr)) - elif abs(p - 2.0) < 0.01: - return cp.sum(_y / mu_arr - cp.log(_y / mu_arr) - 1.0) - else: - return cp.sum( - -_y * cp.power(mu_arr, 1.0 - p) / (1.0 - p) - + cp.power(mu_arr, 2.0 - p) / (2.0 - p)) - else: - return cp.sum(mu_arr - _y * cp.log(mu_arr)) - else: - if _fname in ("gaussian", "squared_error"): - return float(0.5 * np.sum((_y - mu_arr) ** 2)) - elif _fname in ("binomial", "logistic"): - _mu_c = np.clip(mu_arr, 1e-10, 1.0 - 1e-10) - return float(np.sum(-_y * np.log(_mu_c) - - (1.0 - _y) * np.log1p(-_mu_c))) - elif _fname == "gamma": - return float(np.sum(_y / mu_arr + np.log(mu_arr))) - elif _fname == "inverse_gaussian": - return float(np.sum(_y / (2.0 * mu_arr ** 2) - 1.0 / mu_arr)) - elif _fname == "negative_binomial": - _mu_c = np.clip(mu_arr, 1e-10, None) - _y_c = np.clip(_y, 1e-10, None) - _a = _nb_alpha - return float(np.sum( - _y_c * np.log(_y_c / _mu_c) - - (_y_c + 1.0 / _a) * np.log((1.0 + _a * _y_c) / (1.0 + _a * _mu_c)) - )) - elif _fname == "tweedie": - p = _tweedie_power - if abs(p - 1.0) < 0.01: - return float(np.sum(mu_arr - _y * np.log(mu_arr))) - elif abs(p - 2.0) < 0.01: - return float(np.sum(_y / mu_arr - np.log(_y / mu_arr) - 1.0)) - else: - return float(np.sum( - -_y * np.power(mu_arr, 1.0 - p) / (1.0 - p) - + np.power(mu_arr, 2.0 - p) / (2.0 - p))) - else: - return float(np.sum(mu_arr - _y * np.log(mu_arr))) + + return cp.sum(terms) + return np.sum(terms) def _penalty_val(params_arr): value = 0.0 @@ -441,9 +446,11 @@ def _penalty_val(params_arr): ) if backend == "torch": import torch + value = value + 0.5 * ridge_alpha * torch.sum(penalized ** 2) elif backend == "cupy": import cupy as cp + value = value + 0.5 * ridge_alpha * cp.sum(penalized ** 2) else: value = value + 0.5 * ridge_alpha * np.sum(penalized ** 2) @@ -453,105 +460,82 @@ def _penalty_val(params_arr): ) return value - def _objective_val(mu_arr, params_arr): - return _dev_val(mu_arr) + _penalty_val(params_arr) + def _objective_val(eta_arr, params_arr): + return _loss_val(eta_arr) + _penalty_val(params_arr) - # Current loss — use only eta clipping (prevent exp overflow), - # NOT mu clipping (which distorts the deviance landscape). - eta_cur = _clip(X @ params_old, -30, 30, backend) - mu_cur = family.link.inverse(eta_cur) - try: - dev_old_dev = _objective_val(mu_cur, params_old) - except Exception: - dev_old_dev = float('inf') - - # Gaussian-identity and Gamma-log have constant Fisher weights. - # Try their full Fisher-scoring step before backtracking. - _direction = params_new - params_old - _is_constant_W = ( - _fname in ("gaussian", "squared_error") - or (_fname == "gamma" and _link_name == "log") - ) + def _scalar_float(value): + return float(value.item() if hasattr(value, "item") else value) - # Convert dev_old to Python float for tolerance computation - # (single sync per iteration, not per line-search step) - if backend == "torch": - dev_old_f = float(dev_old_dev.item()) - elif backend == "cupy": - dev_old_f = float(dev_old_dev) - else: - dev_old_f = float(dev_old_dev) - _dev_tol = max(abs(dev_old_f) * 1e-10, 1e-6) - - def _dev_accept(dev_try_dev): - """Check if trial deviance is acceptable (device-side NaN + comparison).""" + def _scalar_is_finite(value): if backend == "torch": import torch - if torch.isnan(dev_try_dev): - return False - return bool((dev_try_dev <= dev_old_dev + _dev_tol).item()) - elif backend == "cupy": + + return bool(torch.isfinite(value).item()) + if backend == "cupy": import cupy as cp - if cp.isnan(dev_try_dev): - return False - return bool(dev_try_dev <= dev_old_dev + _dev_tol) - else: - if dev_try_dev != dev_try_dev: - return False - return dev_try_dev <= dev_old_f + _dev_tol - - if _is_constant_W: - # Constant weights: IRLS = Newton. Try full step first; - # if deviance increases significantly, fall back to Armijo. - eta_new = _clip(X @ params_new, -30, 30, backend) - mu_new = family.link.inverse(eta_new) - try: - dev_new_dev = _objective_val(mu_new, params_new) - except Exception: - dev_new_dev = float('inf') - if _dev_accept(dev_new_dev): + + return bool(cp.isfinite(value).item()) + return bool(np.isfinite(value)) + + eta_cur = X @ params_old + objective_old = _objective_val(eta_cur, params_old) + if not _scalar_is_finite(objective_old): + raise FloatingPointError( + "IRLS objective became non-finite at the current iterate." + ) + objective_old_float = _scalar_float(objective_old) + objective_tolerance = max( + abs(objective_old_float) * 1e-10, + 1e-6, + ) + + def _objective_accept(objective_try): + if not _scalar_is_finite(objective_try): + return False + return _scalar_float(objective_try) <= ( + objective_old_float + objective_tolerance + ) + + direction = params_new - params_old + is_constant_weight = ( + family_name in ("gaussian", "squared_error") + or ( + family_name == "gamma" + and str(getattr(family.link, "name", "")).lower() == "log" + ) + ) + + if is_constant_weight: + objective_new = _objective_val(X @ params_new, params_new) + if _objective_accept(objective_new): params = params_new else: step = 1.0 - _accepted = False - for _bt in range(30): - params_try = params_old + step * _direction - eta_try = _clip(X @ params_try, -30, 30, backend) - mu_try = family.link.inverse(eta_try) - try: - dev_try_dev = _objective_val(mu_try, params_try) - except Exception: - step *= 0.5 - continue - if _dev_accept(dev_try_dev): - _accepted = True + accepted = False + for _ in range(30): + params_try = params_old + step * direction + objective_try = _objective_val(X @ params_try, params_try) + if _objective_accept(objective_try): + accepted = True break step *= 0.5 - if _accepted: + if accepted: params = params_try else: params = params_old line_search_failed = True break else: - # Variable weights: Armijo backtracking on deviance step = 1.0 - _accepted = False - for _bt in range(30): - params_try = params_old + step * _direction - eta_try = _clip(X @ params_try, -30, 30, backend) - mu_try = family.link.inverse(eta_try) - try: - dev_try_dev = _objective_val(mu_try, params_try) - except Exception: - step *= 0.5 - continue - if _dev_accept(dev_try_dev): - _accepted = True + accepted = False + for _ in range(30): + params_try = params_old + step * direction + objective_try = _objective_val(X @ params_try, params_try) + if _objective_accept(objective_try): + accepted = True break step *= 0.5 - - if _accepted: + if accepted: params = params_try else: params = params_old @@ -560,7 +544,7 @@ def _dev_accept(dev_try_dev): # Convergence: normalized penalized score norm. Parameter changes can # be tiny merely because line search truncated a bad step. - if iteration % 5 == 4 or iteration == max_iter - 1: + if is_constant_weight or iteration % 5 == 4 or iteration == max_iter - 1: eta_check = X @ params if _link_name not in ("identity", "Identity"): eta_check = _clip(eta_check, -30, 30, backend) @@ -588,6 +572,7 @@ def _dev_accept(dev_try_dev): grad_f = grad_f + (penalty_matrix_work @ params) / n_eff grad_norm = float(_norm(grad_f, backend)) if grad_norm < tol: + converged = True break n_iter = iteration + 1 @@ -599,7 +584,7 @@ def _dev_accept(dev_try_dev): ConvergenceWarning, stacklevel=2, ) - elif n_iter >= max_iter: + elif not converged: warnings.warn( f"irls did not converge within {max_iter} iterations " f"(family={getattr(family, 'name', '?')}).", diff --git a/statgpu/glm_core/_solver_utils.py b/statgpu/glm_core/_solver_utils.py index 6ebeb8c8e..d79373d8b 100644 --- a/statgpu/glm_core/_solver_utils.py +++ b/statgpu/glm_core/_solver_utils.py @@ -59,7 +59,7 @@ class ConvergenceWarning(UserWarning): _FISTA_STEP_COMPILED = None _NEWTON_STEP_COMPILED = None -from statgpu.backends._utils import torch_compile_supported as _torch_compile_supported +from statgpu.backends._torch_compile import compile_torch def _get_fista_step_compiled(): @@ -71,25 +71,17 @@ def _fista_step(y_k, grad, step, coef_old, coef, beta_t): w_tilde = y_k - step * grad y_k_new = coef + beta_t * (coef - coef_old) return w_tilde, y_k_new - if _torch_compile_supported(): - try: - _FISTA_STEP_COMPILED = torch.compile(_fista_step, dynamic=True, fullgraph=False) - except RuntimeError: - _FISTA_STEP_COMPILED = _fista_step - else: - _FISTA_STEP_COMPILED = _fista_step + _FISTA_STEP_COMPILED = compile_torch( + _fista_step, + workload="iterative", + dynamic=True, + fullgraph=False, + ) return _FISTA_STEP_COMPILED def _fista_step_call(compiled_fn, *args): - try: - return compiled_fn(*args) - except (RuntimeError, TypeError): - def _fista_eager(y_k, grad, step, coef_old, coef, beta_t): - w_tilde = y_k - step * grad - y_k_new = coef + beta_t * (coef - coef_old) - return w_tilde, y_k_new - return _fista_eager(*args) + return compiled_fn(*args) def _get_newton_step_compiled(): @@ -101,27 +93,17 @@ def _newton_step(params, direction, params_old): params_new = params - direction diff_norm = torch.linalg.norm(params_new - params_old) return params_new, diff_norm - if _torch_compile_supported(): - try: - _NEWTON_STEP_COMPILED = torch.compile(_newton_step, dynamic=True, fullgraph=False) - except RuntimeError: - _NEWTON_STEP_COMPILED = _newton_step - else: - _NEWTON_STEP_COMPILED = _newton_step + _NEWTON_STEP_COMPILED = compile_torch( + _newton_step, + workload="iterative", + dynamic=True, + fullgraph=False, + ) return _NEWTON_STEP_COMPILED def _newton_step_call(compiled_fn, *args): - try: - return compiled_fn(*args) - except (RuntimeError, TypeError): - def _newton_eager(params, direction, params_old): - import torch - - params_new = params - direction - diff_norm = torch.linalg.norm(params_new - params_old) - return params_new, diff_norm - return _newton_eager(*args) + return compiled_fn(*args) # --------------------------------------------------------------------------- @@ -410,34 +392,7 @@ def _fused_glm_value_and_gradient(loss, X, y, coef): def _weighted_loss_and_grad(loss, X, y, coef, sample_weight): - n = X.shape[0] - _backend = _resolve_backend("auto", X) - xp = _get_xp(_backend) - _sw_np = _to_numpy(sample_weight) - if hasattr(X, 'device'): - _sw = xp.asarray(_sw_np, dtype=X.dtype, device=X.device) - else: - _sw = xp.asarray(_sw_np, dtype=X.dtype) - sw_sum = _to_float_scalar(xp.sum(_sw)) - - loss_name = getattr(loss, 'name', '') - if loss_name == 'squared_error': - resid = X @ coef - y - grad = X.T @ (_sw * resid) / sw_sum - val = 0.5 * _to_float_scalar(xp.sum(_sw * resid * resid)) / sw_sum - return val, grad + """Delegate to the single backend-native weighted GLM implementation.""" + from statgpu.glm_core._fused import _weighted_loss_and_grad as _weighted - if hasattr(loss, 'fused_value_and_gradient'): - try: - return loss.fused_value_and_gradient(X, y, coef, sample_weight=sample_weight) - except TypeError: - pass - - try: - val = loss.value(X, y, coef, sample_weight=sample_weight) - grad = loss.gradient(X, y, coef, sample_weight=sample_weight) - return val, grad - except TypeError: - val = loss.value(X, y, coef) - grad = loss.gradient(X, y, coef) - return val, grad + return _weighted(loss, X, y, coef, sample_weight) diff --git a/statgpu/glm_core/_validation.py b/statgpu/glm_core/_validation.py new file mode 100644 index 000000000..2f97268d3 --- /dev/null +++ b/statgpu/glm_core/_validation.py @@ -0,0 +1,152 @@ +"""Backend-native validation for scalar GLM design and weight inputs.""" + +from __future__ import annotations + +import numpy as np + + +def _as_native_array(value, *, name): + """Return an array while preserving existing Torch/CuPy device residency.""" + module = type(value).__module__ + if module.startswith("pandas"): + value = value.to_numpy() + module = type(value).__module__ + if module.startswith("torch"): + import torch + + return value if torch.is_tensor(value) else torch.as_tensor(value) + if module.startswith("cupy"): + import cupy as cp + + return value if isinstance(value, cp.ndarray) else cp.asarray(value) + try: + return np.asarray(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a numeric array-like.") from exc + + +def _require_real_finite(values, *, name): + module = type(values).__module__ + if module.startswith("torch"): + import torch + + if torch.is_complex(values): + raise ValueError(f"{name} must contain real numeric values.") + if not bool(torch.all(torch.isfinite(values)).item()): + raise ValueError(f"{name} must contain finite values.") + return + if module.startswith("cupy"): + import cupy as cp + + if getattr(values.dtype, "kind", "") not in "biuf": + raise ValueError(f"{name} must contain real numeric values.") + if not bool(cp.all(cp.isfinite(values)).item()): + raise ValueError(f"{name} must contain finite values.") + return + + if getattr(values.dtype, "kind", "") not in "biuf": + raise ValueError(f"{name} must contain real numeric values.") + if not bool(np.all(np.isfinite(values))): + raise ValueError(f"{name} must contain finite values.") + + +def _safe_weight_sum(values) -> float: + """Accumulate analytic weights in float64 to avoid integer wraparound.""" + module = type(values).__module__ + if module.startswith("torch"): + import torch + + total = torch.sum(values.to(dtype=torch.float64)) + return float(total.item()) + if module.startswith("cupy"): + import cupy as cp + + total = cp.sum(values, dtype=cp.float64) + return float(total.item()) + return float(np.sum(np.asarray(values), dtype=np.float64)) + + +def validate_glm_design_matrix(X, *, name="X"): + """Validate a dense scalar-GLM design matrix and return its native array.""" + values = _as_native_array(X, name=name) + if int(values.ndim) != 2: + raise ValueError(f"{name} must be a two-dimensional design matrix.") + if int(values.shape[0]) == 0: + raise ValueError(f"{name} must contain at least one observation.") + _require_real_finite(values, name=name) + return values + + +def validate_binary_response(y, n_samples=None, *, context="LogisticRegression"): + """Validate a strict 0/1 response while preserving GPU residency.""" + values = _as_native_array(y, name="binary y") + if int(values.ndim) == 2 and int(values.shape[1]) == 1: + values = values.reshape(-1) + elif int(values.ndim) != 1: + raise ValueError(f"{context} requires one-dimensional binary y") + if int(values.shape[0]) == 0: + raise ValueError(f"{context} requires at least one binary response") + if n_samples is not None and int(values.shape[0]) != int(n_samples): + raise ValueError("Response length must match the number of X rows.") + _require_real_finite(values, name="binary y") + + module = type(values).__module__ + if module.startswith("torch"): + import torch + + valid = torch.all((values == 0) | (values == 1)) + elif module.startswith("cupy"): + import cupy as cp + + valid = cp.all((values == 0) | (values == 1)) + else: + valid = np.all((values == 0) | (values == 1)) + if not bool(valid.item() if hasattr(valid, "item") else valid): + raise ValueError(f"{context} requires binary y with values 0 or 1") + return values + + +def validate_glm_sample_weight(sample_weight, n_samples, *, name="sample_weight"): + """Validate analytic weights and normalize integral inputs to float64.""" + values = _as_native_array(sample_weight, name=name) + if int(values.ndim) != 1: + raise ValueError(f"{name} must be one-dimensional") + if int(values.shape[0]) != int(n_samples): + raise ValueError(f"{name} must have length n_samples") + _require_real_finite(values, name=name) + + module = type(values).__module__ + if module.startswith("torch"): + import torch + + if bool(torch.any(values < 0).item()): + raise ValueError(f"{name} must be non-negative") + elif module.startswith("cupy"): + import cupy as cp + + if bool(cp.any(values < 0).item()): + raise ValueError(f"{name} must be non-negative") + else: + if np.any(values < 0): + raise ValueError(f"{name} must be non-negative") + total = _safe_weight_sum(values) + if not np.isfinite(total) or total <= 0.0: + raise ValueError(f"{name} must have a finite positive sum") + + # Returning integer weights would reintroduce wraparound in downstream + # objective normalizers that call ``sum()`` directly. Preserve device + # residency while promoting integral/bool weights once at validation. + kind = getattr(values.dtype, "kind", "") + if module.startswith("torch"): + import torch + + if not torch.is_floating_point(values): + values = values.to(dtype=torch.float64) + elif kind in "biu": + if module.startswith("cupy"): + import cupy as cp + + values = values.astype(cp.float64, copy=False) + else: + values = values.astype(np.float64, copy=False) + return values diff --git a/statgpu/inference/_sandwich.py b/statgpu/inference/_sandwich.py index 99f2c4474..3d64ce2ce 100644 --- a/statgpu/inference/_sandwich.py +++ b/statgpu/inference/_sandwich.py @@ -53,6 +53,20 @@ def _infer_covariance_convention(cov_type: str, has_curvature: bool) -> str: return "penalized_sandwich" if has_curvature else "robust_sandwich" +def _runtime_error_is_singular(exc: RuntimeError) -> bool: + """Return whether a backend RuntimeError specifically reports singularity.""" + message = str(exc).lower() + return any( + marker in message + for marker in ( + "singular", + "not invertible", + "zero pivot", + "rank deficient", + ) + ) + + # --------------------------------------------------------------------------- # Bread: inverse of (scaled) Hessian # --------------------------------------------------------------------------- @@ -111,31 +125,20 @@ def compute_bread_avg( eye = xp_eye(p, H_avg.dtype, xp, ref_arr=H_avg) try: bread_avg = xp.linalg.solve(H_avg, eye) - except np.linalg.LinAlgError as e: + except np.linalg.LinAlgError as exc: raise np.linalg.LinAlgError( "Singular Hessian in compute_bread_avg. " "The design matrix may be rank-deficient or the penalty is too weak. " "Consider adding ridge regularization or checking for collinear features." - ) from e - except RuntimeError as e: - # torch raises RuntimeError for singular matrices AND other errors. - # Only re-wrap if the message suggests a linalg issue. - msg = str(e).lower() - if any(kw in msg for kw in ("singular", "linalg", "solve", "lapack")): - raise np.linalg.LinAlgError( - "Singular Hessian in compute_bread_avg. " - "The design matrix may be rank-deficient or the penalty is too weak. " - "Consider adding ridge regularization or checking for collinear features." - ) from e - raise - except Exception as e: - # CuPy may raise bare Exception for cuSOLVER failures. - # Re-wrap but note it may also be OOM or device errors. + ) from exc + except RuntimeError as exc: + if not _runtime_error_is_singular(exc): + raise raise np.linalg.LinAlgError( - "Hessian solve failed in compute_bread_avg. " - "This may indicate singularity, GPU out-of-memory, or cuSOLVER error. " + "Singular Hessian in compute_bread_avg. " + "The design matrix may be rank-deficient or the penalty is too weak. " "Consider adding ridge regularization or checking for collinear features." - ) from e + ) from exc return bread_avg @@ -212,10 +215,14 @@ def assemble_cov_avg( n_eff, k, cov_type, + *, + hc1_n=None, ) -> np.ndarray: """Assemble covariance: cov = bread_avg @ meat_avg @ bread_avg / n_eff. - HC1: multiply by n_eff / (n_eff - k). + HC1: multiply by n / (n - k), where ``n`` is the observation count. + For analytic weights this keeps the correction invariant to a global + rescaling of the weights; ``n_eff`` remains the sandwich normalization. Parameters ---------- @@ -226,6 +233,9 @@ def assemble_cov_avg( k : int Number of parameters (including intercept if applicable). cov_type : str + hc1_n : int or float, optional + Observation count used by the HC1 finite-sample correction. Defaults + to ``n_eff`` for backward-compatible direct calls. Returns ------- @@ -235,8 +245,9 @@ def assemble_cov_avg( cov = bread_avg @ meat_avg @ bread_avg / n_eff - if cov_type == "hc1" and n_eff > k: - cov = cov * (n_eff / (n_eff - k)) + correction_n = float(n_eff if hc1_n is None else hc1_n) + if cov_type == "hc1" and correction_n > k: + cov = cov * (correction_n / (correction_n - k)) return cov @@ -328,7 +339,9 @@ def m_estimation_inference( # ---- dispersion (for nonrobust) ---- if dispersion is None and cov_type == "nonrobust": - dispersion = _default_dispersion(loss, X, y, coef, n_eff, k) + dispersion = _default_dispersion( + loss, X, y, coef, X.shape[0], k, sample_weight=sample_weight + ) # ---- covariance ---- if cov_type == "nonrobust": @@ -341,7 +354,14 @@ def m_estimation_inference( bread_avg=bread_avg, sample_weight=sample_weight, ) - cov = assemble_cov_avg(bread_avg, meat_avg, n_eff, k, cov_type) + cov = assemble_cov_avg( + bread_avg, + meat_avg, + n_eff, + k, + cov_type, + hc1_n=X.shape[0], + ) # ---- standard errors ---- cov_diag = xp.diag(cov) @@ -367,7 +387,11 @@ def m_estimation_inference( # wald = coef' @ cov^{-1} @ coef via solve wald_vec = xp.linalg.solve(cov, coef) wald_stat = float(xp.dot(coef, wald_vec)) - except (np.linalg.LinAlgError, RuntimeError): + except np.linalg.LinAlgError: + wald_stat = float("nan") + except RuntimeError as exc: + if not _runtime_error_is_singular(exc): + raise wald_stat = float("nan") from math import isnan as _math_isnan wald_pval = _chi2_sf(xp, wald_stat, k) if not _math_isnan(wald_stat) else float("nan") @@ -393,7 +417,9 @@ def m_estimation_inference( # Internal helpers # --------------------------------------------------------------------------- -def _default_dispersion(loss, X, y, coef, n_eff, k): +def _default_dispersion( + loss, X, y, coef, n_obs, k, *, sample_weight=None +): """Default dispersion for nonrobust covariance. Backend-agnostic. Canonical-link GLMs (Poisson, logistic, NegBinom): = 1.0. @@ -405,13 +431,16 @@ def _default_dispersion(loss, X, y, coef, n_eff, k): if name in ("squared_error",): _, xp = _resolve_backend_and_xp(X) eta = X @ coef; mu = eta - resid = y - mu; rss = float(xp.sum(resid ** 2)) - return rss / max(n_eff - k, 1) + resid_sq = (y - mu) ** 2 + if sample_weight is not None: + resid_sq = resid_sq * sample_weight + rss = float(xp.sum(resid_sq)) + return rss / max(n_obs - k, 1) # Pearson dispersion for non-canonical GLMs (backend-agnostic) if name in ("gamma", "inverse_gaussian", "tweedie"): _, xp = _resolve_backend_and_xp(X) - df = max(n_eff - k, 1) + df = max(n_obs - k, 1) if hasattr(loss, '_mu_from_eta'): eta = X @ coef; mu = loss._mu_from_eta(eta) else: @@ -428,7 +457,10 @@ def _default_dispersion(loss, X, y, coef, n_eff, k): return 1.0 resid_sq = (y - mu) ** 2 from statgpu.backends._utils import xp_maximum - pearson = float(xp.sum(resid_sq / xp_maximum(V, 1e-10, xp))) + pearson_terms = resid_sq / xp_maximum(V, 1e-10, xp) + if sample_weight is not None: + pearson_terms = pearson_terms * sample_weight + pearson = float(xp.sum(pearson_terms)) return pearson / df return 1.0 diff --git a/statgpu/linear_model/_glm_base.py b/statgpu/linear_model/_glm_base.py index 0620c76f2..60c00168c 100644 --- a/statgpu/linear_model/_glm_base.py +++ b/statgpu/linear_model/_glm_base.py @@ -11,18 +11,22 @@ def _parse_formula_if_provided(formula, data, X, y): - """Parse formula+data or fall back to raw arrays. Returns (y, X, info).""" + """Parse formula data and return retained row positions for side arrays.""" if formula is not None: - from statgpu.core.formula import parse_formula - return parse_formula(formula, data) - y = np.asarray(y) - if y.ndim == 2 and y.shape[1] == 1: - y = y.ravel() - return y, np.asarray(X), None + from statgpu.core.formula import FormulaParser + + parser = FormulaParser(formula) + y_arr, X_arr, design_info = parser.eval(data) + return y_arr, X_arr, design_info, parser.row_positions + y_arr = np.asarray(y) + if y_arr.ndim == 2 and y_arr.shape[1] == 1: + y_arr = y_arr.ravel() + return y_arr, np.asarray(X), None, None from statgpu._base import BaseEstimator from statgpu._config import Device from statgpu.backends import _to_numpy, _resolve_backend, _is_torch_array +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure from statgpu.glm_core._irls import IRLSSolver from statgpu.solvers import fista_solver from statgpu.glm_core._family import ( @@ -155,7 +159,7 @@ def _effective_intercept(self): """ if self._use_intercept is not None: return self._use_intercept - return self.fit_intercept + return self._fit_intercept def _get_family(self): """Return the GLM Family instance. Override in subclass.""" @@ -182,7 +186,7 @@ def _get_penalty_alpha(self): def _cleanup_cuda_memory(self): """Best-effort CuPy memory pool cleanup.""" - if not bool(self.gpu_memory_cleanup): + if not bool(self._gpu_memory_cleanup): return try: import cupy as cp @@ -193,7 +197,7 @@ def _cleanup_cuda_memory(self): def _cleanup_torch_memory(self): """Best-effort Torch CUDA memory cleanup.""" - if not bool(self.gpu_memory_cleanup): + if not bool(self._gpu_memory_cleanup): return try: import torch @@ -310,7 +314,7 @@ def _compute_inference(self): result = m_estimation_inference( self._loss, self._X_design, self._y_inf, self._params, - cov_type=self.cov_type, + cov_type=self._cov_type, penalty_curvature_diag=curv, sample_weight=self._sample_weight_inf, ) @@ -336,9 +340,9 @@ def _compute_inference(self): "dispersion": result["dispersion"], "wald_stat": result["wald_stat"], "wald_pval": result["wald_pval"], - "meat_type": self.cov_type, + "meat_type": self._cov_type, "covariance_convention": _infer_covariance_convention( - self.cov_type, curv is not None + self._cov_type, curv is not None ), "solver_used": self._fit_metadata.get("solver_used"), "inference_backend": backend, @@ -369,7 +373,7 @@ def summary(self): lines.append(f" Family: {family_name}") lines.append(f" Solver: {getattr(self, 'solver', 'unknown')}") lines.append(f" No. Observations: {self._nobs}") - lines.append(f" Df Residuals: {self._df_resid}") + lines.append(f" Df Residuals: {self._df_resid:g}") lines.append(f" Covariance Type: {getattr(self, 'cov_type', 'nonrobust')}") lines.append("") @@ -409,11 +413,12 @@ def llf(self): def loglikelihood(self): """Pseudo-loglikelihood at the fitted coefficients. - Computed as -sum(loss.per_sample_value(eta, y)). Additive constants - that do not depend on the parameters (e.g. -log(y!) for Poisson, - -n log(2πσ²)/2 for Gaussian) are omitted. ΔAIC / ΔBIC comparisons - between nested models on the same data remain valid; absolute values - should not be compared with statsmodels or R. + Without sample weights this is ``-sum(per_sample_loss)``. With + analytic sample weights it is the negative weighted-average loss + multiplied by the original row count, so globally rescaling all + weights leaves loglikelihood, AIC, and BIC unchanged. Additive + constants independent of the parameters are omitted; absolute values + should therefore not be compared directly with statsmodels or R. """ self._check_is_fitted() if self._loss is None or self._X_design is None or self._y_inf is None: @@ -425,7 +430,17 @@ def loglikelihood(self): xp = _get_xp(backend) params = xp_asarray(self._params, xp=xp, ref_arr=self._X_design) eta = self._X_design @ params - return -float(xp.sum(self._loss.per_sample_value(eta, self._y_inf))) + values = self._loss.per_sample_value(eta, self._y_inf) + if self._sample_weight_inf is not None: + weights = xp_asarray( + self._sample_weight_inf, xp=xp, ref_arr=self._X_design + ) + weight_sum = xp.sum(weights) + # Analytic weights define a weighted average objective. Report + # its n-observation pseudo-loglikelihood so multiplying every + # weight by a constant does not change diagnostics. + return -float(self._nobs * xp.sum(values * weights) / weight_sum) + return -float(xp.sum(values)) @property def aic(self): @@ -440,7 +455,7 @@ def bic(self): ll = self.loglikelihood k = len(self._params) if self._params is not None else 0 n = self._nobs if self._nobs else 0 - return -2.0 * ll + k * np.log(max(n, 1)) + return -2.0 * ll + k * np.log(max(float(n), 1.0)) def __del__(self): try: @@ -468,6 +483,11 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): # Resolve backend once for both formula and direct paths backend = self._get_backend(backend="auto") backend_name = backend.name + from statgpu.glm_core._validation import ( + validate_glm_design_matrix, + validate_glm_sample_weight, + ) + fit_loss = self._resolve_loss_for_inference() # Handle formula interface if formula is not None: @@ -476,9 +496,18 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): "formula was provided but data is None. " "Pass data=your_dataframe when using formula." ) - y_arr, X_arr, design_info = _parse_formula_if_provided( + y_arr, X_arr, design_info, retained_rows = _parse_formula_if_provided( formula, data, None, None ) + if sample_weight is not None: + from statgpu.core.formula import align_formula_sample_weight + + sample_weight = align_formula_sample_weight( + sample_weight, + data_length=len(data), + retained_rows=retained_rows, + retained_length=X_arr.shape[0], + ) self._design_info = design_info formula_column_names = list(design_info.column_names) self._formula_has_intercept = "Intercept" in formula_column_names @@ -489,7 +518,9 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): self._use_intercept = True else: self._use_intercept = False - # Formula produces numpy; convert to backend + X_arr = validate_glm_design_matrix(X_arr) + y_arr = fit_loss.validate_response(y_arr) + # Formula produces NumPy; convert validated arrays to backend. y_arr = self._to_array(y_arr, backend=backend_name) X_arr = self._to_array(X_arr, backend=backend_name) else: @@ -501,17 +532,27 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): self._design_info = None self._formula_has_intercept = None self._use_intercept = None - # _to_array safely handles numpy/cupy/torch inputs - y_arr = self._to_array(y, backend=backend_name) - X_arr = self._to_array(X, backend=backend_name) + X_validated = validate_glm_design_matrix(X) + y_validated = fit_loss.validate_response(y) + y_arr = self._to_array(y_validated, backend=backend_name) + X_arr = self._to_array(X_validated, backend=backend_name) # Ensure y is 1D after backend conversion if hasattr(y_arr, 'ndim') and y_arr.ndim == 2 and y_arr.shape[1] == 1: y_arr = y_arr.ravel() self._nobs = X_arr.shape[0] + if sample_weight is not None: + sample_weight = validate_glm_sample_weight( + sample_weight, self._nobs + ) + sample_weight = self._to_array(sample_weight, backend=backend_name) + family = self._get_family() - _solver_lower = self.solver.lower() if isinstance(self.solver, str) else self.solver + y_arr = fit_loss.validate_response(y_arr) + if int(y_arr.shape[0]) != int(X_arr.shape[0]): + raise ValueError("Response length must match X.shape[0].") + _solver_lower = self._solver.lower() if isinstance(self._solver, str) else self._solver if _solver_lower == "auto": # Heuristic: IRLS for smooth/no penalties, FISTA for non-smooth _pen = getattr(self, "_penalty", None) @@ -537,6 +578,11 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): "solver must be one of: 'auto', 'irls', 'fista', 'newton', 'lbfgs'" ) + # Parameter counts are backend-neutral; reading ``shape`` must not + # trigger an implicit CuPy/Torch-to-NumPy transfer. + parameter_count = int(self._params.shape[0]) + self._df_resid = float(self._nobs - parameter_count) + # ---- Store design/loss for loglikelihood/aic/bic (always) ---- from statgpu.backends import _to_numpy, _resolve_backend from statgpu.backends._utils import _get_xp @@ -553,20 +599,25 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): self._y_inf = np.asarray(_to_numpy(y_arr), dtype=float).ravel() self._X_design, self._params, self._intercept_idx = \ self._aligned_inference_design_glm(X_arr) - self._loss = self._resolve_loss_for_inference() + self._loss = fit_loss - # ---- Compute inference if requested ---- - if self.compute_inference: - if sample_weight is not None: - sw = np.asarray(_to_numpy(sample_weight), dtype=float).ravel() - if is_gpu: - self._sample_weight_inf = self._to_array( - sw, backend=inf_backend) - else: - self._sample_weight_inf = sw + # Preserve fit weights even when inference is disabled because + # loglikelihood/AIC/BIC are public fitted-model diagnostics. GPU + # weights stay on their selected backend. + if sample_weight is not None: + if is_gpu: + self._sample_weight_inf = self._to_array( + sample_weight, backend=inf_backend + ) else: - self._sample_weight_inf = None + self._sample_weight_inf = np.asarray( + sample_weight, dtype=float + ).ravel() + else: + self._sample_weight_inf = None + # ---- Compute inference if requested ---- + if self._compute_inference_enabled: self._fit_metadata = { "solver_used": solver_name, "objective_scale": "mean_loss_plus_penalty", @@ -597,17 +648,24 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): def _fit_irls(self, X, y, sample_weight, family, backend_name="numpy"): """Fit using IRLS (per-iteration weighted least squares).""" - # IRLSSolver solves the unnormalized WLS normal equations - # X'WX + lambda I, while _get_penalty_alpha() is the normalized - # objective penalty. Scale by n to keep C semantics consistent. - ridge_alpha = X.shape[0] * self._get_penalty_alpha() + # IRLSSolver solves unnormalized WLS normal equations. The public + # objective is normalized by n without weights and by sum(w) with + # weights, so the normal-equation ridge term must use the same scale. + if sample_weight is None: + objective_scale = float(X.shape[0]) + else: + scale_value = sample_weight.sum() + objective_scale = float( + scale_value.item() if hasattr(scale_value, "item") else scale_value + ) + ridge_alpha = objective_scale * self._get_penalty_alpha() if self._effective_intercept: X_design = _add_intercept_column(X, backend_name) else: X_design = X - solver = IRLSSolver(family, max_iter=self.max_iter, tol=self.tol) + solver = IRLSSolver(family, max_iter=self._max_iter, tol=self._tol) params, n_iter = solver.fit( X_design, y, sample_weight=sample_weight, @@ -696,7 +754,9 @@ def _fit_fista(self, X, y, sample_weight, family, backend_name="numpy"): eta_target = eta_raw - torch.mean(eta_raw) try: init_t = torch.linalg.lstsq(X_t, eta_target).solution - except RuntimeError: + except RuntimeError as exc: + if not _linalg_exception_is_rank_failure(exc): + raise init_t = torch.zeros(X.shape[1], dtype=torch.float64, device=X.device) eta_init = X_t @ init_t eta_abs_max = torch.max(torch.abs(eta_init)) @@ -745,7 +805,7 @@ def _fit_fista(self, X, y, sample_weight, family, backend_name="numpy"): init = init * (target / (med_abs + 1e-12)) coef, n_iter = fista_solver( loss, L2Penalty(alpha=0.0), X_centered, y, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) self.coef_ = _to_numpy(coef) @@ -795,7 +855,7 @@ def _fit_fista(self, X, y, sample_weight, family, backend_name="numpy"): full_coef, n_iter = fista_solver( loss, L2Penalty(alpha=0.0), X_aug, y, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) @@ -805,32 +865,83 @@ def _fit_fista(self, X, y, sample_weight, family, backend_name="numpy"): self.n_iter_ = n_iter self._params = np.concatenate([[self.intercept_], self.coef_]) else: - # Squared error: centering X and y preserves the objective. + # Squared error with an intercept can be profiled by centering. For + # weighted loss the centering constants must be weighted means; + # ordinary means optimize a different objective whenever weights + # are unequal. Keep all reductions on the selected backend. from statgpu.backends._utils import _get_xp + xp = _get_xp(backend_name) if backend_name == "cupy": - X_centered = X - xp.mean(X, axis=0) - y_centered = y - xp.mean(y) + x_dtype = X.dtype if xp.issubdtype(X.dtype, xp.floating) else xp.float64 + X_float = X.astype(x_dtype, copy=False) + y_float = xp.asarray(y, dtype=x_dtype) + if sample_weight is None: + X_mean_native = xp.mean(X_float, axis=0) + y_mean_native = xp.mean(y_float) + else: + weights = xp.asarray(sample_weight, dtype=x_dtype) + weight_sum = xp.sum(weights) + X_mean_native = xp.sum( + X_float * weights[:, None], axis=0 + ) / weight_sum + y_mean_native = xp.sum(y_float * weights) / weight_sum + X_centered = X_float - X_mean_native + y_centered = y_float - y_mean_native elif backend_name == "torch": import torch + x_dtype = _torch_promoted_float_dtype(X, y) + if sample_weight is not None: + weight_dtype = ( + sample_weight.dtype + if sample_weight.is_floating_point() + else torch.float64 + ) + x_dtype = torch.promote_types(x_dtype, weight_dtype) X_float = X.to(dtype=x_dtype) y_float = y.to(X.device).to(x_dtype) - X_centered = X_float - torch.mean(X_float, dim=0) - y_centered = y_float - torch.mean(y_float) + if sample_weight is None: + X_mean_native = torch.mean(X_float, dim=0) + y_mean_native = torch.mean(y_float) + else: + weights = sample_weight.to(X.device).to(x_dtype) + weight_sum = torch.sum(weights) + X_mean_native = torch.sum( + X_float * weights[:, None], dim=0 + ) / weight_sum + y_mean_native = torch.sum(y_float * weights) / weight_sum + X_centered = X_float - X_mean_native + y_centered = y_float - y_mean_native else: - X_centered = X - X.mean(axis=0) - y_centered = y - y.mean() + X_float = np.asarray(X, dtype=np.float64) + y_float = np.asarray(y, dtype=np.float64) + if sample_weight is None: + X_mean_native = np.mean(X_float, axis=0) + y_mean_native = np.mean(y_float) + else: + weights = np.asarray(sample_weight, dtype=np.float64) + weight_sum = np.sum(weights) + X_mean_native = np.sum( + X_float * weights[:, None], axis=0 + ) / weight_sum + y_mean_native = np.sum(y_float * weights) / weight_sum + X_centered = X_float - X_mean_native + y_centered = y_float - y_mean_native coef, n_iter = fista_solver( loss, L2Penalty(alpha=0.0), X_centered, y_centered, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=None, sample_weight=sample_weight, ) - _xp_mod = _get_xp(backend_name) if backend_name != "numpy" else np - X_mean = _to_numpy(_xp_mod.mean(X, axis=0)) - y_mean = float(_xp_mod.mean(y)) + X_mean = _to_numpy(X_mean_native) + if backend_name == "torch": + y_mean = float(y_mean_native.item()) + elif backend_name == "cupy": + y_mean = float(y_mean_native.item()) + else: + y_mean = float(y_mean_native) self.coef_ = _to_numpy(coef) self.intercept_ = float(y_mean - X_mean @ self.coef_) self.n_iter_ = n_iter @@ -886,11 +997,11 @@ def _fit_smooth_solver(self, X, y, sample_weight, solver_name, backend_name): if solver_name == "newton": params, n_iter = newton_solver( - loss, None, X_work, y, max_iter=self.max_iter, tol=self.tol + loss, None, X_work, y, max_iter=self._max_iter, tol=self._tol ) else: params, n_iter = lbfgs_solver( - loss, None, X_work, y, max_iter=self.max_iter, tol=self.tol + loss, None, X_work, y, max_iter=self._max_iter, tol=self._tol ) params_np = _to_numpy(params) @@ -1069,7 +1180,7 @@ def fit(self, X, y, sample_weight=None): self._df_resid = self._nobs - (p + K - 1) self._params = np.concatenate([self.coef_, self._thresh_est]) - if self.compute_inference: + if self._compute_inference_enabled: self._compute_ordered_inference(X, y) self._fitted = True finally: @@ -1121,9 +1232,9 @@ def _fit_ordered_newton_impl(self, X, y, family, K, n, p, xp, is_torch, is_cupy, d = len(theta); nll_old = xp.inf; ridge = 1e-4 - if self.max_iter <= 0: + if self._max_iter <= 0: raise ValueError( - f"max_iter must be > 0, got {self.max_iter}. " + f"max_iter must be > 0, got {self._max_iter}. " "Newton-Raphson requires at least 1 iteration." ) @@ -1147,7 +1258,7 @@ def _enforce_thresh_gaps(thresh_arr): return t # ---- Newton loop ---- - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): thresh = _enforce_thresh_gaps(theta[p:]) theta = xp.concatenate([theta[:p], thresh]) beta = theta[:p]; thresh = theta[p:] @@ -1174,10 +1285,10 @@ def _enforce_thresh_gaps(thresh_arr): f"NLL became non-finite ({float(nll):.4g}) at iteration " f"{iteration}. Coefficients may have diverged." ) - if iteration > 0 and abs(float(nll_old - nll)) < self.tol: + if iteration > 0 and abs(float(nll_old - nll)) < self._tol: break grad_inf = float(xp.max(xp.abs(grad))) - if grad_inf < self.tol: + if grad_inf < self._tol: break nll_old = nll @@ -1192,12 +1303,11 @@ def _enforce_thresh_gaps(thresh_arr): # errors re-raise. CuPy uses generic Exception for linalg. try: delta = xp.linalg.solve(H_reg, -grad) - except (np.linalg.LinAlgError, RuntimeError): - ridge *= 10; continue - except Exception: - if is_cupy: - ridge *= 10; continue - raise + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise + ridge *= 10 + continue theta_try = theta + delta thresh_t = _enforce_thresh_gaps(theta_try[p:]) @@ -1303,11 +1413,11 @@ def _compute_ordered_inference(self, X_orig, y_orig): ``_ordered_hessian_analytical`` and backend-native linalg + distributions. """ # Only nonrobust covariance is supported for ordered models - cov_type = self.cov_type.lower() + cov_type = self._cov_type.lower() if cov_type not in ("nonrobust",): raise NotImplementedError( f"Ordered model inference only supports cov_type='nonrobust', " - f"got '{self.cov_type}'. HC0/HC1 sandwich and penalized " + f"got '{self._cov_type}'. HC0/HC1 sandwich and penalized " f"inference are not yet available for ordered models." ) @@ -1349,20 +1459,14 @@ def _compute_ordered_inference(self, X_orig, y_orig): eye = xp_eye(d, xp.float64, xp, ref_arr=H) try: H_inv = xp.linalg.solve(H, eye) - except (np.linalg.LinAlgError, RuntimeError) as e: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise raise np.linalg.LinAlgError( "Ordered model Hessian is singular — cannot compute standard errors. " "This may indicate quasi-complete separation or redundant thresholds. " "Consider using inference_method='bootstrap' or reducing n_categories." - ) from e - except Exception as e: - if is_cupy: - raise np.linalg.LinAlgError( - "Ordered model Hessian is singular — cannot compute standard errors. " - "This may indicate quasi-complete separation or redundant thresholds. " - "Consider using inference_method='bootstrap' or reducing n_categories." - ) from e - raise + ) from exc cov = H_inv # Backend-aware distribution functions diff --git a/statgpu/linear_model/cv/_device.py b/statgpu/linear_model/cv/_device.py new file mode 100644 index 000000000..f90ed1966 --- /dev/null +++ b/statgpu/linear_model/cv/_device.py @@ -0,0 +1,122 @@ +"""Shared strict device/backend resolution for dedicated CV routines.""" + +from __future__ import annotations + +from statgpu._config import Device +from statgpu.backends import get_backend +from statgpu.glm_core._validation import validate_glm_sample_weight + + +def normalize_cv_device(device): + """Normalize string/enum device values without silently accepting typos.""" + if isinstance(device, Device): + return device.value + name = str(device).strip().lower() + if name.startswith("device."): + name = name.split(".", 1)[1] + valid = {item.value for item in Device} + if name not in valid: + expected = ", ".join(sorted(valid)) + raise ValueError(f"Invalid device {device!r}. Expected one of: {expected}") + return name + + +def _array_gpu_backend(value): + """Return the GPU array library already owning *value*, if any.""" + module = type(value).__module__ + if module.startswith("cupy"): + return "cupy" + if module.startswith("torch"): + try: + device = str(value.device) + except AttributeError: + return None + return "torch" if device.startswith("cuda") else None + return None + + +def _backend_name(backend): + name = type(backend).__name__.lower() + if "torch" in name: + return "torch" + if "cupy" in name: + return "cupy" + return "numpy" + + +def resolve_cv_backend(device, X): + """Resolve a dedicated-CV backend while preserving explicit library choice.""" + device_name = normalize_cv_device(device) + input_backend = _array_gpu_backend(X) + + if device_name == Device.CPU.value: + if input_backend is not None: + raise ValueError( + "device='cpu' cannot consume a GPU-resident design matrix; " + "move X to CPU or request the matching GPU backend." + ) + backend_name = "numpy" + backend = get_backend(backend="numpy", device="cpu") + elif device_name == Device.TORCH.value: + if input_backend not in (None, "torch"): + raise ValueError( + "device='torch' cannot silently switch a CuPy design matrix " + "to another GPU library." + ) + backend_name = "torch" + backend = get_backend(backend="torch", device="cuda") + elif device_name == Device.CUDA.value: + if input_backend not in (None, "cupy"): + raise ValueError( + "device='cuda' selects CuPy and cannot silently switch a " + "Torch CUDA design matrix to another GPU library." + ) + backend_name = "cupy" + backend = get_backend(backend="cupy", device="cuda") + else: + if input_backend is not None: + backend_name = input_backend + backend = get_backend(backend=input_backend, device="cuda") + else: + backend = get_backend(backend="auto", device="auto") + backend_name = _backend_name(backend) + + use_gpu = backend_name in {"cupy", "torch"} + return ( + device_name, + backend_name, + backend, + use_gpu, + input_backend == "cupy", + input_backend == "torch", + ) + + +def cv_refit_device(device, backend_name): + """Return the final-fit device matching the backend used for CV. + + Explicit requests remain unchanged. AUTO is pinned to the backend chosen + from the design matrix so parameter selection and the final refit cannot + silently use different GPU libraries. + """ + device_name = normalize_cv_device(device) + if device_name != Device.AUTO.value: + return Device(device_name) + mapping = { + "numpy": Device.CPU, + "cupy": Device.CUDA, + "torch": Device.TORCH, + } + try: + return mapping[str(backend_name).lower()] + except KeyError as exc: + raise ValueError( + f"Unknown CV backend {backend_name!r}; expected numpy, cupy, or torch" + ) from exc + + +def validate_cv_sample_weight(sample_weight, n_samples): + """Validate analytic CV weights before any grid or degenerate return.""" + if sample_weight is None: + return None + return validate_glm_sample_weight(sample_weight, n_samples) diff --git a/statgpu/linear_model/cv/_elasticnet_cv.py b/statgpu/linear_model/cv/_elasticnet_cv.py index 78d8db55c..86c8a7b7f 100644 --- a/statgpu/linear_model/cv/_elasticnet_cv.py +++ b/statgpu/linear_model/cv/_elasticnet_cv.py @@ -13,6 +13,11 @@ from statgpu.cross_validation._base import CVEstimatorBase, batch_mse as _batch_mse_cv from statgpu.backends import get_backend from statgpu.linear_model.wrappers._elasticnet import ElasticNet +from ._device import ( + cv_refit_device, + resolve_cv_backend, + validate_cv_sample_weight, +) # ============================================================================= @@ -111,62 +116,33 @@ def _make_elasticnet_cv_auto_cache_key( # Alpha grid generation for ElasticNet # ============================================================================= + def _default_elasticnet_alpha_grid( X, y, l1_ratio: float = 0.5, n_alphas: int = 100, alpha_min_ratio: float = 1e-3, + sample_weight=None, ) -> np.ndarray: - """ - Generate default alpha grid for ElasticNet. - - Parameters - ---------- - X : array-like - Design matrix (n_samples, n_features). - y : array-like - Response vector. - l1_ratio : float - L1 ratio (0.0 = Ridge, 1.0 = Lasso). - n_alphas : int - Number of alpha values. - alpha_min_ratio : float - Minimum alpha as a ratio of max alpha. - - Returns - ------- - alphas : ndarray - Log-spaced alpha values. - """ + """Generate a grid for the declared weighted ElasticNet objective.""" X_arr = np.asarray(X, dtype=np.float64) y_arr = np.asarray(y, dtype=np.float64).reshape(-1) - - n_samples, n_features = X_arr.shape - - # Handle intercept by centering - X_mean = np.mean(X_arr, axis=0) - y_mean = np.mean(y_arr) + if sample_weight is None: + weight = np.ones(y_arr.shape[0], dtype=np.float64) + else: + weight = np.asarray(sample_weight, dtype=np.float64).reshape(-1) + weight_sum = float(np.sum(weight)) + X_mean = np.sum(X_arr * weight[:, None], axis=0) / weight_sum + y_mean = float(np.sum(y_arr * weight) / weight_sum) X_centered = X_arr - X_mean y_centered = y_arr - y_mean - - # Compute correlation for alpha_max - Xty = X_centered.T @ y_centered - - # alpha_max = max(|X'c yc|) / (n * l1_ratio) - # For l1_ratio=1 (Lasso): max(|X'y|) / n - # For l1_ratio<1: larger because L2 penalty contributes less - _l1r = max(float(l1_ratio), 1e-6) - alpha_max = float(np.max(np.abs(Xty))) / (n_samples * _l1r) + Xty = X_centered.T @ (weight * y_centered) + l1r = max(float(l1_ratio), 1e-6) + alpha_max = float(np.max(np.abs(Xty))) / (weight_sum * l1r) alpha_max = max(alpha_max, 1e-6) - - if alpha_max <= 0: - alpha_max = 1.0 - - # Log-spaced grid if int(n_alphas) <= 1: return np.asarray([alpha_max], dtype=np.float64) - alpha_min = max(float(alpha_min_ratio) * alpha_max, 1e-6) return np.geomspace(alpha_max, alpha_min, num=int(n_alphas)).astype(np.float64) @@ -178,58 +154,29 @@ def _default_elasticnet_alpha_grid_backend( l1_ratio: float = 0.5, n_alphas: int = 100, alpha_min_ratio: float = 1e-3, + sample_weight=None, ) -> np.ndarray: - """ - Generate default alpha grid for ElasticNet using backend abstraction. - - Parameters - ---------- - X : array-like - Design matrix. - y : array-like - Response vector. - backend : BackendBase - Backend instance. - l1_ratio : float - L1 ratio. - n_alphas : int - Number of alpha values. - alpha_min_ratio : float - Minimum alpha ratio. - - Returns - ------- - alphas : ndarray - Log-spaced alpha values. - """ + """Backend-native weighted ElasticNet alpha grid.""" X_arr = backend.asarray(X, dtype=backend.float64) y_arr = backend.asarray(y, dtype=backend.float64).reshape(-1) - - n_samples = int(X_arr.shape[0]) - - # Center data - X_mean = backend.mean(X_arr, axis=0) - y_mean = backend.mean(y_arr) + if sample_weight is None: + weight = backend.ones(y_arr.shape[0], dtype=backend.float64) + else: + weight = backend.asarray(sample_weight, dtype=backend.float64).reshape(-1) + weight_sum = float(backend.sum(weight)) + X_mean = backend.sum(X_arr * weight[:, None], axis=0) / weight_sum + y_mean = backend.sum(y_arr * weight) / weight_sum X_centered = X_arr - X_mean y_centered = y_arr - y_mean - - # Compute Xty - Xty = X_centered.T @ y_centered - - # Alpha max: max(|X'y|) / (n * l1_ratio) - _l1r = max(float(l1_ratio), 1e-6) - alpha_max = float(backend.max(backend.abs(Xty))) / (n_samples * _l1r) - - if alpha_max <= 0: - alpha_max = 1.0 - + Xty = X_centered.T @ (weight * y_centered) + l1r = max(float(l1_ratio), 1e-6) + alpha_max = float(backend.max(backend.abs(Xty))) / (weight_sum * l1r) + alpha_max = max(alpha_max, 1e-6) if int(n_alphas) <= 1: return np.asarray([alpha_max], dtype=np.float64) - alpha_min = max(float(alpha_min_ratio) * alpha_max, 1e-6) return np.geomspace(alpha_max, alpha_min, num=int(n_alphas)).astype(np.float64) - # ============================================================================= # CV main function # ============================================================================= @@ -297,44 +244,16 @@ def _select_elasticnet_params_cv( best_l1_ratio : float details : dict (if return_details=True) """ - if isinstance(device, Device): - device_name = device.value - else: - device_name = str(device).lower() - if device_name.startswith("device."): - enum_name = device_name.split(".", 1)[1].upper() - if enum_name not in Device.__members__: - valid = ", ".join(sorted(d.value for d in Device)) - raise ValueError(f"Invalid device '{device}'. Expected one of: {valid}") - device_name = Device[enum_name].value - if device_name == Device.AUTO.value: - use_gpu = bool(cuda_available()) - elif device_name in (Device.CUDA.value, Device.TORCH.value): - use_gpu = True - else: - use_gpu = False + ( + device_name, + backend_name, + backend, + use_gpu, + gpu_input_cupy, + gpu_input_torch, + ) = resolve_cv_backend(device, X) gpu_requested = use_gpu - # Detect GPU input - gpu_input_cupy = False - gpu_input_torch = False - if use_gpu: - try: - import cupy as cp - gpu_input_cupy = isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) - if sample_weight is not None and not isinstance(sample_weight, cp.ndarray): - gpu_input_cupy = False - except Exception: - pass - if not gpu_input_cupy: - try: - import torch - gpu_input_torch = isinstance(X, torch.Tensor) and isinstance(y, torch.Tensor) - if sample_weight is not None and not isinstance(sample_weight, torch.Tensor): - gpu_input_torch = False - except Exception: - pass - # Validate inputs X_np = None y_np = None @@ -344,7 +263,7 @@ def _select_elasticnet_params_cv( if len(tuple(X.shape)) != 2: raise ValueError("X must be a 2D array") n_samples = int(X.shape[0]) - backend = get_backend(backend='auto', device='cuda') + # backend was selected strictly by resolve_cv_backend above y_check = backend.asarray(y).reshape(-1) if int(y_check.shape[0]) != n_samples: raise ValueError("y must have the same number of rows as X") @@ -359,6 +278,10 @@ def _select_elasticnet_params_cv( raise ValueError("y must have the same number of rows as X") n_samples = int(X_np.shape[0]) + validated_weight = validate_cv_sample_weight(sample_weight, n_samples) + if validated_weight is not None and not use_gpu: + sample_weight_np = np.asarray(validated_weight, dtype=np.float64).reshape(-1) + # Default l1_ratios if l1_ratios is None: l1_ratios_arr = np.asarray([0.2, 0.5, 0.7, 0.8, 0.9, 0.95, 0.99], dtype=np.float64) @@ -376,13 +299,15 @@ def _select_elasticnet_params_cv( for l1_idx, l1r in enumerate(l1_ratios_arr): if alphas is None: if gpu_input_cupy or gpu_input_torch: - backend = get_backend(backend='torch' if gpu_input_torch else 'cupy', device='cuda') + # backend was selected strictly by resolve_cv_backend above alpha_grids[l1_idx] = _default_elasticnet_alpha_grid_backend( - X, y, backend, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio + X, y, backend, l1_ratio=l1r, n_alphas=n_alphas, + alpha_min_ratio=alpha_min_ratio, sample_weight=validated_weight, ) else: alpha_grids[l1_idx] = _default_elasticnet_alpha_grid( - X_np, y_np, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio + X_np, y_np, l1_ratio=l1r, n_alphas=n_alphas, + alpha_min_ratio=alpha_min_ratio, sample_weight=sample_weight_np, ) else: alpha_grid = np.asarray(alphas, dtype=np.float64) @@ -390,13 +315,15 @@ def _select_elasticnet_params_cv( alpha_grid = alpha_grid[alpha_grid > 0.0] if alpha_grid.size == 0: if gpu_input_cupy or gpu_input_torch: - backend = get_backend(backend='torch' if gpu_input_torch else 'cupy', device='cuda') + # backend was selected strictly by resolve_cv_backend above alpha_grids[l1_idx] = _default_elasticnet_alpha_grid_backend( - X, y, backend, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio + X, y, backend, l1_ratio=l1r, n_alphas=n_alphas, + alpha_min_ratio=alpha_min_ratio, sample_weight=validated_weight, ) else: alpha_grids[l1_idx] = _default_elasticnet_alpha_grid( - X_np, y_np, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio + X_np, y_np, l1_ratio=l1r, n_alphas=n_alphas, + alpha_min_ratio=alpha_min_ratio, sample_weight=sample_weight_np, ) else: alpha_grids[l1_idx] = alpha_grid @@ -441,13 +368,7 @@ def _select_elasticnet_params_cv( max_n_alphas = max(len(ag) for ag in alpha_grids.values()) mse_path = np.full((n_l1_ratios, max_n_alphas, n_folds), np.nan, dtype=np.float64) - # Get backend - if gpu_input_torch: - backend = get_backend(backend='torch', device='cuda') - elif gpu_input_cupy: - backend = get_backend(backend='cupy', device='cuda') - else: - backend = get_backend(backend='auto', device='cuda' if use_gpu else 'cpu') + # backend was selected strictly by resolve_cv_backend above xp = backend.xp @@ -742,6 +663,20 @@ def __init__( self.best_score_ = None self.n_iter_ = None self.estimator_ = None + self.cv_selected_device_ = None + + def _reset_cv_fit_state(self): + """Clear all fitted outputs before a new CV attempt.""" + self._fitted = False + self.alpha_ = None + self.l1_ratio_ = None + self.coef_ = None + self.intercept_ = None + self.cv_results_ = None + self.best_score_ = None + self.n_iter_ = None + self.estimator_ = None + self.cv_selected_device_ = None def _fit_cv(self, X, y, sample_weight=None): """ @@ -760,7 +695,10 @@ def _fit_cv(self, X, y, sample_weight=None): ------- self """ - compute_device = self._get_compute_device() + self._reset_cv_fit_state() + device_request = self._device + _, cv_backend_name, _, _, _, _ = resolve_cv_backend(device_request, X) + refit_device = cv_refit_device(device_request, cv_backend_name) # Normalize l1_ratio to list if isinstance(self.l1_ratio, (list, tuple, np.ndarray)): @@ -773,49 +711,56 @@ def _fit_cv(self, X, y, sample_weight=None): X, y, l1_ratios=l1_ratios, alphas=self.alphas, - n_alphas=self.n_alphas, - alpha_min_ratio=self.alpha_min_ratio, - cv_folds=self.cv, + n_alphas=self._n_alphas, + alpha_min_ratio=self._alpha_min_ratio, + cv_folds=self._cv, cv_splits=self.cv_splits, random_state=self.random_state, sample_weight=sample_weight, - fit_intercept=self.fit_intercept, - device=compute_device, - max_iter=self.max_iter, - tol=self.tol, + fit_intercept=self._fit_intercept, + device=device_request, + max_iter=self._max_iter, + tol=self._tol, return_details=True, ) - # Store CV results - self.alpha_ = best_alpha - self.l1_ratio_ = best_l1_ratio - self.cv_results_ = { + # Keep candidate results local until the final refit succeeds. + selected_alpha = float(best_alpha) + selected_l1_ratio = float(best_l1_ratio) + cv_results = { "mse_path": details["mse_path"], "mean_mse": details["mean_mse"], "std_mse": details["std_mse"], "alphas": details["alphas"], "l1_ratios": details["l1_ratios"], - "best_alpha": self.alpha_, - "best_l1_ratio": self.l1_ratio_, + "best_alpha": selected_alpha, + "best_l1_ratio": selected_l1_ratio, } - # sklearn convention: best_score_ is negative MSE (higher is better) - self.best_score_ = -float(details["best_mse"]) + best_score = -float(details["best_mse"]) # Fit final model on full data with best parameters final_model = ElasticNet( - alpha=self.alpha_, - l1_ratio=self.l1_ratio_, - max_iter=self.max_iter, - tol=self.tol, - fit_intercept=self.fit_intercept, - device=self.device, + alpha=selected_alpha, + l1_ratio=selected_l1_ratio, + max_iter=self._max_iter, + tol=self._tol, + fit_intercept=self._fit_intercept, + device=refit_device, + n_jobs=self.n_jobs, + compute_inference=self._compute_inference_enabled, + inference_method="debiased", ) final_model.fit(X, y, sample_weight=sample_weight) + self.alpha_ = selected_alpha + self.l1_ratio_ = selected_l1_ratio + self.cv_results_ = cv_results + self.best_score_ = best_score self.coef_ = final_model.coef_.copy() self.intercept_ = final_model.intercept_ self.n_iter_ = final_model.n_iter_ self.estimator_ = final_model + self.cv_selected_device_ = refit_device self._fitted = True return self diff --git a/statgpu/linear_model/cv/_lasso_cv.py b/statgpu/linear_model/cv/_lasso_cv.py index e5e2b22cc..636f74fb0 100644 --- a/statgpu/linear_model/cv/_lasso_cv.py +++ b/statgpu/linear_model/cv/_lasso_cv.py @@ -176,29 +176,29 @@ def fit(self, X, y, sample_weight=None): device_name = self._get_compute_device().value effective_cpu_solver = ( - "coordinate_descent" if str(self.method).lower() == "glmnet" else str(self.cpu_solver) + "coordinate_descent" if str(self._method).lower() == "glmnet" else str(self._cpu_solver) ) - effective_cd_kkt = self.cd_kkt_check_every + effective_cd_kkt = self._cd_kkt_check_every if effective_cd_kkt is None: - effective_cd_kkt = 4 if str(self.method).lower() == "glmnet" else 1 + effective_cd_kkt = 4 if str(self._method).lower() == "glmnet" else 1 details = _select_lasso_alpha_cv( X, y, alphas=self.alphas, - n_alphas=self.n_alphas, - alpha_min_ratio=self.alpha_min_ratio, - cv_folds=self.cv, + n_alphas=self._n_alphas, + alpha_min_ratio=self._alpha_min_ratio, + cv_folds=self._cv, cv_splits=self.cv_splits, random_state=self.random_state, sample_weight=sample_weight, - fit_intercept=self.fit_intercept, + fit_intercept=self._fit_intercept, device=device_name, - max_iter=self.max_iter, - tol=self.tol, + max_iter=self._max_iter, + tol=self._tol, cpu_solver=effective_cpu_solver, - method=self.method, + method=self._method, cd_kkt_check_every=effective_cd_kkt, - gpu_cv_mixed_precision=self.gpu_cv_mixed_precision, + gpu_cv_mixed_precision=self._gpu_cv_mixed_precision, return_details=True, ) @@ -217,19 +217,19 @@ def fit(self, X, y, sample_weight=None): # Fit final model with selected alpha estimator = Lasso( alpha=self.alpha_, - fit_intercept=self.fit_intercept, - max_iter=self.max_iter, - tol=self.tol, - stopping=self.stopping, - inference_method=self.inference_method, - device=self.device, + fit_intercept=self._fit_intercept, + max_iter=self._max_iter, + tol=self._tol, + stopping=self._stopping, + inference_method=self._inference_method, + device=self._device, n_jobs=self.n_jobs, - compute_inference=self.compute_inference, - solver=self.solver, + compute_inference=self._compute_inference_enabled, + solver=self._solver, cpu_solver=effective_cpu_solver, lipschitz_L=self.lipschitz_L, - admm_rho=self.admm_rho, - gpu_memory_cleanup=self.gpu_memory_cleanup, + admm_rho=self._admm_rho, + gpu_memory_cleanup=self._gpu_memory_cleanup, ) estimator.fit(X, y, sample_weight=sample_weight) diff --git a/statgpu/linear_model/cv/_logistic_cv.py b/statgpu/linear_model/cv/_logistic_cv.py index 8e6d933c8..66f37a1fc 100644 --- a/statgpu/linear_model/cv/_logistic_cv.py +++ b/statgpu/linear_model/cv/_logistic_cv.py @@ -12,7 +12,20 @@ from statgpu._config import Device from statgpu.cross_validation._base import CVEstimatorBase from statgpu.backends import get_backend, _torch_dev +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure from statgpu.linear_model.wrappers._logistic import LogisticRegression +from ._device import ( + cv_refit_device, + resolve_cv_backend, + validate_cv_sample_weight, +) + + +def _validate_binary_cv_response(y): + """Validate a strict 0/1 response without copying GPU arrays to NumPy.""" + from statgpu.glm_core._validation import validate_binary_response + + return validate_binary_response(y, context="LogisticRegressionCV") # ============================================================================= @@ -90,55 +103,64 @@ def _make_logistic_cv_auto_cache_key(X, y, Cs, folds, fit_intercept, max_iter, t # C grid generation (C = 1/alpha, so we use similar approach) # ============================================================================= -def _default_logistic_c_grid(X, y, n_Cs: int = 100, C_min_ratio: float = 1e-3): - """ - Generate default C grid for LogisticRegressionCV. - - C values are log-spaced. Larger C = weaker regularization. - Parameters - ---------- - X : ndarray - Design matrix (n_samples, n_features). - y : ndarray - Response vector. - n_Cs : int - Number of C values to generate. - C_min_ratio : float - Minimum C as a ratio of max C. - - Returns - ------- - Cs : ndarray - Log-spaced C values. - """ +def _default_logistic_c_grid( + X, + y, + n_Cs: int = 100, + C_min_ratio: float = 1e-3, + sample_weight=None, +): + """Generate a default C grid for the declared weighted logistic loss.""" X_arr = np.asarray(X, dtype=np.float64) y_arr = np.asarray(y, dtype=np.float64).reshape(-1) - - # Estimate C_max based on data - # For logistic regression, C_max is where coefficients become very large - # We use a heuristic based on the gradient at zero coefficients. - # Gradient of logistic loss at beta=0: X'(y - sigmoid(0)) = X'(y - 0.5) - grad = X_arr.T @ (y_arr - 0.5) - C_max = np.max(np.abs(grad)) * 2.0 / len(y_arr) - - if C_max == 0: + if sample_weight is None: + residual = y_arr - 0.5 + normalizer = float(y_arr.size) + else: + weight = np.asarray(sample_weight, dtype=np.float64).reshape(-1) + residual = weight * (y_arr - 0.5) + normalizer = float(np.sum(weight)) + grad = X_arr.T @ residual + C_max = float(np.max(np.abs(grad))) * 2.0 / normalizer + if not np.isfinite(C_max) or C_max <= 0.0: C_max = 1.0 + if int(n_Cs) <= 1: + return np.asarray([C_max], dtype=np.float64) + C_min = max(float(C_min_ratio) * C_max, np.finfo(np.float64).tiny) + return np.logspace( + np.log10(C_min), np.log10(C_max), num=int(n_Cs), dtype=np.float64 + ) - C_min = C_max * C_min_ratio - - # Log-spaced grid - if n_Cs <= 1: - return np.array([C_max]) - Cs = np.logspace( - np.log10(C_min), - np.log10(C_max), - num=n_Cs, - dtype=np.float64, +def _default_logistic_c_grid_backend( + X, + y, + backend, + n_Cs: int = 100, + C_min_ratio: float = 1e-3, + sample_weight=None, +): + """Backend-native counterpart of :func:`_default_logistic_c_grid`.""" + X_arr = backend.asarray(X, dtype=backend.float64) + y_arr = backend.asarray(y, dtype=backend.float64).reshape(-1) + if sample_weight is None: + residual = y_arr - 0.5 + normalizer = float(y_arr.shape[0]) + else: + weight = backend.asarray(sample_weight, dtype=backend.float64).reshape(-1) + residual = weight * (y_arr - 0.5) + normalizer = float(backend.sum(weight)) + grad = X_arr.T @ residual + C_max = float(backend.max(backend.abs(grad))) * 2.0 / normalizer + if not np.isfinite(C_max) or C_max <= 0.0: + C_max = 1.0 + if int(n_Cs) <= 1: + return np.asarray([C_max], dtype=np.float64) + C_min = max(float(C_min_ratio) * C_max, np.finfo(np.float64).tiny) + return np.logspace( + np.log10(C_min), np.log10(C_max), num=int(n_Cs), dtype=np.float64 ) - return Cs - # ============================================================================= # Batch log-loss computation @@ -305,7 +327,9 @@ def _solve_logistic_path_gpu_from_batch(X_batch, y_batch, n_train_vec, Cs, backe try: params = backend.solve(XtWX, Xtz) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise lstsq_result = backend.lstsq(XtWX, Xtz) params = lstsq_result[0] @@ -396,42 +420,29 @@ def _select_logistic_c_cv( details : dict (if return_details=True) Full CV results including C grid, loss path, etc. """ - device_name = str(device).lower() - use_gpu = device_name in (Device.CUDA.value, Device.TORCH.value) + ( + device_name, + backend_name, + backend, + use_gpu, + gpu_input_cupy, + gpu_input_torch, + ) = resolve_cv_backend(device, X) gpu_requested = use_gpu - gpu_input_cupy = False - gpu_input_torch = False - if use_gpu: - # Check if inputs are already on GPU (CuPy or Torch) - try: - import cupy as cp - gpu_input_cupy = isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) - if sample_weight is not None and not isinstance(sample_weight, cp.ndarray): - gpu_input_cupy = False - except Exception: - pass - - # Also check for torch tensors - if not gpu_input_cupy: - try: - import torch - gpu_input_torch = isinstance(X, torch.Tensor) and isinstance(y, torch.Tensor) - if sample_weight is not None and not isinstance(sample_weight, torch.Tensor): - gpu_input_torch = False - except Exception: - pass - X_np = None y_np = None sample_weight_np = None if gpu_input_cupy or gpu_input_torch: # GPU inputs - get backend for validation - backend = get_backend(backend='auto', device='cuda') + # backend was selected strictly by resolve_cv_backend above if len(tuple(X.shape)) != 2: raise ValueError("X must be a 2D array") n_samples = int(X.shape[0]) + y_check = backend.asarray(y).reshape(-1) + if int(y_check.shape[0]) != n_samples: + raise ValueError("y must have the same number of rows as X") else: X_np = np.asarray(X, dtype=np.float64) y_np = np.asarray(y, dtype=np.float64).reshape(-1) @@ -443,41 +454,52 @@ def _select_logistic_c_cv( raise ValueError("y must have the same number of rows as X") n_samples = int(X_np.shape[0]) - # Generate C grid + validated_weight = validate_cv_sample_weight(sample_weight, n_samples) + if validated_weight is not None and not use_gpu: + sample_weight_np = np.asarray(validated_weight, dtype=np.float64).reshape(-1) + + + # Generate a grid for the same weighted objective optimized in each fold. if Cs is None: - if gpu_input_cupy or gpu_input_torch: - # GPU path for C grid generation - # Gradient of logistic loss at beta=0: X'(y - sigmoid(0)) = X'(y - 0.5) - # Do NOT center X/y — centering is incorrect for logistic regression - backend = get_backend(backend='auto', device='cuda') - X_temp = backend.asarray(X) - y_temp = backend.asarray(y) - grad = X_temp.T @ (y_temp - 0.5) - C_max = float(backend.max(backend.abs(grad)) * 2.0 / len(y_temp)) - if C_max == 0: - C_max = 1.0 - C_min = C_max * C_min_ratio - C_grid = np.logspace(np.log10(C_min), np.log10(C_max), num=n_Cs) + if use_gpu: + C_grid = _default_logistic_c_grid_backend( + X, + y, + backend, + n_Cs=n_Cs, + C_min_ratio=C_min_ratio, + sample_weight=validated_weight, + ) else: - C_grid = _default_logistic_c_grid(X_np, y_np, n_Cs=n_Cs, C_min_ratio=C_min_ratio) + C_grid = _default_logistic_c_grid( + X_np, + y_np, + n_Cs=n_Cs, + C_min_ratio=C_min_ratio, + sample_weight=sample_weight_np, + ) else: C_grid = np.asarray(Cs, dtype=np.float64) C_grid = C_grid[np.isfinite(C_grid)] C_grid = C_grid[C_grid > 0.0] if C_grid.size == 0: - if gpu_input_cupy or gpu_input_torch: - # GPU path for C grid generation - backend = get_backend(backend='auto', device='cuda') - X_temp = backend.asarray(X) - y_temp = backend.asarray(y) - grad = X_temp.T @ (y_temp - 0.5) - C_max = float(backend.max(backend.abs(grad)) * 2.0 / len(y_temp)) - if C_max == 0: - C_max = 1.0 - C_min = C_max * C_min_ratio - C_grid = np.logspace(np.log10(C_min), np.log10(C_max), num=n_Cs) + if use_gpu: + C_grid = _default_logistic_c_grid_backend( + X, + y, + backend, + n_Cs=n_Cs, + C_min_ratio=C_min_ratio, + sample_weight=validated_weight, + ) else: - C_grid = _default_logistic_c_grid(X_np, y_np, n_Cs=n_Cs, C_min_ratio=C_min_ratio) + C_grid = _default_logistic_c_grid( + X_np, + y_np, + n_Cs=n_Cs, + C_min_ratio=C_min_ratio, + sample_weight=sample_weight_np, + ) # Handle degenerate cases if int(n_samples) < 4 or int(C_grid.size) == 1 or int(cv_folds) < 2: @@ -520,7 +542,7 @@ def _select_logistic_c_cv( if use_gpu: try: # Get backend - supports both CuPy and Torch - backend = get_backend(backend='auto', device='cuda') + # backend was selected strictly by resolve_cv_backend above xp = backend.xp cv_dtype = backend.float32 if bool(gpu_cv_mixed_precision) else backend.float64 @@ -613,7 +635,7 @@ def _select_logistic_c_cv( except Exception as exc: raise RuntimeError( - "GPU path failed in _select_logistic_c_cv with device='cuda'; " + f"GPU path failed in _select_logistic_c_cv with backend={backend_name!r}; " "CPU fallback is disabled for strict CUDA execution." ) from exc @@ -797,6 +819,21 @@ def __init__( self.intercept_ = None self.n_iter_ = None self.estimator_ = None + self.cv_selected_device_ = None + + def _reset_cv_fit_state(self): + """Clear all fitted outputs before a new CV attempt.""" + self._fitted = False + self.C_ = None + self.Cs_ = None + self.cv_results_ = None + self.mean_loss_ = None + self.best_score_ = None + self.coef_ = None + self.intercept_ = None + self.n_iter_ = None + self.estimator_ = None + self.cv_selected_device_ = None def fit(self, X, y, sample_weight=None): """ @@ -816,71 +853,70 @@ def fit(self, X, y, sample_weight=None): self : LogisticRegressionCV Fitted estimator. """ - # Validate y is binary - y_arr = np.asarray(y, dtype=np.float64).ravel() - unique_y = np.unique(y_arr) - if not np.all(np.isin(unique_y, [0.0, 1.0])): - raise ValueError( - f"LogisticRegressionCV requires binary y (0 or 1), " - f"got unique values: {unique_y[:10]}" - ) + self._reset_cv_fit_state() + # Preserve response residency; only a scalar validity decision syncs. + _validate_binary_cv_response(y) - device_name = self._get_compute_device().value + # Keep AUTO unresolved until resolve_cv_backend can inspect X. + device_name = self._device + _, cv_backend_name, _, _, _, _ = resolve_cv_backend(device_name, X) + refit_device = cv_refit_device(device_name, cv_backend_name) # Run CV to select C details = _select_logistic_c_cv( X, y, Cs=self.Cs, - n_Cs=self.n_Cs, - C_min_ratio=self.C_min_ratio, - cv_folds=self.cv, + n_Cs=self._n_Cs, + C_min_ratio=self._C_min_ratio, + cv_folds=self._cv, cv_splits=self.cv_splits, random_state=self.random_state, sample_weight=sample_weight, - fit_intercept=self.fit_intercept, - max_iter=self.max_iter, - tol=self.tol, + fit_intercept=self._fit_intercept, + max_iter=self._max_iter, + tol=self._tol, device=device_name, - gpu_cv_mixed_precision=self.gpu_cv_mixed_precision, + gpu_cv_mixed_precision=self._gpu_cv_mixed_precision, return_details=True, ) - # Store CV results - self.C_ = float(details["C"]) - self.Cs_ = np.asarray(details["Cs"], dtype=np.float64) + # Keep candidate results local until the final refit succeeds. + selected_C = float(details["C"]) + selected_Cs = np.asarray(details["Cs"], dtype=np.float64) loss_path = np.asarray(details["loss_path"], dtype=np.float64) mean_loss = np.asarray(details["mean_loss"], dtype=np.float64) - - self.cv_results_ = {"loss_path": loss_path} - self.mean_loss_ = mean_loss - - if np.any(np.isfinite(mean_loss)): - # sklearn convention: best_score_ is negative loss (higher is better) - self.best_score_ = -float(np.nanmin(mean_loss)) - else: - self.best_score_ = np.nan + best_score = ( + -float(np.nanmin(mean_loss)) + if np.any(np.isfinite(mean_loss)) + else np.nan + ) # Fit final model with selected C estimator = LogisticRegression( - C=self.C_, - fit_intercept=self.fit_intercept, - max_iter=self.max_iter, - tol=self.tol, - device=self.device, + C=selected_C, + fit_intercept=self._fit_intercept, + max_iter=self._max_iter, + tol=self._tol, + device=refit_device, n_jobs=self.n_jobs, - compute_inference=self.compute_inference, - cov_type=self.cov_type, - gpu_memory_cleanup=self.gpu_memory_cleanup, + compute_inference=self._compute_inference_enabled, + cov_type=self._cov_type, + gpu_memory_cleanup=self._gpu_memory_cleanup, ) estimator.fit(X, y, sample_weight=sample_weight) + self.C_ = selected_C + self.Cs_ = selected_Cs + self.cv_results_ = {"loss_path": loss_path} + self.mean_loss_ = mean_loss + self.best_score_ = best_score self.estimator_ = estimator self.coef_ = np.asarray(estimator.coef_) self.intercept_ = estimator.intercept_ self.n_iter_ = getattr(estimator, 'n_iter_', None) - + self.cv_selected_device_ = refit_device self._fitted = True return self diff --git a/statgpu/linear_model/cv/_ridge_cv.py b/statgpu/linear_model/cv/_ridge_cv.py index 68dc2c4f6..24d6c76be 100644 --- a/statgpu/linear_model/cv/_ridge_cv.py +++ b/statgpu/linear_model/cv/_ridge_cv.py @@ -17,6 +17,11 @@ from statgpu.backends import get_backend, _torch_dev, xp_maximum from statgpu.backends._factory import _cupy_backend, _torch_backend from statgpu.linear_model.wrappers._ridge import Ridge +from ._device import ( + cv_refit_device, + resolve_cv_backend, + validate_cv_sample_weight, +) # ============================================================================= @@ -325,45 +330,22 @@ def _select_ridge_alpha_cv( details : dict (if return_details=True) Full CV results including alpha grid, MSE path, etc. """ - if isinstance(device, Device): - device = device.value - device_name = str(device).lower() - use_gpu = device_name in (Device.CUDA.value, Device.TORCH.value, "torch") + ( + device_name, + backend_name, + backend, + use_gpu, + gpu_input_cupy, + gpu_input_torch, + ) = resolve_cv_backend(device, X) gpu_requested = use_gpu - gpu_input_cupy = False - gpu_input_torch = False - if use_gpu: - # Check if inputs are already on GPU (CuPy or Torch) - try: - import cupy as cp - gpu_input_cupy = isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray) - if sample_weight is not None and not isinstance(sample_weight, cp.ndarray): - gpu_input_cupy = False - except Exception: - pass - - # Also check for torch tensors - if not gpu_input_cupy: - try: - import torch - gpu_input_torch = isinstance(X, torch.Tensor) and isinstance(y, torch.Tensor) - if sample_weight is not None and not isinstance(sample_weight, torch.Tensor): - gpu_input_torch = False - except Exception: - pass - X_np = None y_np = None sample_weight_np = None if gpu_input_cupy or gpu_input_torch: - # GPU inputs - get backend for validation - # Use torch backend for torch tensors, cupy for cupy arrays - if gpu_input_torch: - backend = get_backend(backend='torch', device='cuda') - else: - backend = get_backend(backend='cupy', device='cuda') + # backend was selected strictly by resolve_cv_backend above if len(tuple(X.shape)) != 2: raise ValueError("X must be a 2D array") n_samples = int(X.shape[0]) @@ -387,12 +369,14 @@ def _select_ridge_alpha_cv( raise ValueError("sample_weight must have the same number of rows as X") n_samples = int(X_np.shape[0]) + validated_weight = validate_cv_sample_weight(sample_weight, n_samples) + if validated_weight is not None and not use_gpu: + sample_weight_np = np.asarray(validated_weight, dtype=np.float64).reshape(-1) + # Generate alpha grid if alphas is None: if gpu_input_cupy or gpu_input_torch or use_gpu: - backend = get_backend( - backend='torch' if gpu_input_torch else 'cupy', device='cuda' - ) + # backend was selected strictly by resolve_cv_backend above alpha_grid = _default_ridge_alpha_grid_backend( X, y, backend, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio, sample_weight=sample_weight, @@ -473,13 +457,7 @@ def _select_ridge_alpha_cv( cupy_available = False # Detect input type and select appropriate backend - if hasattr(X, '__module__') and 'torch' in str(type(X).__module__): - backend = _torch_backend - elif cupy_available and hasattr(X, '__cuda_array_interface__'): - backend = _cupy_backend - else: - # Default to auto-selection for numpy input - backend = get_backend(backend='auto', device='cuda') + # backend was selected strictly by resolve_cv_backend above xp = backend.xp @@ -1054,6 +1032,21 @@ def __init__( self.intercept_ = None self.n_iter_ = None self.estimator_ = None + self.cv_selected_device_ = None + + def _reset_cv_fit_state(self): + """Clear all fitted outputs before a new CV attempt.""" + self._fitted = False + self.alpha_ = None + self.alphas_ = None + self.cv_results_ = None + self.mean_mse_ = None + self.best_score_ = None + self.coef_ = None + self.intercept_ = None + self.n_iter_ = None + self.estimator_ = None + self.cv_selected_device_ = None def fit(self, X, y, sample_weight=None): """ @@ -1073,65 +1066,69 @@ def fit(self, X, y, sample_weight=None): self : RidgeCV Fitted estimator. """ + self._reset_cv_fit_state() from statgpu.cross_validation._base import validate_cv_sample_weight n_samples = int(X.shape[0]) if hasattr(X, 'shape') else len(X) sample_weight = validate_cv_sample_weight(sample_weight, n_samples) - device_name = self._get_compute_device().value + device_name = self._device + _, cv_backend_name, _, _, _, _ = resolve_cv_backend(device_name, X) + refit_device = cv_refit_device(device_name, cv_backend_name) # Run CV to select alpha details = _select_ridge_alpha_cv( X, y, alphas=self.alphas, - n_alphas=self.n_alphas, - alpha_min_ratio=self.alpha_min_ratio, - cv_folds=self.cv, + n_alphas=self._n_alphas, + alpha_min_ratio=self._alpha_min_ratio, + cv_folds=self._cv, cv_splits=self.cv_splits, random_state=self.random_state, sample_weight=sample_weight, - fit_intercept=self.fit_intercept, + fit_intercept=self._fit_intercept, device=device_name, - gpu_cv_mixed_precision=self.gpu_cv_mixed_precision, + gpu_cv_mixed_precision=self._gpu_cv_mixed_precision, return_details=True, ) - # Store CV results - self.alpha_ = float(details["alpha"]) - self.alphas_ = np.asarray(details["alphas"], dtype=np.float64) + # Keep candidate results local until the final refit succeeds. + selected_alpha = float(details["alpha"]) + selected_alphas = np.asarray(details["alphas"], dtype=np.float64) mse_path = np.asarray(details["mse_path"], dtype=np.float64) mean_mse = np.asarray(details["mean_mse"], dtype=np.float64) - - self.cv_results_ = {"mse_path": mse_path} - self.mean_mse_ = mean_mse - - if np.any(np.isfinite(mean_mse)): - # sklearn convention: best_score_ is negative MSE (higher is better) - self.best_score_ = -float(np.nanmin(mean_mse)) - else: - self.best_score_ = np.nan + best_score = ( + -float(np.nanmin(mean_mse)) + if np.any(np.isfinite(mean_mse)) + else np.nan + ) # Fit final model with selected alpha. # Exact solve uses n*alpha on unnormalized X'X, matching the # per-sample convention (loss/n + alpha*||w||^2) used by all paths. # alpha_ stores the CV-selected value; pass it directly to Ridge. estimator = Ridge( - alpha=self.alpha_, - fit_intercept=self.fit_intercept, - device=self.device, + alpha=selected_alpha, + fit_intercept=self._fit_intercept, + device=refit_device, n_jobs=self.n_jobs, - compute_inference=self.compute_inference, - cov_type=self.cov_type, - gpu_memory_cleanup=self.gpu_memory_cleanup, + compute_inference=self._compute_inference_enabled, + cov_type=self._cov_type, + gpu_memory_cleanup=self._gpu_memory_cleanup, ) estimator.fit(X, y, sample_weight=sample_weight) + self.alpha_ = selected_alpha + self.alphas_ = selected_alphas + self.cv_results_ = {"mse_path": mse_path} + self.mean_mse_ = mean_mse + self.best_score_ = best_score self.estimator_ = estimator self.coef_ = np.asarray(estimator.coef_) self.intercept_ = estimator.intercept_ self.n_iter_ = getattr(estimator, 'n_iter_', None) - + self.cv_selected_device_ = refit_device self._fitted = True return self diff --git a/statgpu/linear_model/legacy/_elasticnet_legacy.py b/statgpu/linear_model/legacy/_elasticnet_legacy.py index 5ff8d84c6..da1e863d3 100644 --- a/statgpu/linear_model/legacy/_elasticnet_legacy.py +++ b/statgpu/linear_model/legacy/_elasticnet_legacy.py @@ -11,9 +11,10 @@ Optimized implementations: - CPU: FISTA with pre-computed Gram matrix - GPU (CuPy): Fused kernel operations with @cp.fuse() -- GPU (Torch): torch.compile() with warm-up strategy +- GPU (Torch): compile_torch() with warm-up strategy """ +from statgpu.backends._torch_compile import compile_torch from typing import Optional, Union import warnings import numpy as np @@ -186,24 +187,18 @@ def _elastic_net_proximal_torch(w_tilde, thresh, l2_scale): torch.tensor(0.0, device=w_tilde.device, dtype=w_tilde.dtype) ) / l2_scale - # Compile the proximal operator - try: - torch._dynamo.config.suppress_errors = True - torch._dynamo.config.guard_immutable_object = False - _elastic_net_proximal_compiled = torch.compile( - _elastic_net_proximal_torch, mode='reduce-overhead' - ) - except (AttributeError, RuntimeError): - _elastic_net_proximal_compiled = _elastic_net_proximal_torch - - return _elastic_net_proximal_compiled + # Compile through the centralized observable policy. Do not mutate + # process-global Dynamo suppression settings here. + return compile_torch( + _elastic_net_proximal_torch, workload="iterative" + ) def _fit_elasticnet_torch_optimized(X, y, alpha, l1_ratio, n_samples, n_features, max_iter=1000, tol=1e-4, lipschitz_L=None, stopping='coef_delta', warmup=True): """ - Fit Elastic Net using optimized PyTorch operations with torch.compile(). + Fit Elastic Net using optimized PyTorch operations with compile_torch(). """ import torch @@ -848,7 +843,7 @@ def _cleanup_torch_memory(self): def _fit_torch(self, X, y, sample_weight=None): """ - Fit using Torch GPU with optimized FISTA solver and torch.compile(). + Fit using Torch GPU with optimized FISTA solver and compile_torch(). """ import torch @@ -889,7 +884,7 @@ def _fit_torch(self, X, y, sample_weight=None): y_mean = torch.tensor(0.0, dtype=X.dtype, device=X.device) y_centered = y - # Use optimized implementation with torch.compile() + # Use optimized implementation with compile_torch() coef, self.n_iter_ = _fit_elasticnet_torch_optimized( X=X_centered, y=y_centered, diff --git a/statgpu/linear_model/penalized/_base.py b/statgpu/linear_model/penalized/_base.py index cead46c47..7d3fbd9ba 100644 --- a/statgpu/linear_model/penalized/_base.py +++ b/statgpu/linear_model/penalized/_base.py @@ -281,7 +281,7 @@ def _effective_intercept(self): """Return effective intercept flag. Formula path overrides via _use_intercept.""" if self._use_intercept is not None: return self._use_intercept - return self.fit_intercept + return self._fit_intercept def _resolve_penalty(self) -> "Penalty": """Resolve penalty string or instance to a Penalty object.""" @@ -296,7 +296,7 @@ def _resolve_penalty(self) -> "Penalty": if pen_name in ("none", "null", ""): return get_penalty("l2", alpha=0.0) - kwargs = {**self.penalty_kwargs, "alpha": self.alpha} + kwargs = {**self._penalty_kwargs, "alpha": self.alpha} if pen_name in ("elasticnet", "en"): kwargs["l1_ratio"] = self.l1_ratio @@ -310,17 +310,17 @@ def _resolve_loss(self): """ try: from statgpu.glm_core import get_glm_loss - return get_glm_loss(self.loss, **self.loss_kwargs) + return get_glm_loss(self.loss, **self._loss_kwargs) except (ValueError, KeyError, TypeError): from statgpu.losses import get_loss - return get_loss(self.loss, **self.loss_kwargs) + return get_loss(self.loss, **self._loss_kwargs) def _validate_solver_penalty(self): """Validate solver/penalty combinations before backend dispatch.""" - solver_name = self.solver + solver_name = self._solver penalty_name = str(getattr(self._penalty, "name", self.penalty)).lower() non_smooth = _NONSMOOTH_PENALTIES - if self.solver == "exact": + if self._solver == "exact": if self.loss != "squared_error" or penalty_name != "l2": raise ValueError( "solver='exact' is only supported for squared-error L2/Ridge models." @@ -361,7 +361,7 @@ def _validate_inference_request(self): - SCAD/MCP + oracle/bootstrap: oracle active-set or bootstrap - Any loss + bootstrap: universal fallback """ - if not self.compute_inference: + if not self._compute_inference_enabled: return penalty_name = str(getattr(self._penalty, "name", self.penalty)).lower() inference_method = str(getattr(self, "inference_method", "sandwich")).lower() diff --git a/statgpu/linear_model/penalized/_fit_mixin.py b/statgpu/linear_model/penalized/_fit_mixin.py index 9bce8c6cd..9cf092c1f 100644 --- a/statgpu/linear_model/penalized/_fit_mixin.py +++ b/statgpu/linear_model/penalized/_fit_mixin.py @@ -2,11 +2,13 @@ from __future__ import annotations +from statgpu.backends._torch_compile import compile_torch import numpy as np from statgpu._config import Device -from statgpu.backends import get_backend, _to_numpy, _LINALG_ERRORS +from statgpu.backends import get_backend, _to_numpy from statgpu.solvers._utils import _nesterov_momentum, _nesterov_update +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure # --------------------------------------------------------------------------- # Solver dispatch table for solver='auto' @@ -51,8 +53,8 @@ def _validate_sample_weight_backend(sample_weight, n_samples, backend_name): raise ValueError("sample_weight must be non-negative") total = float(np.sum(weights)) - if total <= 0.0: - raise ValueError("sample_weight must have a positive sum") + if not np.isfinite(total) or total <= 0.0: + raise ValueError("sample_weight must have a finite positive sum") return total # Losses with special LLA handling (not routed through generic GLM path). @@ -292,6 +294,12 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): self : PenalizedLinearRegression Fitted estimator. """ + # Direct public parameter replacement is part of the refit API. + self._penalty_kwargs = ( + self.penalty_kwargs if self.penalty_kwargs is not None else {} + ) + self._loss_kwargs = self.loss_kwargs if self.loss_kwargs is not None else {} + if formula is not None: if data is None: raise ValueError( @@ -303,17 +311,14 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): parser = FormulaParser(formula) y, X, design_info = parser.eval(data) if sample_weight is not None: - sw_formula = np.asarray(_to_numpy(sample_weight), dtype=np.float64).reshape(-1) - row_positions = parser.row_positions - if sw_formula.shape[0] == len(data): - sample_weight = sw_formula[row_positions] - elif sw_formula.shape[0] == X.shape[0]: - sample_weight = sw_formula - else: - raise ValueError( - "For formula fitting, sample_weight must have length " - "len(data) or the number of rows retained by the formula." - ) + from statgpu.core.formula import align_formula_sample_weight + + sample_weight = align_formula_sample_weight( + sample_weight, + data_length=len(data), + retained_rows=parser.row_positions, + retained_length=X.shape[0], + ) formula_column_names = list(design_info.column_names) self._design_info = design_info self._formula_has_intercept = "Intercept" in formula_column_names @@ -332,13 +337,19 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): self._formula_has_intercept = None self._use_intercept = None - # Record number of features for sklearn compatibility - if X is not None: - X_arr = np.asarray(X) if not hasattr(X, 'shape') else X - self.n_features_in_ = X_arr.shape[1] if X_arr.ndim >= 2 else 1 + from statgpu.glm_core._validation import ( + validate_glm_design_matrix, + validate_glm_sample_weight, + ) + X = validate_glm_design_matrix(X) + self.n_features_in_ = int(X.shape[1]) self._penalty = self._resolve_penalty() self._loss = self._resolve_loss() + if hasattr(self._loss, "validate_response"): + y = self._loss.validate_response(y) + if int(y.shape[0]) != int(len(X)): + raise ValueError("Response length must match the number of X rows.") self._validate_solver_penalty() self._validate_inference_request() @@ -357,7 +368,7 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): # Auto-dispatch small problems to CPU only when device="auto". # Explicit CUDA/TORCH device selection must never silently fall back. - if self.device == Device.AUTO and backend_name in ("cupy", "torch") and X is not None: + if self._device == Device.AUTO and backend_name in ("cupy", "torch") and X is not None: _n, _p = X.shape if _n * _p < 200_000: backend_name = "numpy" @@ -372,8 +383,10 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): # Convert sample_weight to target backend once (avoids CPU/CUDA mismatch) _sw_arr = None if sample_weight is not None: - _sw_arr = self._to_array(sample_weight, backend=backend_name).reshape(-1) - _validate_sample_weight_backend(_sw_arr, X.shape[0], backend_name) + sample_weight = validate_glm_sample_weight( + sample_weight, X.shape[0] + ) + _sw_arr = self._to_array(sample_weight, backend=backend_name) # Handle penalties requiring initialization (e.g., Adaptive Lasso) if self._penalty.requires_init: @@ -412,7 +425,7 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): max_lla_per_step=_max_lla_per_step, lla_tol=getattr(self, '_lla_tol', 1e-6), max_iter=_mi_path, - tol=self.tol, + tol=self._tol, fit_intercept=self._effective_intercept, sample_weight=_sw_arr, ) @@ -426,7 +439,7 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): max_lla_per_step=_max_lla_per_step, lla_tol=getattr(self, '_lla_tol', 1e-6), max_iter=_mi_path, - tol=self.tol, + tol=self._tol, fit_intercept=self._effective_intercept, sample_weight=_sw_arr, ) @@ -466,8 +479,8 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): def _select_solver(self, loss, backend_name=None, X=None): """Auto-select solver based on loss, penalty, and backend.""" - if self.solver != "auto": - return self.solver + if self._solver != "auto": + return self._solver return _preferred_penalized_glm_solver( getattr(loss, "name", self.loss), getattr(self._penalty, "name", self.penalty), @@ -514,7 +527,7 @@ def _cupy_available(): def _auto_backend_override(self, backend_name, X): """Benchmark-backed backend routing for device='auto' only.""" self._auto_backend_reason = None - if self.device != Device.AUTO or self.solver != "auto" or X is None: + if self._device != Device.AUTO or self._solver != "auto" or X is None: return backend_name n_samples, n_features = X.shape @@ -643,7 +656,7 @@ def _fit_initial(self, X, y, backend_name="numpy"): init_model = Ridge( alpha=0.1, fit_intercept=self._effective_intercept, - device=self.device, + device=self._device, ) init_model.fit(X, y) return init_model.coef_ @@ -689,7 +702,7 @@ def _compute_lla_path(self, X_work, y_arr, p, loss_name, n_cont=None): _alpha_path = _np.geomspace(_alpha_start, _target_alpha, n_cont) _max_lla = max(_MAX_LLA_PER_STEP_DEFAULT, getattr(self, '_max_lla_iters', 50) // n_cont) - _saved_mi = self.max_iter + _saved_mi = self._max_iter _mi_path = [_saved_mi if i == n_cont - 1 else max(100, _saved_mi // 10) for i in range(n_cont)] @@ -817,7 +830,7 @@ def _fit_cpu(self, X, y, sample_weight=None): y_k = coef.copy() t_k = 1.0 - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): coef_old = coef.copy() grad_at_y = (XtX @ y_k - Xty) / n_eff @@ -833,7 +846,7 @@ def _fit_cpu(self, X, y, sample_weight=None): self.n_iter_ = iteration + 1 - if np.sum(np.abs(coef - coef_old)) < self.tol: + if np.sum(np.abs(coef - coef_old)) < self._tol: break else: @@ -874,7 +887,7 @@ def _fit_cpu(self, X, y, sample_weight=None): for g_idx in _g_indices: _XtX_blocks.append(XtX[np.ix_(g_idx, g_idx)]) - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): coef_old = coef.copy() if _is_group: @@ -965,7 +978,7 @@ def _fit_cpu(self, X, y, sample_weight=None): self.n_iter_ = iteration + 1 - if np.sum(np.abs(coef - coef_old)) < self.tol: + if np.sum(np.abs(coef - coef_old)) < self._tol: break # Compute intercept and store results @@ -1086,7 +1099,7 @@ def _fit_gpu_backend(self, X, y, sample_weight=None, backend_name="cupy"): else: coef_full_gpu = coef.reshape(-1) - if self.compute_inference: + if self._compute_inference_enabled: infer_fn = getattr(self, f'_precompute_exact_l2_inference_{"torch" if is_torch else "cupy"}') infer_fn( X, y, XtX, X_mean, coef_full_gpu, n_samples, @@ -1205,36 +1218,29 @@ def _fit_gpu_backend(self, X, y, sample_weight=None, backend_name="cupy"): if is_torch: import torch if _use_l2: - # torch.compile requires Triton (CUDA capability >= 7.0). - # On older GPUs (e.g. Tesla P100, CUDA 6.0), skip compilation - # and use the plain eager-mode function directly. - _can_compile = (torch.cuda.is_available() - and torch.cuda.get_device_capability()[0] >= 7) - if _can_compile: - try: - def _fista_elementwise_l2(_y_k, _xtx_y, _step_over_n_Xty, _step_over_n, - _thresh, _l2_scale, _coef_old, _beta): - w = _y_k - _step_over_n * _xtx_y + _step_over_n_Xty - c = _st_fn(w, _thresh, xp) / _l2_scale - y = c + _beta * (c - _coef_old) - return c, y - _fused_step_l2 = torch.compile(_fista_elementwise_l2, mode='reduce-overhead') - except Exception: - _fused_step_l2 = None + def _fista_elementwise_l2( + _y_k, _xtx_y, _step_over_n_Xty, _step_over_n, + _thresh, _l2_scale, _coef_old, _beta, + ): + w = _y_k - _step_over_n * _xtx_y + _step_over_n_Xty + c = _st_fn(w, _thresh, xp) / _l2_scale + y = c + _beta * (c - _coef_old) + return c, y + _fused_step_l2 = compile_torch( + _fista_elementwise_l2, workload="iterative" + ) else: - _can_compile = (torch.cuda.is_available() - and torch.cuda.get_device_capability()[0] >= 7) - if _can_compile: - try: - def _fista_elementwise(_y_k, _xtx_y, _step_over_n_Xty, _step_over_n, - _thresh, _coef_old, _beta): - w = _y_k - _step_over_n * _xtx_y + _step_over_n_Xty - c = _st_fn(w, _thresh, xp) - y = c + _beta * (c - _coef_old) - return c, y - _fused_step = torch.compile(_fista_elementwise, mode='reduce-overhead') - except Exception: - _fused_step = None + def _fista_elementwise( + _y_k, _xtx_y, _step_over_n_Xty, _step_over_n, + _thresh, _coef_old, _beta, + ): + w = _y_k - _step_over_n * _xtx_y + _step_over_n_Xty + c = _st_fn(w, _thresh, xp) + y = c + _beta * (c - _coef_old) + return c, y + _fused_step = compile_torch( + _fista_elementwise, workload="iterative" + ) else: import cupy as cp if _use_l2: @@ -1266,7 +1272,7 @@ def _fista_elementwise(_y_k, _xtx_y, _step_over_n_Xty, _step_over_n, except Exception: _fused_step = None - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): coef_old = xp_copy(coef) xtx_y = XtX @ y_k @@ -1297,7 +1303,7 @@ def _fista_elementwise(_y_k, _xtx_y, _step_over_n_Xty, _step_over_n, beta, t_k = _nesterov_momentum(t_k) self.n_iter_ = iteration + 1 - if iteration % 5 == 4 and float(_to_numpy(_abs_sum_dev(coef - coef_old))) < self.tol: + if iteration % 5 == 4 and float(_to_numpy(_abs_sum_dev(coef - coef_old))) < self._tol: break else: step = 1.0 / L @@ -1308,7 +1314,7 @@ def _fista_elementwise(_y_k, _xtx_y, _step_over_n_Xty, _step_over_n, y_k = xp_copy(coef) t_k = 1.0 - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): coef_old = xp_copy(coef) grad = (XtX @ y_k - Xty) / n_samples w_tilde = y_k - step * grad @@ -1320,7 +1326,7 @@ def _fista_elementwise(_y_k, _xtx_y, _step_over_n_Xty, _step_over_n, y_k, t_k = _nesterov_update(coef, coef_old, t_k) self.n_iter_ = iteration + 1 - if iteration % 5 == 4 and float(_to_numpy(_abs_sum_dev(coef - coef_old))) < self.tol: + if iteration % 5 == 4 and float(_to_numpy(_abs_sum_dev(coef - coef_old))) < self._tol: break # Transfer to CPU @@ -1337,7 +1343,7 @@ def _fista_elementwise(_y_k, _xtx_y, _step_over_n_Xty, _step_over_n, self._df_resid = n_samples - (n_features + (1 if self._effective_intercept else 0)) # Debiased inference on GPU (before cleanup) - if self.compute_inference and "debiased" in str(getattr(self, "inference_method", "")).lower(): + if self._compute_inference_enabled and "debiased" in str(getattr(self, "inference_method", "")).lower(): penalty_name = str(getattr(self._penalty, "name", self.penalty)).lower() if penalty_name in ("l1", "elasticnet", "en"): infer_fn = getattr(self, f'_compute_inference_debiased_{"torch" if is_torch else "gpu"}') @@ -1372,15 +1378,19 @@ def _solve_exact_cupy(self, XtX, Xty, normalization): A = XtX + (float(normalization) * alpha) * cp.eye(p, dtype=XtX.dtype) try: # Cholesky + triangular solve is faster than general solve - # for positive-definite matrices (Ridge penalty guarantees PD) + # for positive-definite matrices (Ridge penalty guarantees PD). L = cp.linalg.cholesky(A) tmp = cp_solve_triangular(L, Xty, lower=True) return cp_solve_triangular(L.T, tmp, lower=False) - except _LINALG_ERRORS: - try: - return cp.linalg.solve(A, Xty) - except _LINALG_ERRORS: - return cp.linalg.pinv(A) @ Xty + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise + try: + return cp.linalg.solve(A, Xty) + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise + return cp.linalg.pinv(A) @ Xty def _solve_exact_torch(self, XtX, Xty, normalization): import torch @@ -1394,7 +1404,9 @@ def _solve_exact_torch(self, XtX, Xty, normalization): # torch.linalg.solve is faster than Cholesky + solve_triangular # on PyTorch due to kernel launch overhead for small matrices return torch.linalg.solve(A, Xty) - except RuntimeError: + except RuntimeError as exc: + if not _linalg_exception_is_rank_failure(exc): + raise return torch.linalg.pinv(A) @ Xty def _block_cd_group_lasso(self, pen, X_work, y_arr, init): @@ -1433,7 +1445,7 @@ def _block_cd_group_lasso(self, pen, X_work, y_arr, init): coef = np.zeros(pp, dtype=np.float64) iteration = -1 # ensure defined when max_iter=0 - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): coef_old = coef.copy() for g in range(_n_groups): @@ -1453,7 +1465,7 @@ def _block_cd_group_lasso(self, pen, X_work, y_arr, init): if self._effective_intercept: coef[pp - 1] = np.mean(y_arr - X_work[:, :p] @ coef[:p]) - if np.max(np.abs(coef - coef_old)) < self.tol: + if np.max(np.abs(coef - coef_old)) < self._tol: break n_iter = iteration + 1 @@ -1553,7 +1565,7 @@ def _block_cd_group_lasso_gpu(self, pen, X_work, y_arr, init, backend_name): _XtX_batched = xp.stack(_XtX_blocks) # (G, gs, gs) iteration = -1 # ensure defined when max_iter=0 - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): coef_old = _xp_copy(coef) if _equal_size and _n_groups > 1: @@ -1578,7 +1590,9 @@ def _block_cd_group_lasso_gpu(self, pen, X_work, y_arr, init, backend_name): # Batched solve: w_g = XtX_blocks[g]^{-1} @ rho_g try: w_mat = xp.linalg.solve(_XtX_batched, rho_mat) # (G, gs) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise w_mat = xp.zeros_like(rho_mat) bad = xp.isnan(w_mat) | xp.isinf(w_mat) if xp.any(bad): @@ -1604,7 +1618,9 @@ def _block_cd_group_lasso_gpu(self, pen, X_work, y_arr, init, backend_name): w_g = xp.linalg.solve(_XtX_blocks[g], rho_g) if xp.any(xp.isnan(w_g)) or xp.any(xp.isinf(w_g)): w_g = _xp_zeros(len(g_idx), X_work.dtype, X_work) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise w_g = _xp_zeros(len(g_idx), X_work.dtype, X_work) norm_w = float(xp.linalg.norm(w_g)) thresh_g = alpha * _sqrt_pg[g] @@ -1617,7 +1633,7 @@ def _block_cd_group_lasso_gpu(self, pen, X_work, y_arr, init, backend_name): coef[pp - 1] = float(xp.mean(y_arr - X_work[:, :p] @ coef[:p])) _max_change = float(xp.max(xp.abs(coef - coef_old))) - if _max_change < self.tol: + if _max_change < self._tol: break n_iter = iteration + 1 @@ -1731,7 +1747,7 @@ def _fit_loss_backend(self, X, y, sample_weight, solver_name, backend_name): # FISTA for GLM+adaptive_l1 -- works on any backend. params, n_iter = fista_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) elif _use_quantile_cd: @@ -1750,7 +1766,7 @@ def _fit_loss_backend(self, X, y, sample_weight, solver_name, backend_name): max_lla_per_step=_max_lla_per_step, lla_tol=getattr(self, '_lla_tol', 1e-6), max_iter=_mi_path, - tol=self.tol, + tol=self._tol, fit_intercept=self._effective_intercept, sample_weight=sample_weight, ) @@ -1777,7 +1793,7 @@ def _fit_loss_backend(self, X, y, sample_weight, solver_name, backend_name): max_lla_per_step=_max_lla_per_step, lla_tol=getattr(self, '_lla_tol', 1e-6), max_iter=_mi_path, - tol=self.tol, + tol=self._tol, fit_intercept=self._effective_intercept, sample_weight=sample_weight, ) @@ -1847,7 +1863,7 @@ def _fit_loss_backend(self, X, y, sample_weight, solver_name, backend_name): ) _max_lla_per_step = max(_MAX_LLA_PER_STEP_DEFAULT, getattr(self, '_max_lla_iters', 50) // max(_n_cont, 1)) - _saved_mi = self.max_iter + _saved_mi = self._max_iter if _cv_return_path: _mi_path = [max(200, _saved_mi // 2)] * max(_n_cont - 1, 0) + [_saved_mi] else: @@ -1904,7 +1920,7 @@ def _fit_loss_backend(self, X, y, sample_weight, solver_name, backend_name): max_lla_per_step=_max_lla_per_step, lla_tol=getattr(self, '_lla_tol', 1e-6), max_iter=_mi_path, - tol=self.tol, + tol=self._tol, fit_intercept=self._effective_intercept, sample_weight=sample_weight, init_coef=_warm_coef, @@ -1950,7 +1966,7 @@ def _fit_loss_backend(self, X, y, sample_weight, solver_name, backend_name): max(_lam_max, _target_alpha * 1.1), _target_alpha, _n_cont, ) _max_lla_per_step = max(_MAX_LLA_PER_STEP_DEFAULT, getattr(self, '_max_lla_iters', 50) // _n_cont) - _saved_mi = self.max_iter + _saved_mi = self._max_iter _mi_path = [_saved_mi if i == _n_cont - 1 else max(100, _saved_mi // 10) for i in range(_n_cont)] @@ -1982,7 +1998,7 @@ def _group_lla_factory(weights_np): max_lla_per_step=_max_lla_per_step, lla_tol=getattr(self, '_lla_tol', 1e-6), max_iter=_mi_path, - tol=self.tol, + tol=self._tol, fit_intercept=self._effective_intercept, sample_weight=sample_weight, lla_penalty_factory=_group_lla_factory, @@ -2002,7 +2018,7 @@ def _group_lla_factory(weights_np): from statgpu.solvers import fista_solver params, n_iter = fista_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) elif backend_name != "numpy": @@ -2035,11 +2051,11 @@ def _group_lla_factory(weights_np): _is_smooth_pen = _pen_name in ("l2", "none", "null", "") if _loss_name == "quantile" and _has_irls and _is_smooth_pen: _inner_pen = getattr(self._penalty, '_pen', self._penalty) - _irls_tol = min(self.tol, 1e-8) + _irls_tol = min(self._tol, 1e-8) params_irls, n_iter = self._loss.irls( X_work, y_arr, penalty=_inner_pen, - max_iter=self.max_iter, tol=_irls_tol, + max_iter=self._max_iter, tol=_irls_tol, init_coef=None, sample_weight=sample_weight, fit_intercept=self._effective_intercept, @@ -2048,32 +2064,32 @@ def _group_lla_factory(weights_np): else: params, n_iter = fista_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) elif solver_name == "fista_bb": params, n_iter = fista_bb_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) elif solver_name == "admm": params, n_iter = admm_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, - tol=self.tol, rho=1.0, adaptive_rho=True, + max_iter=self._max_iter, + tol=self._tol, rho=1.0, adaptive_rho=True, init_coef=init, sample_weight=sample_weight, ) elif solver_name == "newton": params, n_iter = newton_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) elif solver_name == "lbfgs": params, n_iter = lbfgs_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) elif solver_name == "irls": @@ -2085,12 +2101,12 @@ def _group_lla_factory(weights_np): _inner_pen = getattr(self._penalty, '_pen', self._penalty) # Use tighter tolerance for quantile (1e-4 is too loose) _loss_name = getattr(self._loss, 'name', '') - _irls_tol = min(self.tol, 1e-8) if _loss_name == "quantile" else self.tol + _irls_tol = min(self._tol, 1e-8) if _loss_name == "quantile" else self._tol # Pass arrays directly — IRLS uses _get_xp(X) for backend dispatch params_irls, n_iter = self._loss.irls( X_work, y_arr, penalty=_inner_pen, - max_iter=self.max_iter, tol=_irls_tol, + max_iter=self._max_iter, tol=_irls_tol, init_coef=None, sample_weight=sample_weight, fit_intercept=self._effective_intercept, @@ -2100,7 +2116,7 @@ def _group_lla_factory(weights_np): # Fallback: resolve auto to fista (default for non-smooth losses) params, n_iter = fista_solver( self._loss, pen, X_work, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, init_coef=init, sample_weight=sample_weight, ) else: @@ -2195,7 +2211,7 @@ def _fit_irls_backend(self, X, y, sample_weight=None, backend_name="numpy"): init_coef = init_coef_np solver = IRLSSolver( - self._family_for_loss(), max_iter=self.max_iter, tol=self.tol + self._family_for_loss(), max_iter=self._max_iter, tol=self._tol ) ridge_normalization = ( float(n_samples) @@ -2233,7 +2249,7 @@ def _fit_irls_backend(self, X, y, sample_weight=None, backend_name="numpy"): def _cleanup_cuda_memory(self): """Free CuPy memory pool.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -2244,7 +2260,7 @@ def _cleanup_cuda_memory(self): def _cleanup_torch_memory(self): """Free Torch memory pool.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch diff --git a/statgpu/linear_model/penalized/_inference_mixin.py b/statgpu/linear_model/penalized/_inference_mixin.py index ca29d273c..de929395e 100644 --- a/statgpu/linear_model/penalized/_inference_mixin.py +++ b/statgpu/linear_model/penalized/_inference_mixin.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from statgpu.backends import _to_numpy +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure from statgpu.linear_model._gaussian_inference import ( GaussianFitState, build_gaussian_fit_state, @@ -59,7 +60,7 @@ def _gaussian_fit_state(self, X, y, sample_weight=None): def _compute_post_fit_gaussian_inference(self, X, y, sample_weight=None): """Populate inference state after fit. Routes to sandwich/debiased/oracle.""" - if not self.compute_inference: + if not self._compute_inference_enabled: return # Non-squared_error Hessian losses + smooth/L2 penalties: penalized sandwich @@ -159,8 +160,8 @@ def _compute_post_fit_gaussian_inference(self, X, y, sample_weight=None): self._resid, self._scale, self._df_resid, - self.cov_type, - hac_maxlags=self.hac_maxlags, + self._cov_type, + hac_maxlags=self._hac_maxlags, ridge_alpha=ridge_alpha, ridge_penalize_intercept=False if self._effective_intercept else True, ) @@ -236,7 +237,9 @@ def _debiased_stats_from_M(M, Sigma_hat, sigma2, coef, X, y, X_full = xp.concatenate([_ones, X], axis=1) try: XtX_inv = xp.linalg.inv(X_full.T @ X_full) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise XtX_inv = xp.linalg.pinv(X_full.T @ X_full) se_intercept = float(xp.sqrt(sigma2 * XtX_inv[0, 0])) z_intercept = float(intercept) / (se_intercept + 1e-30) @@ -295,7 +298,7 @@ def _compute_post_fit_debiased_inference(self, X, y, sample_weight=None): sigma_hat = np.sqrt(sigma2) lam_nw = np.sqrt(2.0 * np.log(max(p, 2)) / n) * sigma_hat m_cache_key = _debiased_m_key_from_numpy_design( - X_np, n=n, p=p, lam_nw=lam_nw, tol=float(self.tol), + X_np, n=n, p=p, lam_nw=lam_nw, tol=float(self._tol), ) M_cached = _debiased_m_cache_get(m_cache_key) if M_cached is not None: @@ -551,7 +554,7 @@ def _compute_post_fit_bootstrap_inference(self, X, y): refit = PenalizedLinearRegression( penalty="l1", alpha=float(self.alpha), fit_intercept=self._effective_intercept, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, device="cpu", cpu_solver="fista", compute_inference=False, inference_method="none", ) @@ -640,7 +643,7 @@ def _compute_inference_debiased_gpu(self, X_gpu, y_gpu, coef_gpu): x_hasher = hashlib.blake2b(digest_size=32) x_hasher.update(np.asarray([int(n), int(p)], dtype=np.int64).tobytes()) x_hasher.update(str(X_gpu.dtype).encode("utf-8")) - x_hasher.update(np.asarray([float(lam_nw), float(self.tol)], dtype=np.float64).tobytes()) + x_hasher.update(np.asarray([float(lam_nw), float(self._tol)], dtype=np.float64).tobytes()) row_chunk = max(1, min(int(n), _LASSO_DEBIASED_M_GPU_HASH_ROW_CHUNK)) for start in range(0, int(n), row_chunk): stop = min(int(n), start + row_chunk) @@ -823,7 +826,7 @@ def _compute_inference_debiased_torch(self, X_torch, y_torch, coef_torch): X_sample = X_torch[: min(24, n), : min(24, p)].cpu().numpy() m_cache_key = _debiased_m_key_from_sample( n=n, p=p, dtype_name=str(dtype), - sample_block=X_sample, lam_nw=lam_nw, tol=float(self.tol), + sample_block=X_sample, lam_nw=lam_nw, tol=float(self._tol), ) M_cached = _debiased_m_cache_get(m_cache_key) @@ -1033,7 +1036,7 @@ def _compute_penalized_sandwich_inference(self, X, y, sample_weight=None): result = m_estimation_inference( self._loss, X_design, y_arr, params, - cov_type=self.cov_type, + cov_type=self._cov_type, penalty_curvature_diag=curv if has_curv else None, sample_weight=sw_arr, ) @@ -1057,9 +1060,9 @@ def _compute_penalized_sandwich_inference(self, X, y, sample_weight=None): "dispersion": result["dispersion"], "wald_stat": result["wald_stat"], "wald_pval": result["wald_pval"], - "meat_type": self.cov_type, + "meat_type": self._cov_type, "covariance_convention": _infer_covariance_convention( - self.cov_type, has_curv + self._cov_type, has_curv ), "backend": backend, }, @@ -1169,7 +1172,7 @@ def _compute_oracle_inference(self, X, y, sample_weight=None): loss_obj = refit._resolve_loss_for_inference() if hasattr(refit, '_resolve_loss_for_inference') else self._loss result = m_estimation_inference( loss_obj, X_design, y_cpu, params_active, - cov_type=self.cov_type, sample_weight=sw_cpu) + cov_type=self._cov_type, sample_weight=sw_cpu) # Map back to full parameter space. # Inactive features keep their original penalized coefficient values @@ -1207,7 +1210,7 @@ def _compute_oracle_inference(self, X, y, sample_weight=None): metadata={ "n_active": n_active, "active_set": active.tolist() if hasattr(active, 'tolist') else list(active), - "covariance_convention": _infer_covariance_convention(self.cov_type, False), + "covariance_convention": _infer_covariance_convention(self._cov_type, False), }, ) self._inference_result.apply_to(self) @@ -1314,7 +1317,9 @@ def _precompute_exact_l2_inference_cupy( try: chol = cp.linalg.cholesky(bread) bread_inv = cp.linalg.solve(chol.T, cp.linalg.solve(chol, cp.eye(bread.shape[0], dtype=bread.dtype))) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise bread_inv = cp.linalg.pinv(bread) y_pred = X @ coef_full if X_mean is None else coef_full[0] + X @ coef_full[1:] @@ -1340,14 +1345,14 @@ def _precompute_exact_l2_inference_cupy( } return - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": cov_params = scale * (bread_inv @ xtx_full @ bread_inv) distribution, method = "t", "classical" else: from statgpu.linear_model._gaussian_inference import robust_covariance_gpu cov_params = robust_covariance_gpu( - X_design_gpu, resid, bread_inv, self.cov_type, cp, - hac_maxlags=self.hac_maxlags, + X_design_gpu, resid, bread_inv, self._cov_type, cp, + hac_maxlags=self._hac_maxlags, ) distribution, method = "normal", "sandwich" @@ -1365,7 +1370,7 @@ def _precompute_exact_l2_inference_cupy( from statgpu.inference._results import GaussianInferenceResult result = GaussianInferenceResult( params=coef_full.get(), bse=bse.get(), statistic=tvalues.get(), - pvalues=pvalues.get(), conf_int=conf_int.get(), cov_type=self.cov_type, + pvalues=pvalues.get(), conf_int=conf_int.get(), cov_type=self._cov_type, distribution=distribution, df=df_resid, method=method, metadata={"ridge_alpha": ridge_alpha, "alpha": 0.05}, ) @@ -1410,7 +1415,9 @@ def _precompute_exact_l2_inference_torch( try: chol = torch.linalg.cholesky(bread) bread_inv = torch.cholesky_inverse(chol) - except RuntimeError: + except RuntimeError as exc: + if not _linalg_exception_is_rank_failure(exc): + raise bread_inv = torch.linalg.pinv(bread) y_pred = X @ coef_full if X_mean is None else coef_full[0] + X @ coef_full[1:] @@ -1438,14 +1445,14 @@ def _precompute_exact_l2_inference_torch( } return - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": cov_params = scale * (bread_inv @ xtx_full @ bread_inv) distribution, method = "t", "classical" else: from statgpu.linear_model._gaussian_inference import robust_covariance_gpu cov_params = robust_covariance_gpu( - X_design_gpu, resid, bread_inv, self.cov_type, torch, - hac_maxlags=self.hac_maxlags, + X_design_gpu, resid, bread_inv, self._cov_type, torch, + hac_maxlags=self._hac_maxlags, ) distribution, method = "normal", "sandwich" @@ -1466,7 +1473,7 @@ def _precompute_exact_l2_inference_torch( params=coef_full.detach().cpu().numpy(), bse=bse.detach().cpu().numpy(), statistic=tvalues.detach().cpu().numpy(), pvalues=pvalues.detach().cpu().numpy(), conf_int=conf_int.detach().cpu().numpy(), - cov_type=self.cov_type, distribution=distribution, df=df_resid, method=method, + cov_type=self._cov_type, distribution=distribution, df=df_resid, method=method, metadata={"ridge_alpha": ridge_alpha, "alpha": 0.05}, ) result.apply_to(self) diff --git a/statgpu/linear_model/penalized/_penalized_cox.py b/statgpu/linear_model/penalized/_penalized_cox.py index 37da42692..da8fadb6e 100644 --- a/statgpu/linear_model/penalized/_penalized_cox.py +++ b/statgpu/linear_model/penalized/_penalized_cox.py @@ -188,14 +188,14 @@ def _resolve_loss(self): """Resolve Cox loss while keeping public ``loss_kwargs`` clone-safe.""" from statgpu.losses import get_loss - kwargs = dict(self.loss_kwargs) + kwargs = dict(self._loss_kwargs) if "ties" in kwargs: supplied = str(kwargs["ties"]).lower() - if supplied != str(self.ties).lower(): + if supplied != str(self._ties).lower(): raise ValueError( "ties and loss_kwargs['ties'] specify different tie methods" ) - kwargs["ties"] = str(self.ties).lower() + kwargs["ties"] = str(self._ties).lower() return get_loss("cox_ph", **kwargs) @property @@ -211,7 +211,7 @@ def _validate_inference_request(self): data. Failing here gives callers a stable public contract instead of allowing a later shape-dependent ``ValueError``. """ - if self.compute_inference: + if self._compute_inference_enabled: raise NotImplementedError( "PenalizedCoxPHModel is currently estimation-only: " "compute_inference=True is not supported for penalized Cox " @@ -264,8 +264,8 @@ def set_params(self, **params): if not np.isfinite(l1_ratio) or not 0 <= l1_ratio <= 1: raise ValueError("l1_ratio must be between 0 and 1") - prospective_ties = str(params.get("ties", self.ties)).lower() - prospective_loss_kwargs = params.get("loss_kwargs", self.loss_kwargs) + prospective_ties = str(params.get("ties", self._ties)).lower() + prospective_loss_kwargs = params.get("loss_kwargs", self._loss_kwargs) if ( prospective_loss_kwargs is not None and "ties" in prospective_loss_kwargs @@ -396,8 +396,8 @@ def _validate_cox_hyperparameters(self): raise ValueError("alpha must be a finite non-negative number") if not np.isfinite(l1_ratio) or not 0 <= l1_ratio <= 1: raise ValueError("l1_ratio must be between 0 and 1") - self._validate_positive_integer(self.max_iter, "max_iter") - self._validate_finite_positive(self.tol, "tol") + self._validate_positive_integer(self._max_iter, "max_iter") + self._validate_finite_positive(self._tol, "tol") self._validate_positive_integer(self.max_lla_iters, "max_lla_iters") self._validate_finite_positive(self.lla_tol, "lla_tol") if self.lipschitz_L is not None: diff --git a/statgpu/linear_model/penalized/_penalized_cv.py b/statgpu/linear_model/penalized/_penalized_cv.py index 4f2107cca..48b56c316 100644 --- a/statgpu/linear_model/penalized/_penalized_cv.py +++ b/statgpu/linear_model/penalized/_penalized_cv.py @@ -27,7 +27,13 @@ from statgpu._config import Device from statgpu.backends import _to_numpy -from statgpu.backends._array_ops import _copy_arr, _zeros, _xp_zeros, _soft_threshold +from statgpu.backends._array_ops import ( + _copy_arr, + _linalg_exception_is_rank_failure, + _soft_threshold, + _xp_zeros, + _zeros, +) from statgpu.backends._utils import _to_float_scalar from statgpu.cross_validation._base import ( CVEstimatorBase, @@ -89,12 +95,154 @@ class ApproximateCVWarning(UserWarning): """Warning emitted when approximate two-stage CV screening is enabled.""" +def _cv_exception_is_infrastructure_failure(exc) -> bool: + """Return whether a CV exception must never be converted to fallback data. + + Candidate-level numerical failures may be represented by ``NaN`` or routed + to a slower implementation. Hardware/runtime failures must remain visible, + otherwise CV can silently continue after CUDA OOM, device mismatch, illegal + memory access, or an indexing/programming error. + """ + if isinstance(exc, MemoryError): + return True + exc_type = type(exc) + type_text = f"{exc_type.__module__}.{exc_type.__name__}".lower() + if "outofmemory" in type_text or "out_of_memory" in type_text: + return True + message = str(exc).lower() + return any( + marker in message + for marker in ( + "out of memory", + "cuda error", + "hip error", + "device-side assert", + "illegal memory access", + "invalid device ordinal", + "expected all tensors to be on the same device", + "device mismatch", + "index out of range", + "cublas", + "cudnn", + "nvrtc", + ) + ) + + +def _raise_cv_infrastructure_failure(exc) -> None: + """Re-raise explicit hardware/runtime failures.""" + if _cv_exception_is_infrastructure_failure(exc): + raise exc + + +def _cv_candidate_failure_is_recoverable(exc) -> bool: + """Return whether one alpha may be marked failed without hiding a bug.""" + return isinstance( + exc, (FloatingPointError, OverflowError, np.linalg.LinAlgError) + ) or _linalg_exception_is_rank_failure(exc) + + +def _cv_path_failure_is_recoverable(exc) -> bool: + """Return whether an optimized path may fall back to a slower path.""" + return isinstance(exc, NotImplementedError) or _cv_candidate_failure_is_recoverable(exc) + + +def _cv_loss_evaluation_failure_is_recoverable(exc) -> bool: + """Return whether validation scoring may try an equivalent evaluator.""" + return isinstance( + exc, + ( + NotImplementedError, + FloatingPointError, + OverflowError, + np.linalg.LinAlgError, + ), + ) or _linalg_exception_is_rank_failure(exc) + + +def _raise_unless_recoverable_cv_loss_failure(exc) -> None: + if not _cv_loss_evaluation_failure_is_recoverable(exc): + raise exc + + +def _raise_unless_recoverable_cv_candidate_failure(exc) -> None: + if not _cv_candidate_failure_is_recoverable(exc): + raise exc + + +def _raise_unless_recoverable_cv_path_failure(exc) -> None: + if not _cv_path_failure_is_recoverable(exc): + raise exc + + +def _weighted_mse_fallback(y_true, y_pred, sample_weight=None) -> float: + """Squared-error-only emergency evaluator preserving validation weights.""" + residual_sq = (np.asarray(y_true, dtype=np.float64).ravel() - + np.asarray(y_pred, dtype=np.float64).ravel()) ** 2 + if sample_weight is None: + return float(np.mean(residual_sq)) + weights = np.asarray(_to_numpy(sample_weight), dtype=np.float64).ravel() + if weights.shape != residual_sq.shape: + raise ValueError("validation sample_weight must match validation rows") + if not np.all(np.isfinite(weights)) or np.any(weights < 0): + raise ValueError("validation sample_weight must be finite and non-negative") + total = float(np.sum(weights)) + if not np.isfinite(total) or total <= 0.0: + raise ValueError("validation sample_weight must have a finite positive sum") + return float(np.dot(weights, residual_sq) / total) + + +def _is_squared_error_loss_name(loss_name) -> bool: + return str(loss_name).lower() in ("squared_error", "gaussian", "normal") + + +def _cv_lipschitz_failure_is_recoverable(exc) -> bool: + """Return whether an optional Lipschitz hint may defer to the solver.""" + return isinstance( + exc, + (NotImplementedError, FloatingPointError, OverflowError, np.linalg.LinAlgError), + ) or _linalg_exception_is_rank_failure(exc) + + +def _cv_alpha_grid_failure_is_recoverable(exc) -> bool: + """Allow default alpha fallback only for genuine numerical failures.""" + return isinstance( + exc, + (FloatingPointError, OverflowError, np.linalg.LinAlgError), + ) or _linalg_exception_is_rank_failure(exc) + + def _is_uniform_weight(sample_weight) -> bool: - """Check if sample_weight is uniform (all elements equal) or None.""" + """Check uniformity on the current backend and synchronize one boolean.""" if sample_weight is None: return True - sw_np = np.asarray(_to_numpy(sample_weight), dtype=np.float64).ravel() - return not sw_np.size or np.allclose(sw_np, sw_np[0]) + module = type(sample_weight).__module__ + if module.startswith("torch"): + import torch + + values = sample_weight.reshape(-1) + if int(values.numel()) == 0: + return True + uniform = ( + torch.allclose(values, values[0]) + if torch.is_floating_point(values) + else torch.all(values == values[0]) + ) + return bool(uniform.item() if hasattr(uniform, "item") else uniform) + if module.startswith("cupy"): + import cupy as cp + + values = sample_weight.reshape(-1) + if int(values.size) == 0: + return True + uniform = ( + cp.allclose(values, values[0]) + if getattr(values.dtype, "kind", "") == "f" + else cp.all(values == values[0]) + ) + return bool(uniform.item()) + values = np.asarray(sample_weight).reshape(-1) + return not values.size or bool(np.allclose(values, values[0])) def _device_to_name(device): @@ -410,17 +558,16 @@ def _evaluate_loss_numpy(loss_name, loss_fn, X_val_np, y_val_np, coef_np, interc else: X_design = X_val_np coef_with_intercept = coef_np - # Fallback: unweighted loss. Weighted mean cannot be derived from - # unweighted mean, so weights are ignored for unknown loss types. - if sw is not None: - import warnings - warnings.warn( - f"_evaluate_loss_numpy: loss '{loss_name}' not in dispatch table, " - f"falling back to unweighted loss_fn.value(). Sample weights ignored.", - RuntimeWarning, - stacklevel=2, + # Unknown/custom losses use the same public LossBase contract as the + # optimized dispatch table, including analytic validation weights. + return float( + loss_fn.value( + X_design, + y_val_np, + coef_with_intercept, + sample_weight=sw, ) - return float(loss_fn.value(X_design, y_val_np, coef_with_intercept)) + ) def _ridge_eig_batch(X_train_np, y_train_np, X_val_np, y_val_np, alphas_np): @@ -1561,8 +1708,22 @@ def _glm_sparse_cv_path( zero_lip = _zeros(n_features + 1, backend, ref_tensor=X_work) lipschitz_L = float(_to_numpy(loss_fn.lipschitz(X_work, zero_lip, y=yb))) if not np.isfinite(lipschitz_L) or lipschitz_L <= 0.0: + warnings.warn( + "The optional closed-form Lipschitz hint was non-finite or " + "non-positive; the solver will estimate its step size.", + RuntimeWarning, + stacklevel=2, + ) lipschitz_L = None - except Exception: + except Exception as exc: + if not _cv_lipschitz_failure_is_recoverable(exc): + raise + warnings.warn( + f"The optional closed-form Lipschitz hint was unavailable " + f"({exc}); the solver will estimate its step size.", + RuntimeWarning, + stacklevel=2, + ) lipschitz_L = None scores = [] @@ -2064,7 +2225,7 @@ def _reset_cv_fit_state(self): def _solver_for_cv(self, cv_device=None, X=None): """Return the strict internal solver used by the CV loop.""" - solver = str(self.solver).lower() + solver = str(self._solver).lower() if solver != "auto": return solver from statgpu.linear_model.penalized._fit_mixin import _preferred_penalized_glm_solver @@ -2073,7 +2234,7 @@ def _solver_for_cv(self, cv_device=None, X=None): self.loss, getattr(self.penalty, "name", self.penalty), backend_name=_backend_name_for_cv_device( - self.device if cv_device is None else cv_device + self._device if cv_device is None else cv_device ), l1_ratio=self.l1_ratio, cv_mode=True, @@ -2082,16 +2243,16 @@ def _solver_for_cv(self, cv_device=None, X=None): def _effective_cv_device(self, X, penalty_name, n_alphas, *, n_folds=None): """Resolve device for CV-level work; explicit devices are untouched.""" - self.cv_selected_device_ = self.device + self.cv_selected_device_ = self._device self._cv_auto_reason_ = None - if _device_to_name(self.device) != "auto": - return self.device + if _device_to_name(self._device) != "auto": + return self._device n_samples, n_features = X.shape penalty_name = str(penalty_name).lower() loss_name = str(self.loss).lower() nx = int(n_samples) * int(n_features) - fold_count = int(self.cv) if n_folds is None else int(n_folds) + fold_count = int(self._cv) if n_folds is None else int(n_folds) if fold_count < 1: raise ValueError("n_folds must be a positive integer") @@ -2202,6 +2363,8 @@ def _generate_alpha_grid(self, X, y, sample_weight=None): grad = X_np.T @ (sw_np * residual) / normalization alpha_max = float(np.max(np.abs(grad))) except Exception as e: + if not _cv_alpha_grid_failure_is_recoverable(e): + raise warnings.warn( f"Alpha grid estimation failed ({e}), using alpha_max=1.0", RuntimeWarning, @@ -2223,7 +2386,7 @@ def _generate_alpha_grid(self, X, y, sample_weight=None): ) alpha_max = 1.0 - grid = np.geomspace(alpha_max, max(alpha_max * 1e-4, 1e-12), self.n_alphas) + grid = np.geomspace(alpha_max, max(alpha_max * 1e-4, 1e-12), self._n_alphas) return grid def _solve_ridge_fold_batch(self, X_train, y_train, X_val, y_val, alphas): @@ -2265,22 +2428,55 @@ def _evaluate_single(self, model, X_val, y_val, loss_fn=None, X_val_np=None, y_v model.fit_intercept, sample_weight=sample_weight, ) - except Exception: - # Fallback: use loss_fn.value() for correct loss, not raw MSE + except Exception as primary_exc: + _raise_cv_infrastructure_failure(primary_exc) + _raise_unless_recoverable_cv_loss_failure(primary_exc) + warnings.warn( + f"Optimized validation scoring for '{self.loss}' was unavailable " + "or numerically invalid; retrying the same declared objective " + "through the generic loss interface.", + RuntimeWarning, + stacklevel=2, + ) + # Preserve the declared objective by retrying through the generic + # loss interface, including analytic validation weights. try: if model.fit_intercept: X_design = np.column_stack([np.ones(n_val), X_val_np]) - coef_full = np.concatenate([[float(model.intercept_)], _to_numpy(model.coef_).ravel()]) + coef_full = np.concatenate( + [[float(model.intercept_)], _to_numpy(model.coef_).ravel()] + ) else: X_design = X_val_np coef_full = _to_numpy(model.coef_).ravel() - val_loss = float(loss_fn.value(X_design, y_val_np, coef_full)) - except Exception: + val_loss = float( + loss_fn.value( + X_design, + y_val_np, + coef_full, + sample_weight=( + None + if sample_weight is None + else np.asarray(_to_numpy(sample_weight), dtype=np.float64).ravel() + ), + ) + ) + except Exception as fallback_exc: + _raise_cv_infrastructure_failure(fallback_exc) + _raise_unless_recoverable_cv_loss_failure(fallback_exc) + if not _is_squared_error_loss_name(self.loss): + raise RuntimeError( + f"Could not evaluate declared validation loss '{self.loss}'. " + "Refusing to substitute mean squared error because that " + "could select a different regularization parameter." + ) from fallback_exc y_pred_np = _to_numpy(model.predict(X_val_np)).ravel() - val_loss = float(np.mean((y_val_np - y_pred_np) ** 2)) + val_loss = _weighted_mse_fallback( + y_val_np, y_pred_np, sample_weight=sample_weight + ) warnings.warn( - f"_evaluate_single: loss evaluation failed for '{self.loss}', " - f"falling back to MSE. CV scores may be inaccurate for non-Gaussian losses.", + "_evaluate_single: both squared-error evaluators failed; " + "using an equivalent weighted-MSE calculation.", RuntimeWarning, stacklevel=2, ) @@ -2311,9 +2507,9 @@ def _refit_best(self, X, y, best_alpha, sample_weight=None): from statgpu.linear_model.penalized._base import PenalizedGeneralizedLinearModel # Resolve refit device (used by Ridge and general paths) - refit_device = self.device - if _device_to_name(self.device) == "auto": - refit_device = getattr(self, "cv_selected_device_", self.device) or self.device + refit_device = self._device + if _device_to_name(self._device) == "auto": + refit_device = getattr(self, "cv_selected_device_", self._device) or self._device # For Ridge: use eigendecomposition to match CV path exactly. # Supports weighted Ridge via weighted eigensolve (same O(p³) cost). @@ -2325,7 +2521,7 @@ def _refit_best(self, X, y, best_alpha, sample_weight=None): model = PenalizedGeneralizedLinearModel( loss='squared_error', penalty='l2', alpha=best_alpha, device=refit_device, compute_inference=False, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, loss_kwargs=getattr(self, '_loss_kwargs', None), ) return self._populate_refit_model(model, coef, intercept, X, refit_device) @@ -2339,19 +2535,19 @@ def _refit_best(self, X, y, best_alpha, sample_weight=None): if self.loss == "logistic" and penalty_name in ("l1", "elasticnet", "en"): refit_paths.append(lambda: _logistic_sparse_cv_path( X, y, alpha_arr, penalty_name, self.l1_ratio, - _logistic_sparse_effective_max_iter(self.max_iter, refit_device, penalty_name, refit=True), - self.tol, refit_device, sample_weight=sample_weight, + _logistic_sparse_effective_max_iter(self._max_iter, refit_device, penalty_name, refit=True), + self._tol, refit_device, sample_weight=sample_weight, )) if self.loss == "squared_error" and penalty_name in ("l1", "elasticnet", "en"): refit_paths.append(lambda: _squared_error_sparse_cv_path( X, y, alpha_arr, penalty_name, self.l1_ratio, - self.max_iter, self.tol, refit_device, sample_weight=sample_weight, + self._max_iter, self._tol, refit_device, sample_weight=sample_weight, )) cv_solver = self._solver_for_cv(refit_device, X=X) if self._uses_glm_sparse_path(penalty_name, cv_solver): refit_paths.append(lambda: _glm_sparse_cv_path( self.loss, X, y, alpha_arr, penalty_name, self.l1_ratio, - self.max_iter, self.tol, refit_device, + self._max_iter, self._tol, refit_device, return_path=True, solver_name=cv_solver, cv_mode=False, sample_weight=sample_weight, )) @@ -2362,8 +2558,8 @@ def _refit_best(self, X, y, best_alpha, sample_weight=None): model = PenalizedGeneralizedLinearModel( loss=self.loss, penalty=self.penalty, alpha=best_alpha, l1_ratio=self.l1_ratio, device=refit_device, - compute_inference=False, max_iter=self.max_iter, - tol=self.tol, solver=cv_solver, + compute_inference=False, max_iter=self._max_iter, + tol=self._tol, solver=cv_solver, loss_kwargs=getattr(self, '_loss_kwargs', None), penalty_kwargs=getattr(self, '_penalty_kwargs', None), ) @@ -2376,8 +2572,8 @@ def _refit_best(self, X, y, best_alpha, sample_weight=None): model = PenalizedGeneralizedLinearModel( loss=self.loss, penalty=self.penalty, alpha=best_alpha, l1_ratio=self.l1_ratio, device=refit_device, - compute_inference=can_infer, max_iter=self.max_iter, - tol=self.tol, solver=cv_solver, + compute_inference=can_infer, max_iter=self._max_iter, + tol=self._tol, solver=cv_solver, loss_kwargs=getattr(self, '_loss_kwargs', None), penalty_kwargs=getattr(self, '_penalty_kwargs', None), ) @@ -2440,8 +2636,8 @@ def _compute_cv_scores( penalty_name = str(self.penalty).lower() loss_name = str(self.loss).lower() device_name = _device_to_name(cv_device) - max_iter = int(self.max_iter if max_iter is None else max_iter) - tol = self.tol if tol is None else tol + max_iter = int(self._max_iter if max_iter is None else max_iter) + tol = self._tol if tol is None else tol # ── Fast path: Ridge eigendecomposition (CPU only, unweighted) ── _is_gpu_cv_device = device_name in ("cuda", "torch") @@ -2458,6 +2654,7 @@ def _compute_cv_scores( ) all_scores[fold_idx, :] = mse except Exception as e: + _raise_unless_recoverable_cv_path_failure(e) warnings.warn( f"Ridge eig batch failed for fold {fold_idx}: {e}", RuntimeWarning, @@ -2490,6 +2687,7 @@ def _compute_cv_scores( all_scores[:, sort_idx] = path["scores"] return all_scores except Exception as e: + _raise_unless_recoverable_cv_path_failure(e) warnings.warn( f"Fold-batched {loss_name} sparse CV failed on {device_name}; " f"falling back to per-fold path: {e}", @@ -2586,6 +2784,7 @@ def _path_glm_sparse(X_train, y_train, alpha_sorted, penalty_name, l1_ratio, fold_handled = True break except Exception as e: + _raise_unless_recoverable_cv_path_failure(e) warnings.warn( f"{path_fn.__name__} failed for {loss_name}+{penalty_name} " f"fold {fold_idx}: {e}", @@ -2697,7 +2896,8 @@ def _cv_fold_general( # _preserve_cv_cache are still needed for the warm-start fallback. for attr in ("_cv_alpha_path", "_cv_path_results"): if hasattr(model, attr): delattr(model, attr) - except Exception: + except Exception as exc: + _raise_unless_recoverable_cv_path_failure(exc) # Same as path-is-None: keep _cv_cache for warm-start fallback. for attr in ("_cv_alpha_path", "_cv_path_results"): if hasattr(model, attr): delattr(model, attr) @@ -2727,6 +2927,7 @@ def _cv_fold_general( prev_coef = coef_np.copy() prev_intercept = intercept except Exception as exc: + _raise_unless_recoverable_cv_candidate_failure(exc) orig_idx = sort_idx[alpha_idx_sorted] all_scores[fold_idx, orig_idx] = np.nan logger.warning( @@ -2852,19 +3053,19 @@ def _fit_standard(self, X, y, sample_weight=None): else self.cv_splits ) else: - folds = kfold_indices(n_samples, self.cv, self.random_state) + folds = kfold_indices(n_samples, self._cv, self.random_state) cv_device = self._effective_cv_device( X, penalty_name, n_alphas, n_folds=len(folds) ) cv_solver = self._solver_for_cv(cv_device, X=X) - self.cv_strategy_ = self.cv_strategy + self.cv_strategy_ = self._cv_strategy self.cv_selected_device_ = _device_to_name(cv_device) all_scores_stage1 = None mean_scores_stage1 = None refined_mask = np.ones(n_alphas, dtype=bool) - if self.cv_strategy == "two_stage": - if not self.acknowledge_approx: + if self._cv_strategy == "two_stage": + if not self._acknowledge_approx: warnings.warn( "PenalizedGLM_CV(cv_strategy='two_stage') uses relaxed CV " "solves to screen the alpha grid before strict refinement. " @@ -2873,8 +3074,8 @@ def _fit_standard(self, X, y, sample_weight=None): ApproximateCVWarning, stacklevel=2, ) - stage1_max_iter = min(int(self.max_iter), max(50, int(self.max_iter) // 4)) - stage1_tol = max(float(self.tol) * 10.0, 1e-4) + stage1_max_iter = min(int(self._max_iter), max(50, int(self._max_iter) // 4)) + stage1_tol = max(float(self._tol) * 10.0, 1e-4) all_scores_stage1 = self._compute_cv_scores( X, y, @@ -2889,7 +3090,7 @@ def _fit_standard(self, X, y, sample_weight=None): mean_scores_stage1 = _finite_column_mean(all_scores_stage1) refined_mask = _two_stage_candidate_mask( mean_scores_stage1, - refine_top_k=self.refine_top_k, + refine_top_k=self._refine_top_k, ) if self.loss == "squared_error" and penalty_name in ("scad", "mcp"): refined_mask[:] = True @@ -2904,8 +3105,8 @@ def _fit_standard(self, X, y, sample_weight=None): cv_device, folds, sample_weight=sample_weight, - max_iter=self.max_iter, - tol=self.tol, + max_iter=self._max_iter, + tol=self._tol, strict=True, ) all_scores = np.array(all_scores_stage1, copy=True) @@ -2926,8 +3127,8 @@ def _fit_standard(self, X, y, sample_weight=None): cv_device, folds, sample_weight=sample_weight, - max_iter=self.max_iter, - tol=self.tol, + max_iter=self._max_iter, + tol=self._tol, strict=True, ) mean_scores = _finite_column_mean(all_scores) @@ -2966,6 +3167,25 @@ def fit(self, X, y, sample_weight=None): return fit_penalized_cox_cv( self, X, y, sample_weight=sample_weight ) + from statgpu.linear_model.penalized._fit_mixin import _resolve_loss_name + from statgpu.glm_core._validation import ( + validate_glm_design_matrix, + validate_glm_sample_weight, + ) + + validated_X = validate_glm_design_matrix(X) + resolved_loss = _resolve_loss_name( + self.loss, + loss_kwargs=getattr(self, "_loss_kwargs", None), + ) + if hasattr(resolved_loss, "validate_response"): + y = resolved_loss.validate_response(y) + if int(y.shape[0]) != int(validated_X.shape[0]): + raise ValueError("Response length must match the number of X rows.") + if sample_weight is not None: + sample_weight = validate_glm_sample_weight( + sample_weight, validated_X.shape[0] + ) return self._fit_standard(X, y, sample_weight=sample_weight) except Exception: self._reset_cv_fit_state() diff --git a/statgpu/linear_model/penalized/_penalized_linear.py b/statgpu/linear_model/penalized/_penalized_linear.py index e5be658ae..2908735cf 100644 --- a/statgpu/linear_model/penalized/_penalized_linear.py +++ b/statgpu/linear_model/penalized/_penalized_linear.py @@ -165,7 +165,7 @@ def bic(self): def summary(self): if self.coef_ is None: raise RuntimeError("Model has not been fitted yet.") - if not self.compute_inference: + if not self._compute_inference_enabled: raise RuntimeError( "compute_inference=False: summary/inference statistics are not available. " "Re-fit with compute_inference=True to use summary()." @@ -209,7 +209,7 @@ def _fmt(val, spec): print(f"Alpha: {float(self.alpha):>15.4f}") if not is_debiased: - print(f"Covariance Type: {self.cov_type:>15}") + print(f"Covariance Type: {self._cov_type:>15}") print(f"No. Observations: {self._nobs:>15}") print(f"Degrees of Freedom: {self._df_resid:>15}") print(f"R-squared: {_fmt(self.rsquared, '>15.4f')}") diff --git a/statgpu/linear_model/penalized/_penalized_quantile.py b/statgpu/linear_model/penalized/_penalized_quantile.py index 947efc83a..f581449c5 100644 --- a/statgpu/linear_model/penalized/_penalized_quantile.py +++ b/statgpu/linear_model/penalized/_penalized_quantile.py @@ -127,7 +127,7 @@ def score(self, X, y, sample_weight=None): y_pred = self.predict(X, return_cpu=True) y = np.asarray(y) u = y - y_pred - q = self.quantile + q = self._quantile per_sample = np.where(u >= 0, q * u, (q - 1.0) * u) if sample_weight is not None: sw = np.asarray(sample_weight, dtype=np.float64) diff --git a/statgpu/linear_model/penalized/_predict_mixin.py b/statgpu/linear_model/penalized/_predict_mixin.py index d67ab75f9..e5f367753 100644 --- a/statgpu/linear_model/penalized/_predict_mixin.py +++ b/statgpu/linear_model/penalized/_predict_mixin.py @@ -51,7 +51,7 @@ def _prediction_backend_name(self): return "torch" if backend_name == "numpy": return "numpy" - if self.device == Device.AUTO: + if self._device == Device.AUTO: return "numpy" device = self._get_compute_device() if device == Device.CUDA: diff --git a/statgpu/linear_model/wrappers/_elasticnet.py b/statgpu/linear_model/wrappers/_elasticnet.py index 63f1266b8..55c1c8a0b 100644 --- a/statgpu/linear_model/wrappers/_elasticnet.py +++ b/statgpu/linear_model/wrappers/_elasticnet.py @@ -1,10 +1,12 @@ -""" -Elastic Net regression with GPU support. +"""Elastic Net regression with GPU support. -The V9 ElasticNet class is a thin wrapper over PenalizedLinearRegression -with penalty="elasticnet" and solver="exact". +``ElasticNet`` is a thin public wrapper over +:class:`~statgpu.linear_model.penalized.PenalizedLinearRegression` with +``penalty="elasticnet"``. It preserves the shared NumPy/CuPy/Torch solver and +post-fit inference contracts; the public default solver is FISTA. -The legacy standalone implementation has been moved to _elasticnet_legacy.py. +The legacy standalone implementation has been moved to +``_elasticnet_legacy.py``. """ from __future__ import annotations @@ -20,7 +22,32 @@ class ElasticNet(_PenalizedLinearRegression): - """Thin sklearn-style wrapper over ``PenalizedLinearRegression`` with Elastic Net penalty.""" + """Elastic Net regression through the shared penalized-linear engine. + + Parameters + ---------- + alpha : float, default=1.0 + Overall regularization strength. + l1_ratio : float, default=0.5 + Mixing proportion between L1 and L2 penalties. + solver : str, default="fista" + Backend-aware optimization method. + compute_inference : bool, default=False + Whether to compute post-fit coefficient inference. + inference_method : str, default="debiased" + Post-fit inference method. Supported values are inherited from + ``PenalizedLinearRegression``. + cov_type : str, default="nonrobust" + Covariance convention where the selected inference method uses it. + hac_maxlags : int, optional + HAC lag count where supported by the selected inference method. + + Notes + ----- + ``compute_inference=True`` does not alter the penalized fit. Inference is + computed after estimation and is conditional on the chosen regularization + parameters. + """ def __init__( self, @@ -36,6 +63,10 @@ def __init__( cpu_solver: str = "fista", lipschitz_L: Optional[float] = None, gpu_memory_cleanup: bool = False, + compute_inference: bool = False, + inference_method: str = "debiased", + cov_type: str = "nonrobust", + hac_maxlags: Optional[int] = None, ): if alpha < 0: raise ValueError(f"alpha must be non-negative, got {alpha}") @@ -54,6 +85,10 @@ def __init__( lipschitz_L=lipschitz_L, gpu_memory_cleanup=gpu_memory_cleanup, stopping=stopping, + compute_inference=compute_inference, + inference_method=inference_method, + cov_type=cov_type, + hac_maxlags=hac_maxlags, ) def fit(self, X=None, y=None, sample_weight=None, initial_coef=None, **kwargs): diff --git a/statgpu/linear_model/wrappers/_linear.py b/statgpu/linear_model/wrappers/_linear.py index 8242d0a00..e4c902337 100644 --- a/statgpu/linear_model/wrappers/_linear.py +++ b/statgpu/linear_model/wrappers/_linear.py @@ -11,6 +11,7 @@ from statgpu._base import BaseEstimator from statgpu._config import Device +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure from statgpu.backends import _get_torch_device_str from statgpu.inference._results import GaussianInferenceResult from statgpu.linear_model._gaussian_inference import ( @@ -106,7 +107,7 @@ def _clear_inference_result(self): def _cleanup_cuda_memory(self): """Best-effort CuPy memory pool cleanup.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -119,10 +120,10 @@ def _resolve_hac_maxlags(self, n_obs: int) -> int: """Resolve HAC lag count with a Newey-West style default rule.""" if n_obs <= 1: return 0 - if self.hac_maxlags is None: + if self._hac_maxlags is None: maxlags = int(np.floor(4.0 * (n_obs / 100.0) ** (2.0 / 9.0))) else: - maxlags = int(self.hac_maxlags) + maxlags = int(self._hac_maxlags) return max(0, min(maxlags, n_obs - 1)) def _benchmark_hac_numpy_kernel( @@ -245,15 +246,15 @@ def _robust_covariance_numpy(self, X: np.ndarray, resid: np.ndarray, XtX_inv: np n, k = X.shape e = np.asarray(resid, dtype=float).reshape(-1) - if self.cov_type == "hac": + if self._cov_type == "hac": scores = X * e[:, np.newaxis] meat = self._hac_meat_numpy(scores) return XtX_inv @ meat @ XtX_inv - if self.cov_type in ("hc2", "hc3"): + if self._cov_type in ("hc2", "hc3"): leverage = np.einsum("ij,jk,ik->i", X, XtX_inv, X) leverage = np.clip(leverage, 0.0, 1.0 - 1e-12) - if self.cov_type == "hc2": + if self._cov_type == "hc2": e2 = (e ** 2) / (1.0 - leverage) else: e2 = (e ** 2) / ((1.0 - leverage) ** 2) @@ -263,7 +264,7 @@ def _robust_covariance_numpy(self, X: np.ndarray, resid: np.ndarray, XtX_inv: np Xw = X * e2[:, np.newaxis] meat = X.T @ Xw cov_params = XtX_inv @ meat @ XtX_inv - if self.cov_type == "hc1" and self._df_resid is not None and self._df_resid > 0: + if self._cov_type == "hc1" and self._df_resid is not None and self._df_resid > 0: cov_params *= (n / self._df_resid) return cov_params @@ -274,15 +275,15 @@ def _robust_covariance_cupy(self, X, resid, XtX_inv, *, df_resid=None): n, k = X.shape e = resid.reshape(-1) - if self.cov_type == "hac": + if self._cov_type == "hac": scores = X * e[:, cp.newaxis] meat = self._hac_meat_cupy(scores) return XtX_inv @ meat @ XtX_inv - if self.cov_type in ("hc2", "hc3"): + if self._cov_type in ("hc2", "hc3"): leverage = cp.einsum("ij,jk,ik->i", X, XtX_inv, X) leverage = cp.clip(leverage, 0.0, 1.0 - 1e-12) - if self.cov_type == "hc2": + if self._cov_type == "hc2": e2 = cp.square(e) / (1.0 - leverage) else: e2 = cp.square(e) / cp.square(1.0 - leverage) @@ -292,7 +293,7 @@ def _robust_covariance_cupy(self, X, resid, XtX_inv, *, df_resid=None): Xw = X * e2[:, cp.newaxis] meat = X.T @ Xw cov_params = XtX_inv @ meat @ XtX_inv - if self.cov_type == "hc1": + if self._cov_type == "hc1": correction_df = df_resid if df_resid is not None else (n - k) if correction_df > 0: cov_params = cov_params * (n / correction_df) @@ -326,7 +327,7 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): # Formula syntax controls the fitted design without mutating the # public constructor parameter required by sklearn-style cloning. - effective_fit_intercept = bool(self.fit_intercept) + effective_fit_intercept = bool(self._fit_intercept) if formula is not None: if data is None: raise ValueError( @@ -342,22 +343,14 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): self._feature_names = [name for name in formula_column_names if name != "Intercept"] if sample_weight is not None: - from statgpu.backends import _to_numpy - - weights = np.asarray(_to_numpy(sample_weight), dtype=float) - if weights.ndim != 1: - raise ValueError("sample_weight must be one-dimensional") - retained_rows = np.asarray(retained_rows, dtype=np.int64) - if weights.shape[0] == len(data): - sample_weight = weights[retained_rows] - elif weights.shape[0] == len(y_arr): - # Already aligned weights are accepted for programmatic use. - sample_weight = weights - else: - raise ValueError( - "sample_weight must match the original data length or " - "the number of formula rows retained after missing-value filtering" - ) + from statgpu.core.formula import align_formula_sample_weight + + sample_weight = align_formula_sample_weight( + sample_weight, + data_length=len(data), + retained_rows=retained_rows, + retained_length=len(y_arr), + ) if self._formula_has_intercept: intercept_idx = formula_column_names.index("Intercept") @@ -415,12 +408,12 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): # GPU single-output inference is computed in _fit_gpu/_fit_torch(). # Multi-output GPU inference is not implemented yet; do not fall back to # the NumPy inference path when the user selected a GPU backend. - if self.compute_inference and self._is_multi_output and device in (Device.CUDA, Device.TORCH): + if self._compute_inference_enabled and self._is_multi_output and device in (Device.CUDA, Device.TORCH): raise NotImplementedError( "Multi-output LinearRegression inference is not implemented for " f"device='{device.value}'. Set compute_inference=False or use device='cpu'." ) - if self.compute_inference and device == Device.CPU: + if self._compute_inference_enabled and device == Device.CPU: self._compute_inference() self._fitted = True return self @@ -554,7 +547,9 @@ def _fit_gpu(self, X, y, sample_weight=None): tmp = cp.linalg.solve_triangular(L, Xty, lower=True) coef = cp.linalg.solve_triangular(L.T, tmp, lower=False) self.rank_ = n_design_cols - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise lstsq_result = cp.linalg.lstsq(X_design, y, rcond=None) coef = lstsq_result[0] self.rank_ = int(lstsq_result[2]) if len(lstsq_result) > 2 else n_design_cols @@ -586,16 +581,18 @@ def _fit_gpu(self, X, y, sample_weight=None): scale = cp.nan # Compute inference-related statistics only when requested. - if self.compute_inference and not self._is_multi_output: + if self._compute_inference_enabled and not self._is_multi_output: coef_flat = coef.flatten() - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": self._bse_gpu, self._tvalues_gpu, self._pvalues_gpu, self._conf_int_gpu = \ compute_inference_gpu(X_design, resid, scale, df_resid, coef_flat) else: XtX_cov = X_design.T @ X_design try: XtX_inv = cp.linalg.inv(XtX_cov) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise XtX_inv = cp.linalg.pinv(XtX_cov) cov_params = self._robust_covariance_cupy(X_design, resid, XtX_inv, df_resid=df_resid) self._bse_gpu = cp.sqrt(cp.maximum(cp.diag(cov_params), 0.0)) @@ -629,7 +626,7 @@ def _fit_gpu(self, X, y, sample_weight=None): scale_np = float(scale.get()) if not cp.isnan(scale) else np.nan X_design_np = X_design.get() - if self.compute_inference and not self._is_multi_output: + if self._compute_inference_enabled and not self._is_multi_output: # Transfer inference results self._bse = self._bse_gpu.get() self._tvalues = self._tvalues_gpu.get() @@ -666,7 +663,7 @@ def _fit_gpu(self, X, y, sample_weight=None): ) self._df_resid = df_resid self._scale = scale_np - if self.compute_inference and not self._is_multi_output: + if self._compute_inference_enabled and not self._is_multi_output: self._wrap_gaussian_inference_result() # Release large temporary GPU tensors early. @@ -694,7 +691,7 @@ def _fit_gpu(self, X, y, sample_weight=None): def _cleanup_torch_memory(self): """Best-effort Torch memory cleanup.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch @@ -728,16 +725,16 @@ def _robust_covariance_torch(self, X, resid, XtX_inv, device=None, *, df_resid=N if device is None: device = 'cuda' if X.is_cuda else 'cpu' - if self.cov_type == "hac": + if self._cov_type == "hac": # HAC requires temporal ordering - compute score matrix and apply Bartlett kernel scores = X * e[:, None] meat = self._hac_meat_torch(scores) return XtX_inv @ meat @ XtX_inv - if self.cov_type in ("hc2", "hc3"): + if self._cov_type in ("hc2", "hc3"): leverage = torch.einsum("ij,jk,ik->i", X, XtX_inv, X) leverage = torch.clamp(leverage, 0.0, 1.0 - 1e-12) - if self.cov_type == "hc2": + if self._cov_type == "hc2": e2 = torch.square(e) / (1.0 - leverage) else: e2 = torch.square(e) / torch.square(1.0 - leverage) @@ -747,7 +744,7 @@ def _robust_covariance_torch(self, X, resid, XtX_inv, device=None, *, df_resid=N Xw = X * e2[:, None] meat = X.T @ Xw cov_params = XtX_inv @ meat @ XtX_inv - if self.cov_type == "hc1": + if self._cov_type == "hc1": correction_df = df_resid if df_resid is not None else (n - k) if correction_df > 0: cov_params = cov_params * (n / correction_df) @@ -819,7 +816,9 @@ def _fit_torch(self, X, y, sample_weight=None): tmp = torch.linalg.solve_triangular(L, Xty, upper=False) coef = torch.linalg.solve_triangular(L.T, tmp, upper=True) self.rank_ = n_design_cols - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise coef = torch.linalg.lstsq(X_design, y).solution self.rank_ = int(torch.linalg.matrix_rank(X_design).item()) self._effective_rank = self.rank_ @@ -849,16 +848,18 @@ def _fit_torch(self, X, y, sample_weight=None): scale = torch.tensor(float('nan'), dtype=y.dtype, device=torch_device) # Compute inference-related statistics only when requested. - if self.compute_inference and not self._is_multi_output: + if self._compute_inference_enabled and not self._is_multi_output: coef_flat = coef.flatten() - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": self._bse_gpu, self._tvalues_gpu, self._pvalues_gpu, self._conf_int_gpu = \ compute_inference_torch(X_design, resid, scale, df_resid, coef_flat, cov_type="nonrobust", device=torch_device) else: XtX_cov = X_design.T @ X_design try: XtX_inv = torch.linalg.inv(XtX_cov) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise XtX_inv = torch.linalg.pinv(XtX_cov) cov_params = self._robust_covariance_torch(X_design, resid, XtX_inv, device=torch_device, df_resid=df_resid) self._bse_gpu = torch.sqrt(torch.clamp(torch.diag(cov_params), 0.0)) @@ -895,7 +896,7 @@ def _fit_torch(self, X, y, sample_weight=None): scale_np = float(scale_val) if not np.isnan(scale_val) else np.nan X_design_np = X_design.detach().cpu().numpy() - if self.compute_inference and not self._is_multi_output: + if self._compute_inference_enabled and not self._is_multi_output: # Transfer inference results self._bse = self._bse_gpu.detach().cpu().numpy() self._tvalues = self._tvalues_gpu.detach().cpu().numpy() @@ -932,7 +933,7 @@ def _fit_torch(self, X, y, sample_weight=None): ) self._df_resid = df_resid self._scale = scale_np - if self.compute_inference and not self._is_multi_output: + if self._compute_inference_enabled and not self._is_multi_output: self._wrap_gaussian_inference_result() # Release large temporary Torch tensors early. @@ -966,8 +967,8 @@ def _compute_inference(self): self._resid, self._scale, self._df_resid, - self.cov_type, - hac_maxlags=self.hac_maxlags, + self._cov_type, + hac_maxlags=self._hac_maxlags, ) if result is None: self._clear_inference_result() @@ -989,15 +990,15 @@ def _inference_feature_names(self): return [f"x{i+1}" for i in range(n_features)] def _wrap_gaussian_inference_result(self): - method = "classical" if self.cov_type == "nonrobust" else "sandwich" - distribution = "t" if self.cov_type == "nonrobust" else "normal" + method = "classical" if self._cov_type == "nonrobust" else "sandwich" + distribution = "t" if self._cov_type == "nonrobust" else "normal" result = GaussianInferenceResult( params=self._params, bse=self._bse, statistic=self._tvalues, pvalues=self._pvalues, conf_int=self._conf_int, - cov_type=self.cov_type, + cov_type=self._cov_type, distribution=distribution, df=self._df_resid, method=method, @@ -1139,7 +1140,7 @@ def summary(self): if not self._fitted: raise RuntimeError("Model has not been fitted yet.") - if not self.compute_inference: + if not self._compute_inference_enabled: raise RuntimeError( "compute_inference=False: summary/inference statistics are not available. " "Re-fit with compute_inference=True (default)." @@ -1165,7 +1166,7 @@ def summary(self): print("=" * 80) print(" Linear Regression Results") print("=" * 80) - print(f"Covariance Type: {self.cov_type:>15}") + print(f"Covariance Type: {self._cov_type:>15}") print(f"No. Observations: {self._nobs:>15}") print(f"Degrees of Freedom: {self._df_resid:>15}") print(f"R-squared: {self.rsquared:>15.4f}") diff --git a/statgpu/linear_model/wrappers/_logistic.py b/statgpu/linear_model/wrappers/_logistic.py index bb591a0e7..150de508a 100644 --- a/statgpu/linear_model/wrappers/_logistic.py +++ b/statgpu/linear_model/wrappers/_logistic.py @@ -5,15 +5,28 @@ __all__ = ["LogisticRegression"] +from numbers import Integral, Real from typing import Any, Dict, Optional, Union, Tuple +import warnings + import numpy as np from scipy import stats from statgpu._base import BaseEstimator from statgpu._config import Device +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure +from statgpu.glm_core._logistic import LogisticLoss +from statgpu.glm_core._validation import ( + validate_binary_response, + validate_glm_design_matrix, + validate_glm_sample_weight, +) from statgpu.backends import _get_torch_device_str +from statgpu.solvers._convergence import ConvergenceWarning from statgpu.metrics import ( binary_average_precision_score, + binary_classification_table, + binary_confusion_matrix, binary_precision_recall_curve, binary_roc_auc_score, binary_roc_curve, @@ -65,8 +78,8 @@ class LogisticRegression(BaseEstimator): fit_intercept : bool, default=True Whether to calculate the intercept. C : float, default=1.0 - Inverse of regularization strength; must be a positive float. - Smaller values specify stronger regularization. + Inverse of regularization strength. Positive values use L2 + regularization; ``C=0`` preserves the legacy unregularized path. max_iter : int, default=100 Maximum number of iterations for IRLS. tol : float, default=1e-4 @@ -102,16 +115,29 @@ def __init__( super().__init__(device=device, n_jobs=n_jobs) self.fit_intercept = fit_intercept self.C = C + self._C = ( + float(C) + if isinstance(C, Real) and not isinstance(C, (bool, np.bool_)) + else C + ) self.max_iter = max_iter self.tol = tol self.compute_inference = compute_inference + if not isinstance(cov_type, str): + raise ValueError("cov_type must be a string") self.cov_type = cov_type.lower() if self.cov_type not in ("nonrobust", "hc0", "hc1", "hc2", "hc3", "hac"): raise ValueError( "cov_type must be one of: 'nonrobust', 'hc0', 'hc1', 'hc2', 'hc3', 'hac'" ) - if hac_maxlags is not None and int(hac_maxlags) < 0: + if hac_maxlags is not None and ( + isinstance(hac_maxlags, bool) + or not isinstance(hac_maxlags, Integral) + or int(hac_maxlags) < 0 + ): raise ValueError("hac_maxlags must be a non-negative integer or None") + if not isinstance(gpu_memory_cleanup, (bool, np.bool_)): + raise ValueError("gpu_memory_cleanup must be boolean") self.hac_maxlags = None if hac_maxlags is None else int(hac_maxlags) self.gpu_memory_cleanup = bool(gpu_memory_cleanup) self.coef_ = None @@ -132,12 +158,106 @@ def __init__( self._loglik_null = None self._train_pred_cache = None self._train_eval_cache = None + self._sample_weight = None + + def _reset_fit_state(self): + """Clear every published and cached result before a new fit attempt.""" + self._fitted = False + for name in ( + "coef_", + "intercept_", + "n_iter_", + "_X_design", + "_y", + "_nobs", + "_df_resid", + "_params", + "_bse", + "_zvalues", + "_pvalues", + "_conf_int", + "_loglik", + "_loglik_null", + "_train_pred_cache", + "_train_eval_cache", + "_sample_weight", + "_bse_gpu", + "_zvalues_gpu", + "_pvalues_gpu", + "_conf_int_gpu", + "_loglik_gpu", + "_accuracy_gpu", + "_accuracy", + "converged_", + ): + setattr(self, name, None) + + def _validate_fit_controls(self): + """Validate and snapshot public controls for the current fit.""" + if not isinstance(self.fit_intercept, (bool, np.bool_)): + raise ValueError("fit_intercept must be boolean") + if isinstance(self.C, bool) or not isinstance(self.C, Real): + raise ValueError("C must be a finite non-negative real number") + C = float(self.C) + if not np.isfinite(C) or C < 0.0: + raise ValueError("C must be a finite non-negative real number") + if ( + isinstance(self.max_iter, bool) + or not isinstance(self.max_iter, Integral) + or int(self.max_iter) < 1 + ): + raise ValueError("max_iter must be a positive integer") + if isinstance(self.tol, bool) or not isinstance(self.tol, Real): + raise ValueError("tol must be a finite positive real number") + tol = float(self.tol) + if not np.isfinite(tol) or tol <= 0.0: + raise ValueError("tol must be a finite positive real number") + if not isinstance(self.compute_inference, (bool, np.bool_)): + raise ValueError("compute_inference must be boolean") + if not isinstance(self.gpu_memory_cleanup, (bool, np.bool_)): + raise ValueError("gpu_memory_cleanup must be boolean") + if not isinstance(self.cov_type, str): + raise ValueError("cov_type must be a string") + cov_type = self.cov_type.lower() + valid_cov = {"nonrobust", "hc0", "hc1", "hc2", "hc3", "hac"} + if cov_type not in valid_cov: + raise ValueError( + "cov_type must be one of: 'nonrobust', 'hc0', 'hc1', " + "'hc2', 'hc3', 'hac'" + ) + if self.hac_maxlags is not None and ( + isinstance(self.hac_maxlags, bool) + or not isinstance(self.hac_maxlags, Integral) + or int(self.hac_maxlags) < 0 + ): + raise ValueError("hac_maxlags must be a non-negative integer or None") + + self._fit_intercept = bool(self.fit_intercept) + self._C = C + self._max_iter = int(self.max_iter) + self._tol = tol + self._compute_inference_enabled = bool(self.compute_inference) + self._gpu_memory_cleanup = bool(self.gpu_memory_cleanup) + self._cov_type = cov_type + self._hac_maxlags = ( + None if self.hac_maxlags is None else int(self.hac_maxlags) + ) + + def _publish_convergence(self, converged): + self.converged_ = bool(converged) + if not self.converged_: + warnings.warn( + f"LogisticRegression IRLS did not converge within " + f"{self._max_iter} iterations.", + ConvergenceWarning, + stacklevel=3, + ) def _cleanup_cuda_memory(self): """Best-effort CuPy memory pool cleanup.""" self._train_pred_cache = None self._train_eval_cache = None - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -150,10 +270,10 @@ def _resolve_hac_maxlags(self, n_obs: int) -> int: """Resolve HAC lag count with a Newey-West style default rule.""" if n_obs <= 1: return 0 - if self.hac_maxlags is None: + if self._hac_maxlags is None: maxlags = int(np.floor(4.0 * (n_obs / 100.0) ** (2.0 / 9.0))) else: - maxlags = int(self.hac_maxlags) + maxlags = int(self._hac_maxlags) return max(0, min(maxlags, n_obs - 1)) def _hac_meat_numpy(self, scores: np.ndarray) -> np.ndarray: @@ -205,42 +325,73 @@ def fit(self, X, y, sample_weight=None): ------- self : object """ - self._y = self._to_numpy(y).astype(float) - self._train_pred_cache = None - self._train_eval_cache = None + self._reset_fit_state() + try: + self._validate_fit_controls() + self._train_pred_cache = None + self._train_eval_cache = None - # Get backend - support explicit torch backend selection - backend = self._get_backend(backend="auto") - backend_name = backend.name + # Validate shape/domain before backend-specific unpacking. + X_validated = validate_glm_design_matrix(X) - X_arr = self._to_array(X, backend=backend_name) - # Handle dtype conversion based on backend - if backend_name == "torch": - import torch - y_arr = self._to_array(y, backend=backend_name) - if y_arr.dtype != torch.float64: - y_arr = y_arr.to(torch.float64) - elif backend_name == "cupy": - import cupy as cp - y_arr = self._to_array(y, backend=backend_name).astype(cp.float64) - else: - y_arr = self._to_array(y, backend=backend_name).astype(float) + # Get backend - support explicit torch backend selection. + backend = self._get_backend(backend="auto") + backend_name = backend.name - device = self._get_compute_device() + X_arr = self._to_array(X_validated, backend=backend_name) + y_validated = validate_binary_response( + y, X_validated.shape[0], context="LogisticRegression" + ) + self._y = self._to_numpy(y_validated).astype(float) + # Handle dtype conversion based on backend + if backend_name == "torch": + import torch + y_arr = self._to_array(y_validated, backend=backend_name) + if y_arr.dtype != torch.float64: + y_arr = y_arr.to(torch.float64) + elif backend_name == "cupy": + import cupy as cp + y_arr = self._to_array(y_validated, backend=backend_name).astype(cp.float64) + else: + y_arr = self._to_array(y_validated, backend=backend_name).astype(float) - # Route to appropriate backend - if backend_name == "torch": - self._fit_torch(X_arr, y_arr, sample_weight) - elif backend_name == "cupy": - self._fit_gpu(X_arr, y_arr, sample_weight) - else: - self._fit_cpu(X_arr, y_arr, sample_weight) + if sample_weight is None: + sample_weight_arr = None + self._sample_weight = None + else: + sample_weight_arr = self._to_array(sample_weight, backend=backend_name) + sample_weight_arr = validate_glm_sample_weight( + sample_weight_arr, X_arr.shape[0] + ) + # CPU covariance inference consumes a NumPy weight cache. + # CuPy/Torch inference already uses the device-native + # ``sample_weight_arr`` inside the backend fit and must not pay + # for an otherwise unused full device-to-host copy. + self._sample_weight = ( + np.asarray(sample_weight_arr, dtype=np.float64).reshape(-1) + if backend_name == "numpy" + else None + ) + + device = self._get_compute_device() + + # Route to appropriate backend + if backend_name == "torch": + self._fit_torch(X_arr, y_arr, sample_weight_arr) + elif backend_name == "cupy": + self._fit_gpu(X_arr, y_arr, sample_weight_arr) + else: + self._fit_cpu(X_arr, y_arr, sample_weight_arr) - if self.compute_inference and device == Device.CPU: - self._compute_inference() - self._fitted = True - return self + if self._compute_inference_enabled and device == Device.CPU: + self._compute_inference() + self._fitted = True + return self + except Exception: + self._reset_fit_state() + raise + def _fit_cpu(self, X, y, sample_weight=None): """Fit using CPU with IRLS.""" X = np.asarray(X) @@ -250,7 +401,7 @@ def _fit_cpu(self, X, y, sample_weight=None): self._nobs = n_samples # Add intercept if needed - if self.fit_intercept: + if self._fit_intercept: self._X_design = np.column_stack([np.ones(n_samples, dtype=X.dtype), X]) else: self._X_design = X.copy() @@ -259,26 +410,28 @@ def _fit_cpu(self, X, y, sample_weight=None): params = np.zeros(self._X_design.shape[1]) # Regularization parameter (lambda = 1 / (2*C)) - alpha = 1.0 / self.C if self.C > 0 else 0.0 + alpha = 1.0 / self._C if self._C > 0 else 0.0 # IRLS iteration iteration = 0 - for iteration in range(self.max_iter): + converged = False + for iteration in range(self._max_iter): params_old = params.copy() # Predicted probabilities eta = self._X_design @ params p = self._sigmoid(eta) - # Weights for WLS - W = p * (1 - p) - W = np.clip(W, 1e-8, 1 - 1e-8) # Avoid numerical issues - - if sample_weight is not None: - W = W * np.asarray(sample_weight) - - # Working response - z = eta + (y - p) / W + # The IRLS working response uses only the Bernoulli variance. + # Analytic sample weights belong in the WLS weights, not in the + # working-response denominator. + W_base = np.clip(p * (1 - p), 1e-8, 1 - 1e-8) + z = eta + (y - p) / W_base + W = ( + W_base + if sample_weight is None + else W_base * np.asarray(sample_weight, dtype=np.float64) + ) # Weighted least squares # (X'WX + alpha*I) * params = X'Wz @@ -287,7 +440,7 @@ def _fit_cpu(self, X, y, sample_weight=None): # Add L2 regularization (don't regularize intercept) if alpha > 0: reg_diag = np.full(XtWX.shape[0], alpha) - if self.fit_intercept: + if self._fit_intercept: reg_diag[0] = 0.0 # Don't regularize intercept XtWX += np.diag(reg_diag) @@ -299,13 +452,15 @@ def _fit_cpu(self, X, y, sample_weight=None): params = np.linalg.lstsq(XtWX, Xtz, rcond=None)[0] # Check convergence - if np.linalg.norm(params - params_old) < self.tol: + if np.linalg.norm(params - params_old) < self._tol: + converged = True break self.n_iter_ = iteration + 1 + self._publish_convergence(converged) self._params = params - if self.fit_intercept: + if self._fit_intercept: self.intercept_ = float(params[0]) self.coef_ = params[1:] else: @@ -313,7 +468,29 @@ def _fit_cpu(self, X, y, sample_weight=None): self.coef_ = params.copy() # Degrees of freedom - self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0)) + self._df_resid = n_samples - (n_features + (1 if self._fit_intercept else 0)) + + # Likelihood diagnostics are fit outputs, not inference-only state. + eta_diag = self._X_design @ params + loglik_i = -LogisticLoss().per_sample_value(eta_diag, y) + weights_diag = ( + None + if sample_weight is None + else np.asarray(sample_weight, dtype=np.float64).reshape(-1) + ) + self._loglik = float(np.sum( + loglik_i if weights_diag is None else weights_diag * loglik_i + )) + y_mean = ( + float(np.mean(y)) + if weights_diag is None + else float(np.average(y, weights=weights_diag)) + ) + y_mean = float(np.clip(y_mean, 1e-15, 1.0 - 1e-15)) + null_i = y * np.log(y_mean) + (1.0 - y) * np.log(1.0 - y_mean) + self._loglik_null = float(np.sum( + null_i if weights_diag is None else weights_diag * null_i + )) def _fit_gpu(self, X, y, sample_weight=None): """Fit using GPU with IRLS.""" @@ -324,7 +501,7 @@ def _fit_gpu(self, X, y, sample_weight=None): self._nobs = n_samples # Add intercept if needed - if self.fit_intercept: + if self._fit_intercept: X_design = cp.column_stack([cp.ones(n_samples, dtype=X.dtype), X]) else: X_design = X @@ -333,26 +510,26 @@ def _fit_gpu(self, X, y, sample_weight=None): params = cp.zeros(X_design.shape[1]) # Regularization parameter - alpha = 1.0 / self.C if self.C > 0 else 0.0 - + alpha = 1.0 / self._C if self._C > 0 else 0.0 + sw_work = ( + None + if sample_weight is None + else cp.asarray(sample_weight, dtype=cp.float64).reshape(-1) + ) + # IRLS iteration iteration = 0 - for iteration in range(self.max_iter): + converged = False + for iteration in range(self._max_iter): params_old = params.copy() # Predicted probabilities eta = X_design @ params p = 1 / (1 + cp.exp(-cp.clip(eta, -500, 500))) - # Weights for WLS - W = p * (1 - p) - W = cp.clip(W, 1e-8, 1 - 1e-8) - - if sample_weight is not None: - W = W * cp.asarray(sample_weight) - - # Working response - z = eta + (y - p) / W + W_base = cp.clip(p * (1 - p), 1e-8, 1 - 1e-8) + z = eta + (y - p) / W_base + W = W_base if sw_work is None else W_base * sw_work # Weighted least squares XtWX = X_design.T @ (X_design * W[:, cp.newaxis]) @@ -360,7 +537,7 @@ def _fit_gpu(self, X, y, sample_weight=None): # Add L2 regularization if alpha > 0: reg_diag = cp.full(XtWX.shape[0], alpha) - if self.fit_intercept: + if self._fit_intercept: reg_diag[0] = 0.0 XtWX += cp.diag(reg_diag) @@ -368,64 +545,79 @@ def _fit_gpu(self, X, y, sample_weight=None): try: params = cp.linalg.solve(XtWX, Xtz) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise params = cp.linalg.lstsq(XtWX, Xtz)[0] # Check convergence - if cp.linalg.norm(params - params_old) < self.tol: + if bool((cp.linalg.norm(params - params_old) < self._tol).item()): + converged = True break self.n_iter_ = iteration + 1 + self._publish_convergence(converged) - # Compute log-likelihood on GPU + # Reuse the registered stable Bernoulli objective on CuPy. eta = X_design @ params p = 1 / (1 + cp.exp(-cp.clip(eta, -500, 500))) - loglik = cp.sum(y * cp.log(p + 1e-10) + (1 - y) * cp.log(1 - p + 1e-10)) - - # Compute accuracy on GPU + loglik_i = -LogisticLoss().per_sample_value(eta, y) + loglik = cp.sum(loglik_i if sw_work is None else sw_work * loglik_i) + + # Compute accuracy on GPU using the same analytic weights. y_pred = (p > 0.5).astype(cp.int32) - accuracy = cp.mean(y_pred == y) + correct = (y_pred == y).astype(cp.float64) + accuracy = ( + cp.mean(correct) + if sw_work is None + else cp.sum(sw_work * correct) / cp.sum(sw_work) + ) # Store GPU results temporarily self._loglik_gpu = loglik self._accuracy_gpu = accuracy - if self.compute_inference: + if self._compute_inference_enabled: # Bread: inverse Hessian, H = X'WX (+ ridge) - W_inf = p * (1 - p) - W_inf = cp.clip(W_inf, 1e-8, 1 - 1e-8) + W_inf = cp.clip(p * (1 - p), 1e-8, 1 - 1e-8) + if sw_work is not None: + W_inf = W_inf * sw_work H = X_design.T @ (X_design * W_inf[:, cp.newaxis]) if alpha > 0: reg_diag_inf = cp.full(H.shape[0], alpha) - if self.fit_intercept: + if self._fit_intercept: reg_diag_inf[0] = 0.0 H += cp.diag(reg_diag_inf) try: eye = cp.eye(H.shape[0], dtype=H.dtype) bread = cp.linalg.solve(H, eye) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise bread = cp.linalg.pinv(H) - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": cov_params = bread else: resid_score = y - p + if sw_work is not None: + resid_score = resid_score * sw_work scores = X_design * resid_score[:, cp.newaxis] - if self.cov_type == "hac": + if self._cov_type == "hac": meat = self._hac_meat_cupy(scores) else: - if self.cov_type in ("hc2", "hc3"): + if self._cov_type in ("hc2", "hc3"): leverage = W_inf * cp.einsum("ij,jk,ik->i", X_design, bread, X_design) leverage = cp.clip(leverage, 0.0, 1.0 - 1e-12) - if self.cov_type == "hc2": + if self._cov_type == "hc2": scores = scores / cp.sqrt(1.0 - leverage)[:, cp.newaxis] else: scores = scores / (1.0 - leverage)[:, cp.newaxis] meat = scores.T @ scores cov_params = bread @ meat @ bread - if self.cov_type == "hc1": + if self._cov_type == "hc1": n = X_design.shape[0] k = X_design.shape[1] if n > k: @@ -451,20 +643,25 @@ def _fit_gpu(self, X, y, sample_weight=None): self._X_design = X_design_np self._params = params_np - if self.fit_intercept: + if self._fit_intercept: self.intercept_ = float(params_np[0]) self.coef_ = params_np[1:] else: self.intercept_ = 0.0 self.coef_ = params_np.copy() - self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0)) + self._df_resid = n_samples - (n_features + (1 if self._fit_intercept else 0)) self._loglik = float(cp.asnumpy(self._loglik_gpu)) self._accuracy = float(cp.asnumpy(self._accuracy_gpu)) - y_mean = cp.mean(y) + y_mean = ( + cp.mean(y) + if sw_work is None + else cp.sum(sw_work * y) / cp.sum(sw_work) + ) y_mean = cp.clip(y_mean, 1e-15, 1 - 1e-15) + null_i = y * cp.log(y_mean) + (1 - y) * cp.log(1 - y_mean) self._loglik_null = float( - cp.asnumpy(cp.sum(y * cp.log(y_mean) + (1 - y) * cp.log(1 - y_mean))) + cp.asnumpy(cp.sum(null_i if sw_work is None else sw_work * null_i)) ) # Release large temporary GPU tensors early. @@ -506,7 +703,7 @@ def _cleanup_torch_memory(self): """Best-effort Torch CUDA memory cleanup.""" self._train_pred_cache = None self._train_eval_cache = None - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch @@ -538,7 +735,7 @@ def _fit_torch(self, X, y, sample_weight=None): X = X.to(torch.float64) # Add intercept if needed - if self.fit_intercept: + if self._fit_intercept: X_design = torch.cat([torch.ones(n_samples, 1, dtype=torch.float64, device=torch_device), X], dim=1) else: X_design = X @@ -547,32 +744,28 @@ def _fit_torch(self, X, y, sample_weight=None): params = torch.zeros(X_design.shape[1], dtype=torch.float64, device=torch_device) # Regularization parameter (lambda = 1 / (2*C)) - alpha = 1.0 / self.C if self.C > 0 else 0.0 + alpha = 1.0 / self._C if self._C > 0 else 0.0 + sw_work = ( + None + if sample_weight is None + else torch.as_tensor( + sample_weight, dtype=torch.float64, device=torch_device + ).reshape(-1) + ) # IRLS iteration iteration = 0 - for iteration in range(self.max_iter): + converged = False + for iteration in range(self._max_iter): params_old = params.clone() # Predicted probabilities eta = X_design @ params p = 1 / (1 + torch.exp(-torch.clamp(eta, -500, 500))) - # Weights for WLS - W = p * (1 - p) - W = torch.clamp(W, 1e-8, 1 - 1e-8) - - if sample_weight is not None: - if not isinstance(sample_weight, torch.Tensor): - sample_weight_torch = torch.from_numpy(sample_weight).to(torch_device) - else: - sample_weight_torch = sample_weight.to(torch_device) - if sample_weight_torch.dtype != torch.float64: - sample_weight_torch = sample_weight_torch.to(torch.float64) - W = W * sample_weight_torch - - # Working response - z = eta + (y - p) / W + W_base = torch.clamp(p * (1 - p), 1e-8, 1 - 1e-8) + z = eta + (y - p) / W_base + W = W_base if sw_work is None else W_base * sw_work # Weighted least squares XtWX = X_design.T @ (X_design * W[:, None]) @@ -580,7 +773,7 @@ def _fit_torch(self, X, y, sample_weight=None): # Add L2 regularization if alpha > 0: reg_diag = torch.full((XtWX.shape[0],), alpha, dtype=torch.float64, device=torch_device) - if self.fit_intercept: + if self._fit_intercept: reg_diag[0] = 0.0 XtWX += torch.diag(reg_diag) @@ -588,65 +781,80 @@ def _fit_torch(self, X, y, sample_weight=None): try: params = torch.linalg.solve(XtWX, Xtz) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise params = torch.linalg.lstsq(XtWX, Xtz)[0] # Check convergence - if torch.linalg.norm(params - params_old) < self.tol: + if bool((torch.linalg.norm(params - params_old) < self._tol).item()): + converged = True break self.n_iter_ = iteration + 1 + self._publish_convergence(converged) - # Compute log-likelihood on GPU + # Reuse the registered stable Bernoulli objective on Torch. eta = X_design @ params p = 1 / (1 + torch.exp(-torch.clamp(eta, -500, 500))) - loglik = torch.sum(y * torch.log(p + 1e-10) + (1 - y) * torch.log(1 - p + 1e-10)) + loglik_i = -LogisticLoss().per_sample_value(eta, y) + loglik = torch.sum(loglik_i if sw_work is None else sw_work * loglik_i) - # Compute accuracy on GPU + # Compute accuracy using the same analytic weights. y_pred = (p > 0.5).to(torch.int32) y_true = y.to(torch.int32).reshape(y_pred.shape) - accuracy = torch.mean((y_pred == y_true).to(torch.float64)) + correct = (y_pred == y_true).to(torch.float64) + accuracy = ( + torch.mean(correct) + if sw_work is None + else torch.sum(sw_work * correct) / torch.sum(sw_work) + ) # Store GPU results temporarily self._loglik_gpu = loglik self._accuracy_gpu = accuracy - if self.compute_inference: + if self._compute_inference_enabled: # Bread: inverse Hessian, H = X'WX (+ ridge) - W_inf = p * (1 - p) - W_inf = torch.clamp(W_inf, 1e-8, 1 - 1e-8) + W_inf = torch.clamp(p * (1 - p), 1e-8, 1 - 1e-8) + if sw_work is not None: + W_inf = W_inf * sw_work H = X_design.T @ (X_design * W_inf[:, None]) if alpha > 0: reg_diag_inf = torch.full((H.shape[0],), alpha, dtype=torch.float64, device=torch_device) - if self.fit_intercept: + if self._fit_intercept: reg_diag_inf[0] = 0.0 H += torch.diag(reg_diag_inf) try: eye = torch.eye(H.shape[0], dtype=H.dtype, device=torch_device) bread = torch.linalg.solve(H, eye) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise bread = torch.linalg.pinv(H) - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": cov_params = bread else: resid_score = y - p + if sw_work is not None: + resid_score = resid_score * sw_work scores = X_design * resid_score[:, None] - if self.cov_type == "hac": + if self._cov_type == "hac": meat = self._hac_meat_torch(scores) else: - if self.cov_type in ("hc2", "hc3"): + if self._cov_type in ("hc2", "hc3"): leverage = W_inf * torch.einsum("ij,jk,ik->i", X_design, bread, X_design) leverage = torch.clamp(leverage, 0.0, 1.0 - 1e-12) - if self.cov_type == "hc2": + if self._cov_type == "hc2": scores = scores / torch.sqrt(1.0 - leverage)[:, None] else: scores = scores / (1.0 - leverage)[:, None] meat = scores.T @ scores cov_params = bread @ meat @ bread - if self.cov_type == "hc1": + if self._cov_type == "hc1": n = X_design.shape[0] k = X_design.shape[1] if n > k: @@ -672,19 +880,28 @@ def _fit_torch(self, X, y, sample_weight=None): self._X_design = X_design_np self._params = params_np - if self.fit_intercept: + if self._fit_intercept: self.intercept_ = float(params_np[0]) self.coef_ = params_np[1:] else: self.intercept_ = 0.0 self.coef_ = params_np.copy() - self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0)) + self._df_resid = n_samples - (n_features + (1 if self._fit_intercept else 0)) self._loglik = float(self._loglik_gpu.cpu().numpy()) self._accuracy = float(self._accuracy_gpu.cpu().numpy()) - y_mean = torch.mean(y) + y_mean = ( + torch.mean(y) + if sw_work is None + else torch.sum(sw_work * y) / torch.sum(sw_work) + ) y_mean = torch.clamp(y_mean, 1e-15, 1 - 1e-15) - self._loglik_null = float(torch.sum(y * torch.log(y_mean) + (1 - y) * torch.log(1 - y_mean)).cpu().numpy()) + null_i = y * torch.log(y_mean) + (1 - y) * torch.log(1 - y_mean) + self._loglik_null = float( + torch.sum(null_i if sw_work is None else sw_work * null_i) + .cpu() + .numpy() + ) # Release large temporary GPU tensors early. try: @@ -745,17 +962,18 @@ def _compute_inference(self): eta = self._X_design @ self._params p = self._sigmoid(eta) - # Compute Hessian (information matrix) - W = p * (1 - p) - W = np.clip(W, 1e-8, 1 - 1e-8) - + # Compute Hessian (information matrix) with analytic weights. + W = np.clip(p * (1 - p), 1e-8, 1 - 1e-8) + if self._sample_weight is not None: + W = W * self._sample_weight + XtWX = self._X_design.T @ (self._X_design * W[:, np.newaxis]) # Add regularization to Hessian - alpha = 1.0 / self.C if self.C > 0 else 0.0 + alpha = 1.0 / self._C if self._C > 0 else 0.0 if alpha > 0: reg_diag = np.full(XtWX.shape[0], alpha) - if self.fit_intercept: + if self._fit_intercept: reg_diag[0] = 0.0 XtWX += np.diag(reg_diag) @@ -764,26 +982,28 @@ def _compute_inference(self): except np.linalg.LinAlgError: bread = np.linalg.pinv(XtWX) - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": cov_params = bread else: resid_score = self._y - p + if self._sample_weight is not None: + resid_score = resid_score * self._sample_weight scores = self._X_design * resid_score[:, np.newaxis] - if self.cov_type == "hac": + if self._cov_type == "hac": meat = self._hac_meat_numpy(scores) else: - if self.cov_type in ("hc2", "hc3"): + if self._cov_type in ("hc2", "hc3"): leverage = W * np.einsum("ij,jk,ik->i", self._X_design, bread, self._X_design) leverage = np.clip(leverage, 0.0, 1.0 - 1e-12) - if self.cov_type == "hc2": + if self._cov_type == "hc2": scores = scores / np.sqrt(1.0 - leverage)[:, np.newaxis] else: scores = scores / (1.0 - leverage)[:, np.newaxis] meat = scores.T @ scores cov_params = bread @ meat @ bread - if self.cov_type == "hc1": + if self._cov_type == "hc1": n = self._X_design.shape[0] k = self._X_design.shape[1] if n > k: @@ -806,69 +1026,74 @@ def _compute_inference(self): self._params + z_crit * self._bse ]) - # Log-likelihood - eps = 1e-15 # Avoid log(0) - p_clipped = np.clip(p, eps, 1 - eps) - self._loglik = np.sum(self._y * np.log(p_clipped) + (1 - self._y) * np.log(1 - p_clipped)) - - # Null log-likelihood (intercept-only model) - y_mean = np.mean(self._y) - y_mean = np.clip(y_mean, eps, 1 - eps) - self._loglik_null = np.sum(self._y * np.log(y_mean) + (1 - self._y) * np.log(1 - y_mean)) + # Likelihood diagnostics are computed once during fitting from the + # registered stable LogisticLoss objective. Inference must not overwrite + # those public fit outputs with a different numerical approximation. def _train_classification_table(self): - """Training-set classification table on current device. + """Return and cache hard-label training metrics on the active backend. - Results are cached in ``_train_eval_cache`` so that multiple - properties (accuracy, precision, recall, f1, auc, average_precision) - sharing the same training data only trigger a single forward pass. + Confusion-based metrics are intentionally independent of ROC and + precision-recall curves so they remain available for one-class targets. + Ranking metrics are computed lazily by ``auc`` and + ``average_precision`` because their class-support requirements differ. """ if self._y is None or not self._fitted: return None if self._train_eval_cache is not None: - return self._train_eval_cache.get("classification_table") + cached = self._train_eval_cache.get("classification_table") + if cached is not None: + return cached - X_train = self._X_design[:, 1:] if self.fit_intercept else self._X_design + X_train = self._X_design[:, 1:] if self._fit_intercept else self._X_design + y_pred = self.predict(X_train) device = self._get_compute_device() if device == Device.CUDA: cp = _require_cupy("_train_classification_table") - - y_true = cp.asarray(self._to_array(self._y, Device.CUDA)).reshape(-1) - y_score = cp.asarray(self.predict_proba(X_train))[:, 1] - self._train_eval_cache = evaluate_binary_classification( - y_true, - y_score, - threshold=0.5, - include_curves=False, - backend="cupy", + y_true = cp.asarray( + self._to_array(self._y, Device.CUDA) + ).reshape(-1) + table = binary_classification_table( + y_true, y_pred, backend="cupy" ) - return self._train_eval_cache["classification_table"] - if device == Device.TORCH: - import torch - - y_true = self._to_array(self._y, Device.TORCH, backend="torch").reshape(-1) - y_score = self.predict_proba(X_train)[:, 1] - if not isinstance(y_score, torch.Tensor): - y_score = torch.as_tensor(y_score, dtype=torch.float64, device=y_true.device) - self._train_eval_cache = evaluate_binary_classification( - y_true, - y_score, - threshold=0.5, - include_curves=False, - backend="torch", + elif device == Device.TORCH: + y_true = self._to_array( + self._y, Device.TORCH, backend="torch" + ).reshape(-1) + table = binary_classification_table( + y_true, y_pred, backend="torch" + ) + else: + table = binary_classification_table( + self._y, self._to_numpy(y_pred), backend="numpy" ) - return self._train_eval_cache["classification_table"] - y_score = self._to_numpy(self.predict_proba(X_train))[:, 1] - self._train_eval_cache = evaluate_binary_classification( - self._y, - y_score, - threshold=0.5, - include_curves=False, - backend="numpy", - ) - return self._train_eval_cache["classification_table"] + if self._train_eval_cache is None: + self._train_eval_cache = {} + self._train_eval_cache["classification_table"] = table + return table + + @staticmethod + def _validate_threshold(threshold): + """Return a finite binary decision threshold as a Python float.""" + if ( + isinstance(threshold, (bool, np.bool_)) + or not isinstance(threshold, Real) + ): + raise ValueError( + "threshold must be a finite real number in [0, 1]" + ) + threshold = float(threshold) + if ( + not np.isfinite(threshold) + or threshold < 0.0 + or threshold > 1.0 + ): + raise ValueError( + "threshold must be a finite real number in [0, 1]" + ) + return threshold @staticmethod def _to_python_float(value): @@ -948,9 +1173,11 @@ def predict(self, X): Predicted class labels. """ proba = self.predict_proba(X) - if hasattr(proba, 'is_floating_point'): # torch tensor - return (proba[:, 1] >= 0.5).to(dtype=proba.dtype) - return (proba[:, 1] >= 0.5).astype(int) + if type(proba).__module__.startswith("torch"): + import torch + + return (proba[:, 1] >= 0.5).to(dtype=torch.int64) + return (proba[:, 1] >= 0.5).astype(np.int64) def predict_with_threshold(self, X, threshold: float = 0.5): """ @@ -968,12 +1195,13 @@ def predict_with_threshold(self, X, threshold: float = 0.5): ndarray of shape (n_samples,) Predicted class labels. """ - if threshold < 0.0 or threshold > 1.0: - raise ValueError("threshold must be in [0, 1]") + threshold = self._validate_threshold(threshold) proba = self.predict_proba(X) - if hasattr(proba, "to") and hasattr(proba, "dtype"): - return (proba[:, 1] >= threshold).to(dtype=proba.dtype) - return (proba[:, 1] >= threshold).astype(int) + if type(proba).__module__.startswith("torch"): + import torch + + return (proba[:, 1] >= threshold).to(dtype=torch.int64) + return (proba[:, 1] >= threshold).astype(np.int64) def score(self, X, y): """ @@ -991,97 +1219,80 @@ def score(self, X, y): float Mean accuracy. """ - y_pred = self.predict(X) + y_pred = self.predict(X).reshape(-1) + y_validated = validate_binary_response( + y, + int(y_pred.shape[0]), + context="LogisticRegression.score", + ) device = self._get_compute_device() if device == Device.CUDA: import cupy as cp - yb = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1) - return float(cp.mean(y_pred.reshape(-1) == yb).item()) + yb = cp.asarray( + self._to_array(y_validated, Device.CUDA) + ).reshape(-1) + return float(cp.mean(y_pred == yb).item()) if device == Device.TORCH: import torch - yb = self._to_array(y, Device.TORCH, backend="torch").reshape(-1) - return float(torch.mean((y_pred.reshape(-1) == yb).to(torch.float64)).item()) - y_pred = self._to_numpy(y_pred) - y = self._to_numpy(y) - return np.mean(y_pred == y) + yb = self._to_array( + y_validated, Device.TORCH, backend="torch" + ).reshape(-1) + return float( + torch.mean((y_pred == yb).to(torch.float64)).item() + ) + y_pred_np = np.asarray(self._to_numpy(y_pred)).reshape(-1) + y_np = np.asarray(self._to_numpy(y_validated)).reshape(-1) + return float(np.mean(y_pred_np == y_np)) def confusion_matrix(self, X, y, threshold: float = 0.5) -> np.ndarray: """Compute binary confusion matrix on a dataset.""" + threshold = self._validate_threshold(threshold) + y_pred = self.predict_with_threshold(X, threshold=threshold) if self._get_compute_device() == Device.CUDA: cp = _require_cupy("confusion_matrix") y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1) - y_score = cp.asarray(self.predict_proba(X))[:, 1] - out = evaluate_binary_classification( - y_true, - y_score, - threshold=threshold, - include_curves=False, - backend="cupy", + return binary_confusion_matrix( + y_true, y_pred, backend="cupy" ) - return out["confusion_matrix"] if self._get_compute_device() == Device.TORCH: - y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1) - y_score = self.predict_proba(X)[:, 1] - out = evaluate_binary_classification( - y_true, - y_score, - threshold=threshold, - include_curves=False, - backend="torch", + y_true = self._to_array( + y, Device.TORCH, backend="torch" + ).reshape(-1) + return binary_confusion_matrix( + y_true, y_pred, backend="torch" ) - return out["confusion_matrix"] y_true = self._to_numpy(y) - y_score = self._to_numpy(self.predict_proba(X))[:, 1] - out = evaluate_binary_classification( - y_true, - y_score, - threshold=threshold, - include_curves=False, - backend="numpy", + return binary_confusion_matrix( + y_true, y_pred, backend="numpy" ) - return out["confusion_matrix"] def classification_table(self, X, y, threshold: float = 0.5) -> Dict[str, float]: """Return a compact classification table on a dataset.""" + threshold = self._validate_threshold(threshold) + y_pred = self.predict_with_threshold(X, threshold=threshold) if self._get_compute_device() == Device.CUDA: cp = _require_cupy("classification_table") y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1) - y_score = cp.asarray(self.predict_proba(X))[:, 1] - out = evaluate_binary_classification( - y_true, - y_score, - threshold=threshold, - include_curves=False, - backend="cupy", + return binary_classification_table( + y_true, y_pred, backend="cupy" ) - return out["classification_table"] if self._get_compute_device() == Device.TORCH: - y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1) - y_score = self.predict_proba(X)[:, 1] - out = evaluate_binary_classification( - y_true, - y_score, - threshold=threshold, - include_curves=False, - backend="torch", + y_true = self._to_array( + y, Device.TORCH, backend="torch" + ).reshape(-1) + return binary_classification_table( + y_true, y_pred, backend="torch" ) - return out["classification_table"] y_true = self._to_numpy(y) - y_score = self._to_numpy(self.predict_proba(X))[:, 1] - out = evaluate_binary_classification( - y_true, - y_score, - threshold=threshold, - include_curves=False, - backend="numpy", + return binary_classification_table( + y_true, y_pred, backend="numpy" ) - return out["classification_table"] def roc_curve(self, X, y) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Compute ROC curve arrays (fpr, tpr, thresholds).""" @@ -1178,8 +1389,7 @@ def evaluate_classification( A dictionary with batched metrics. On CUDA device, arrays/scalars are GPU-backed (CuPy) except ``threshold``. """ - if threshold < 0.0 or threshold > 1.0: - raise ValueError("threshold must be in [0, 1]") + threshold = self._validate_threshold(threshold) if self._get_compute_device() == Device.CUDA: cp = _require_cupy("evaluate_classification") @@ -1361,28 +1571,36 @@ def auc(self): """ROC-AUC on training data.""" if self._y is None or not self._fitted: return None - # Use cached eval result if available (populated by _train_classification_table) - if self._train_eval_cache is not None: - return self._train_eval_cache.get("roc_auc") - # Trigger cache population via _train_classification_table - self._train_classification_table() - if self._train_eval_cache is not None: - return self._train_eval_cache.get("roc_auc") - return None + if ( + self._train_eval_cache is not None + and "roc_auc" in self._train_eval_cache + ): + return self._train_eval_cache["roc_auc"] + + X_train = self._X_design[:, 1:] if self._fit_intercept else self._X_design + value = self.roc_auc_score(X_train, self._y) + if self._train_eval_cache is None: + self._train_eval_cache = {} + self._train_eval_cache["roc_auc"] = value + return value @property def average_precision(self): """Average precision on training data.""" if self._y is None or not self._fitted: return None - # Use cached eval result if available (populated by _train_classification_table) - if self._train_eval_cache is not None: - return self._train_eval_cache.get("average_precision") - # Trigger cache population via _train_classification_table - self._train_classification_table() - if self._train_eval_cache is not None: - return self._train_eval_cache.get("average_precision") - return None + if ( + self._train_eval_cache is not None + and "average_precision" in self._train_eval_cache + ): + return self._train_eval_cache["average_precision"] + + X_train = self._X_design[:, 1:] if self._fit_intercept else self._X_design + value = self.average_precision_score(X_train, self._y) + if self._train_eval_cache is None: + self._train_eval_cache = {} + self._train_eval_cache["average_precision"] = value + return value def summary(self): """Print summary table similar to statsmodels/R.""" @@ -1396,7 +1614,7 @@ def summary(self): ) # Build feature names - if self.fit_intercept: + if self._fit_intercept: feature_names = ['(Intercept)'] + [f'x{i+1}' for i in range(len(self.coef_))] else: feature_names = [f'x{i+1}' for i in range(len(self.coef_))] @@ -1407,7 +1625,7 @@ def summary(self): print(f"No. Observations: {self._nobs:>15}") print(f"Degrees of Freedom: {self._df_resid:>15}") print(f"Iterations: {self.n_iter_:>15}") - print(f"Covariance Type: {self.cov_type:>15}") + print(f"Covariance Type: {self._cov_type:>15}") print(f"Log-Likelihood: {self.loglikelihood:>15.4f}") print(f"Log-Likelihood (Null): {self.loglikelihood_null:>15.4f}") print(f"Pseudo R-squared: {self.pseudo_rsquared:>15.4f}") @@ -1417,10 +1635,20 @@ def summary(self): print(f"Precision: {self._to_python_float(self.precision):>15.4f}") print(f"Recall: {self._to_python_float(self.recall):>15.4f}") print(f"F1 Score: {self._to_python_float(self.f1):>15.4f}") - auc = self.auc + try: + auc = self.auc + except ValueError as exc: + if "only one class" not in str(exc).lower(): + raise + auc = None auc_display = self._to_python_float(auc) print(f"ROC-AUC: {auc_display:>15.4f}") - ap = self.average_precision + try: + ap = self.average_precision + except ValueError as exc: + if "no positive class" not in str(exc).lower(): + raise + ap = None ap_display = self._to_python_float(ap) print(f"Avg Precision: {ap_display:>15.4f}") print("-" * 80) diff --git a/statgpu/linear_model/wrappers/_quantile.py b/statgpu/linear_model/wrappers/_quantile.py index d25948438..deb702f54 100644 --- a/statgpu/linear_model/wrappers/_quantile.py +++ b/statgpu/linear_model/wrappers/_quantile.py @@ -96,9 +96,9 @@ def fit(self, X, y, sample_weight=None): y_arr = self._to_array(y, backend=backend_name) n, p = X_arr.shape - loss = QuantileLoss(quantile=self.quantile) + loss = QuantileLoss(quantile=self._quantile) - if self.fit_intercept: + if self._fit_intercept: from statgpu.penalties._l2 import L2Penalty from statgpu.backends._utils import _get_xp, xp_ones xp = _get_xp(backend_name) @@ -106,7 +106,7 @@ def fit(self, X, y, sample_weight=None): X_aug = xp.column_stack([X_arr, ones]) pen = L2Penalty(alpha=0.0) params, n_iter = fista_solver(loss, pen, X_aug, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, sample_weight=sample_weight) self.coef_ = np.asarray(_to_numpy(params[:-1])) self.intercept_ = float(_to_numpy(params[-1])) @@ -114,24 +114,24 @@ def fit(self, X, y, sample_weight=None): from statgpu.penalties._l2 import L2Penalty pen = L2Penalty(alpha=0.0) params, n_iter = fista_solver(loss, pen, X_arr, y_arr, - max_iter=self.max_iter, tol=self.tol, + max_iter=self._max_iter, tol=self._tol, sample_weight=sample_weight) self.coef_ = np.asarray(_to_numpy(params)) self.intercept_ = 0.0 self.n_iter_ = n_iter - if self.fit_intercept: + if self._fit_intercept: self._params = np.concatenate([[self.intercept_], self.coef_]) else: self._params = self.coef_.copy() self._selected_backend_name = backend_name self._fitted = True - if self.compute_inference: + if self._compute_inference_enabled: self._compute_inference(X_arr, y_arr, loss, backend_name=backend_name) - if self.gpu_memory_cleanup: + if self._gpu_memory_cleanup: self._cleanup_backend_memory(backend_name) return self @@ -139,12 +139,12 @@ def fit(self, X, y, sample_weight=None): def _compute_inference(self, X, y, loss, backend_name="numpy"): """Dispatch to kernel-based or bootstrap inference.""" _valid = {"kernel", "bootstrap"} - if self.inference_method not in _valid: + if self._inference_method not in _valid: raise ValueError( - f"Unknown inference_method='{self.inference_method}'. " + f"Unknown inference_method='{self._inference_method}'. " f"Valid options: {sorted(_valid)}." ) - if self.inference_method == "bootstrap": + if self._inference_method == "bootstrap": self._compute_inference_bootstrap(X, y) elif backend_name == "numpy": self._compute_inference_kernel(X, y) @@ -205,7 +205,7 @@ def _compute_inference_kernel(self, X, y): _norm = get_distribution("norm", backend="numpy") import numpy as _np - if self.fit_intercept: + if self._fit_intercept: X_design = np.column_stack([np.ones(X.shape[0]), X]) params = np.concatenate([[self.intercept_], self.coef_]) else: @@ -214,7 +214,7 @@ def _compute_inference_kernel(self, X, y): n, k = X_design.shape resid = y - X_design @ params - tau = self.quantile + tau = self._quantile # Bandwidth h = self._get_bandwidth_h(n, tau, self.bandwidth, resid, float(np.std(y))) @@ -284,7 +284,7 @@ def _compute_inference_kernel_gpu(self, X, y): dev = X.device if is_torch else None n = X.shape[0] - if self.fit_intercept: + if self._fit_intercept: ones = xp_ones((n, 1), X.dtype, xp, ref_arr=X) X_design = xp.cat([ones, X], dim=1) if is_torch else xp.column_stack([ones, X]) inter = xp_asarray([self.intercept_], dtype=X.dtype, xp=xp, ref_arr=X) @@ -296,7 +296,7 @@ def _compute_inference_kernel_gpu(self, X, y): k = X_design.shape[1] resid = (y - X_design @ params).ravel() - tau = self.quantile + tau = self._quantile # Bandwidth (scipy operates on CPU scalars only) resid_cpu = np.asarray(_to_numpy(resid)).ravel() @@ -354,9 +354,9 @@ def _compute_bootstrap_batched(self, X, y): backend = _resolve_backend("auto", X) xp = _get_xp(backend) is_torch = (backend == "torch") - n = X.shape[0]; tau = self.quantile; p = X.shape[1] + n = X.shape[0]; tau = self._quantile; p = X.shape[1] - if self.fit_intercept: + if self._fit_intercept: ones = xp_ones((n, 1), X.dtype, xp, ref_arr=X) Xd = xp.cat([ones, X], dim=1) if is_torch else xp.column_stack([ones, X]) inter = xp_asarray([self.intercept_], dtype=X.dtype, xp=xp, ref_arr=X) @@ -370,7 +370,7 @@ def _compute_bootstrap_batched(self, X, y): eta = Xd @ params resid_cpu = np.asarray(_to_numpy((y - eta).ravel())) eta_cpu = np.asarray(_to_numpy(eta)) - B = self.n_bootstrap + B = self._n_bootstrap rng = np.random.default_rng(self.random_state) y_batch = np.array([eta_cpu + resid_cpu[rng.integers(0, n, size=n)] for _ in range(B)]) y_gpu = xp_asarray(y_batch, dtype=X.dtype, xp=xp, ref_arr=X) @@ -394,7 +394,7 @@ def _pinball_grad_kernel(_r, _out): def _pinball_loss_kernel(_r, _out): _out[:] = xp.where(_r > 0, float(tau) * _r, float(tau - 1.0) * _r) - for iteration in range(self.max_iter): + for iteration in range(self._max_iter): # ---- Gradient (all backends) ---- pred_z = Xd @ z r_z = y_gpu.T - pred_z # (n, B) @@ -410,7 +410,7 @@ def _pinball_loss_kernel(_r, _out): grad = Xd.T @ d_eta / n # ---- Convergence check ---- - if float(xp.max(xp.abs(grad))) < self.tol: + if float(xp.max(xp.abs(grad))) < self._tol: break # ---- Backtracking line search ---- @@ -461,7 +461,7 @@ def _compute_inference_bootstrap(self, X, y): works on ``(p, B)`` coefficients for parallel solves and includes its own backtracking line search with Armijo condition. """ - if self.fit_intercept: + if self._fit_intercept: params = np.concatenate([[self.intercept_], self.coef_]) else: params = self.coef_.copy() @@ -485,7 +485,7 @@ def _compute_inference_bootstrap(self, X, y): pvalues=self._pvalues.copy(), conf_int=self._conf_int.copy(), distribution="bootstrap_percentile", metadata={ - "n_bootstrap": self.n_bootstrap, + "n_bootstrap": self._n_bootstrap, "ci_method": "percentile", "pvalue_method": "bootstrap_sign_test", "solver": "batched_pinball_fista", @@ -504,14 +504,14 @@ def predict(self, X): raw = X_arr @ coef + intercept from statgpu.backends import _to_numpy result = np.asarray(_to_numpy(raw)) if backend_name != "numpy" else raw - if self.gpu_memory_cleanup: + if self._gpu_memory_cleanup: self._cleanup_backend_memory(backend_name) return result # ---- GPU memory management ---- def _cleanup_cuda_memory(self): - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -521,7 +521,7 @@ def _cleanup_cuda_memory(self): pass def _cleanup_torch_memory(self): - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch @@ -552,7 +552,7 @@ def summary(self): return f"{self.__class__.__name__}(not fitted)" lines = [ f"{'='*60}", - f" QuantileRegression (τ={self.quantile})", + f" QuantileRegression (τ={self._quantile})", f"{'='*60}", ] if self._inference_result is not None: diff --git a/statgpu/linear_model/wrappers/_ridge.py b/statgpu/linear_model/wrappers/_ridge.py index 47cc93e3e..019bcf779 100644 --- a/statgpu/linear_model/wrappers/_ridge.py +++ b/statgpu/linear_model/wrappers/_ridge.py @@ -67,7 +67,7 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): """ if (formula is not None or self._get_compute_device() != Device.CPU - or self.solver != "exact"): + or self._solver != "exact"): # Fall back to parent for formula, GPU, or non-exact solver return super().fit(X=X, y=y, sample_weight=sample_weight, formula=formula, data=data) @@ -95,7 +95,7 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): if float(np.sum(sw)) <= 0.0: raise ValueError("sample_weight must have a positive sum") - if self.fit_intercept: + if self._fit_intercept: if sw is not None: w_sum = float(sw.sum()) X_wmean = np.average(X_np, axis=0, weights=sw) @@ -113,14 +113,14 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): sw_col = sw[:, None] XtX = (X_np * sw_col).T @ X_np Xty = (X_np * sw_col).T @ y_np - if self.fit_intercept: + if self._fit_intercept: XtX -= w_sum * np.outer(X_wmean, X_wmean) Xty -= w_sum * X_wmean * y_wmean n_eff = w_sum else: n_eff = float(sw.sum()) else: - if self.fit_intercept: + if self._fit_intercept: X_mean = np.mean(X_np, axis=0) y_mean = np.mean(y_np) XtX = X_np.T @ X_np @@ -146,7 +146,7 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): except np.linalg.LinAlgError: coef = np.linalg.lstsq(A, Xty, rcond=None)[0].flatten() - if self.fit_intercept: + if self._fit_intercept: self.intercept_ = float(y_wmean - X_wmean @ coef) self.coef_ = coef self._params = np.concatenate([[self.intercept_], self.coef_]) @@ -159,11 +159,11 @@ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None): self._resid = None self._scale = np.nan self.n_iter_ = 1 - self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0)) + self._df_resid = n_samples - (n_features + (1 if self._fit_intercept else 0)) # Build design matrix and compute residuals only when inference is needed - if self.compute_inference: - if self.fit_intercept: + if self._compute_inference_enabled: + if self._fit_intercept: self._X_design = np.column_stack([np.ones(n_samples, dtype=X_np.dtype), X_np]) else: self._X_design = X_np.copy() diff --git a/statgpu/losses/__init__.py b/statgpu/losses/__init__.py index 556ca66ff..b0cb8f423 100644 --- a/statgpu/losses/__init__.py +++ b/statgpu/losses/__init__.py @@ -23,7 +23,28 @@ from ._huber import HuberLoss from ._bisquare import BisquareLoss from ._fair import FairLoss -from ._cox_ph import CoxPartialLikelihoodLoss + + +def __getattr__(name): + """Load survival losses only when their public symbol is requested. + + ``glm_core._base`` inherits from ``losses._base``. Eagerly importing the + Cox loss while that base module is still initializing enters the + ``survival`` package, which imports model code that depends on GLM losses. + Keeping the Cox export lazy removes that package-initialization cycle while + preserving ``from statgpu.losses import CoxPartialLikelihoodLoss``. + """ + if name == "CoxPartialLikelihoodLoss": + from ._cox_ph import CoxPartialLikelihoodLoss + + globals()[name] = CoxPartialLikelihoodLoss + return CoxPartialLikelihoodLoss + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) + __all__ = [ "LossBase", diff --git a/statgpu/nonparametric/kernel_methods/_krr_cv.py b/statgpu/nonparametric/kernel_methods/_krr_cv.py index 686f805a2..b66eed014 100644 --- a/statgpu/nonparametric/kernel_methods/_krr_cv.py +++ b/statgpu/nonparametric/kernel_methods/_krr_cv.py @@ -197,9 +197,9 @@ def fit(self, X, y): n_samples = X_arr.shape[0] n_targets = y_arr.shape[1] - if isinstance(self.cv, bool) or not isinstance(self.cv, (int, np.integer)): + if isinstance(self._cv, bool) or not isinstance(self._cv, (int, np.integer)): raise ValueError("cv must be an integer") - n_folds = int(self.cv) + n_folds = int(self._cv) if n_folds < 2 or n_folds > n_samples: raise ValueError("cv must satisfy 2 <= cv <= n_samples") @@ -336,7 +336,7 @@ def fit(self, X, y): degree=self.degree, coef0=self.coef0, kernel_params=self.kernel_params, - device=self.device, + device=self._device, ) self.estimator_.fit(X, y) diff --git a/statgpu/nonparametric/kernel_smoothing/_kde.py b/statgpu/nonparametric/kernel_smoothing/_kde.py index 17c4c4193..224cb0fcc 100644 --- a/statgpu/nonparametric/kernel_smoothing/_kde.py +++ b/statgpu/nonparametric/kernel_smoothing/_kde.py @@ -171,7 +171,7 @@ def _require_fitted(self) -> None: raise RuntimeError("Estimator not fitted. Call fit() first.") def _cleanup_cuda_memory(self): - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -181,7 +181,7 @@ def _cleanup_cuda_memory(self): pass def _cleanup_torch_memory(self): - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch diff --git a/statgpu/nonparametric/kernel_smoothing/_kernel_regression.py b/statgpu/nonparametric/kernel_smoothing/_kernel_regression.py index 6f9fa6522..292698d90 100644 --- a/statgpu/nonparametric/kernel_smoothing/_kernel_regression.py +++ b/statgpu/nonparametric/kernel_smoothing/_kernel_regression.py @@ -5,6 +5,7 @@ from typing import Any, Optional, Union import numpy as np +from statgpu.backends._array_ops import _linalg_exception_is_rank_failure from statgpu._base import BaseEstimator from statgpu.backends import ( @@ -186,7 +187,7 @@ def _require_fitted(self) -> None: raise RuntimeError("Model not fitted. Call fit() first.") def _cleanup_cuda_memory(self): - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -196,7 +197,7 @@ def _cleanup_cuda_memory(self): pass def _cleanup_torch_memory(self): - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch @@ -483,7 +484,9 @@ def _evaluate_local_linear( beta0 = beta[:, 0, :] solved = True break - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise A_work = A_work + ridge_work[:, None, None] * eye_p1[None, :, :] ridge_work = ridge_work * 10.0 @@ -528,9 +531,9 @@ def predict( ): self._require_fitted() if batch_size is None: - batch_size = int(self.batch_size) + batch_size = int(self._batch_size) if min_effective_weight is None: - min_effective_weight = float(self.min_effective_weight) + min_effective_weight = float(self._min_effective_weight) xp = _get_xp(self.backend_) points_2d = _as_points_2d(points, self.n_features_, xp, ref_arr=self.samples_) @@ -734,7 +737,9 @@ def _solve_linear_system_with_ridge(A, B, xp): for _ in range(6): try: return xp.linalg.solve(A_work, B) - except Exception: + except Exception as exc: + if not _linalg_exception_is_rank_failure(exc): + raise A_work = A_work + ridge * eye ridge *= 10.0 return None diff --git a/statgpu/panel/_between.py b/statgpu/panel/_between.py index 55752a533..436fafbe2 100644 --- a/statgpu/panel/_between.py +++ b/statgpu/panel/_between.py @@ -145,7 +145,7 @@ def fit(self, X=None, y=None, entity_ids=None, time_ids=None, formula=None, data # Inference _compute_ols_inference( self, X_mean, resid, params, scale, n, k, xp, backend.name, - self.cov_type, self.alpha, dist_df=n - k + self._cov_type, self.alpha, dist_df=n - k ) # R-squared @@ -188,7 +188,7 @@ def summary(self): ) return PanelSummary( model_type="BetweenOLS", - cov_type=self.cov_type, + cov_type=self._cov_type, coef=np.asarray(self.coef_), bse=np.asarray(self.bse_), tvalues=np.asarray(self.tvalues_), @@ -201,18 +201,12 @@ def summary(self): ) def get_params(self, deep=True): - params = super().get_params(deep=deep) - params["cov_type"] = self.cov_type - params["alpha"] = self.alpha - return params + """Return the shared exact-constructor parameter contract.""" + return super().get_params(deep) def set_params(self, **params): - for key in ["cov_type", "alpha"]: - if key in params: - setattr(self, key, params.pop(key)) - if params: - super().set_params(**params) - return self + """Delegate parameter updates to the shared estimator contract.""" + return super().set_params(**params) # Backward-compatible re-export (used by _first_diff.py) diff --git a/statgpu/panel/_fama_macbeth.py b/statgpu/panel/_fama_macbeth.py index a8735b6bb..c175a6131 100644 --- a/statgpu/panel/_fama_macbeth.py +++ b/statgpu/panel/_fama_macbeth.py @@ -62,7 +62,7 @@ def __init__( raise ValueError("cov_type must be 'nonrobust' or 'newey-west'") def _validate_parameters(self): - if self.cov_type not in ("nonrobust", "newey-west"): + if self._cov_type not in ("nonrobust", "newey-west"): raise ValueError("cov_type must be 'nonrobust' or 'newey-west'") if self.bandwidth is not None: if ( @@ -179,7 +179,7 @@ def fit(self, X=None, y=None, time_ids=None, formula=None, data=None): avg_beta = xp.mean(betas, axis=0) beta_centered = betas - avg_beta - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": covariance = (beta_centered.T @ beta_centered) / float(T - 1) cov_params = covariance / float(T) else: @@ -201,7 +201,7 @@ def fit(self, X=None, y=None, time_ids=None, formula=None, data=None): from statgpu.inference._distributions_backend import get_distribution - dist_name = "norm" if self.cov_type == "newey-west" else "t" + dist_name = "norm" if self._cov_type == "newey-west" else "t" distribution = get_distribution(dist_name, backend="numpy") pvalues_py = [] for value in xp.abs(tvalues): @@ -271,7 +271,7 @@ def summary(self): ) return PanelSummary( model_type="FamaMacBeth", - cov_type=self.cov_type, + cov_type=self._cov_type, coef=np.asarray(_to_numpy(self.coef_)), bse=np.asarray(_to_numpy(self.bse_)), tvalues=np.asarray(_to_numpy(self.tvalues_)), @@ -284,19 +284,9 @@ def summary(self): ) def get_params(self, deep=True): - params = super().get_params(deep=deep) - params.update( - cov_type=self.cov_type, - bandwidth=self.bandwidth, - alpha=self.alpha, - min_obs_per_period=self.min_obs_per_period, - ) - return params + """Return the shared exact-constructor parameter contract.""" + return super().get_params(deep) def set_params(self, **params): - for key in ["cov_type", "bandwidth", "alpha", "min_obs_per_period"]: - if key in params: - setattr(self, key, params.pop(key)) - if params: - super().set_params(**params) - return self + """Delegate parameter updates to the shared estimator contract.""" + return super().set_params(**params) diff --git a/statgpu/panel/_first_diff.py b/statgpu/panel/_first_diff.py index 88e6a237f..209d431bf 100644 --- a/statgpu/panel/_first_diff.py +++ b/statgpu/panel/_first_diff.py @@ -131,7 +131,7 @@ def fit(self, X=None, y=None, entity_ids=None, time_ids=None, formula=None, data _compute_ols_inference( self, X_diff, resid, params, scale, n, k, xp, backend.name, - self.cov_type, self.alpha, dist_df=n - k + self._cov_type, self.alpha, dist_df=n - k ) y_bar = xp.mean(y_diff) @@ -168,7 +168,7 @@ def summary(self): ) return PanelSummary( model_type="FirstDifferenceOLS", - cov_type=self.cov_type, + cov_type=self._cov_type, coef=np.asarray(self.coef_), bse=np.asarray(self.bse_), tvalues=np.asarray(self.tvalues_), @@ -181,18 +181,12 @@ def summary(self): ) def get_params(self, deep=True): - params = super().get_params(deep=deep) - params["cov_type"] = self.cov_type - params["alpha"] = self.alpha - return params + """Return the shared exact-constructor parameter contract.""" + return super().get_params(deep) def set_params(self, **params): - for key in ["cov_type", "alpha"]: - if key in params: - setattr(self, key, params.pop(key)) - if params: - super().set_params(**params) - return self + """Delegate parameter updates to the shared estimator contract.""" + return super().set_params(**params) def _first_diff_transform(X, y, entity_ids, time_ids, xp): diff --git a/statgpu/panel/_fixed_effects.py b/statgpu/panel/_fixed_effects.py index 5412d5206..66f39f30f 100644 --- a/statgpu/panel/_fixed_effects.py +++ b/statgpu/panel/_fixed_effects.py @@ -185,7 +185,7 @@ def fit(self, X=None, y=None, entity_ids=None, time_ids=None, cluster=None, raise ValueError("entity_ids is required when entity_effects=True") if self.time_effects and time_ids is None: raise ValueError("time_ids is required when time_effects=True") - if self.cov_type == 'clustered' and cluster is None: + if self._cov_type == 'clustered' and cluster is None: raise ValueError("cluster is required when cov_type='clustered'") entity_arr = None @@ -306,11 +306,11 @@ def _compute_inference(self, xp, cluster, backend_name, except _LINALG_ERRORS: XtX_inv = xp.linalg.pinv(XtX) - if self.cov_type == 'nonrobust': + if self._cov_type == 'nonrobust': cov_params = self._scale * XtX_inv bse_dev = xp.sqrt(xp_maximum(xp.diag(cov_params), 0.0, xp)) - elif self.cov_type == 'robust': + elif self._cov_type == 'robust': # HC1 sandwich — on device # Use df_resid (not n-k) to account for absorbed fixed effects e2 = resid ** 2 @@ -343,7 +343,7 @@ def _compute_inference(self, xp, cluster, backend_name, abs_t = xp.abs(tvalues_dev) # p-values via backend-agnostic inference framework — on device - if self.cov_type in ('nonrobust',): + if self._cov_type in ('nonrobust',): t_dist = get_distribution("t", backend=backend_name) pvalues_dev = 2.0 * t_dist.sf(abs_t, float(df)) t_crit = float(t_dist.isf(xp.asarray([alpha / 2.0]), float(df))[0]) @@ -451,7 +451,7 @@ def summary(self): conf_int=self.conf_int_, feature_names=feat_names, rsquared_within=self.rsquared_within, - cov_type=self.cov_type, + cov_type=self._cov_type, entity_effects=self.entity_effects, time_effects=self.time_effects, alpha=self.alpha, @@ -460,23 +460,12 @@ def summary(self): return s def get_params(self, deep=True): - """Get parameters for this estimator.""" - params = super().get_params(deep) - params.update({ - 'entity_effects': self.entity_effects, - 'time_effects': self.time_effects, - 'cov_type': self.cov_type, - 'alpha': self.alpha, - }) - return params + """Return the shared exact-constructor parameter contract.""" + return super().get_params(deep) def set_params(self, **params): - """Set parameters for this estimator.""" - for key in ('entity_effects', 'time_effects', 'cov_type', 'alpha'): - if key in params: - setattr(self, key, params.pop(key)) - super().set_params(**params) - return self + """Delegate parameter updates to the shared estimator contract.""" + return super().set_params(**params) # Alias for naming consistency with RandomEffects, PooledOLS, etc. diff --git a/statgpu/panel/_pooled.py b/statgpu/panel/_pooled.py index 2dec9bbf2..c427885d8 100644 --- a/statgpu/panel/_pooled.py +++ b/statgpu/panel/_pooled.py @@ -134,7 +134,7 @@ def fit(self, X=None, y=None, cluster=None, time_index=None, formula=None, data= # HAC depends on temporal ordering. Metadata may remain on CPU, but the # numerical arrays are reordered on their selected backend. - if self.cov_type == "hac" and time_index is not None: + if self._cov_type == "hac" and time_index is not None: time_values = np.asarray(_to_numpy(time_index)) if time_values.ndim != 1 or time_values.shape[0] != X_arr.shape[0]: raise ValueError("time_index must be one-dimensional with length n_samples") @@ -221,7 +221,7 @@ def summary(self): ) return PanelSummary( model_type="PooledOLS", - cov_type=self.cov_type, + cov_type=self._cov_type, coef=np.asarray(self.coef_), bse=np.asarray(self.bse_), tvalues=np.asarray(self.tvalues_), @@ -241,21 +241,21 @@ def _compute_inference( XtX = X.T @ X / n XtX_inv = xp.linalg.pinv(XtX) - if self.cov_type == "nonrobust": + if self._cov_type == "nonrobust": cov_params = scale * XtX_inv / n - elif self.cov_type == "robust": + elif self._cov_type == "robust": # HC1: (X'X)^{-1} X' diag(e^2) X (X'X)^{-1} * n/(n-k) scores = X * resid[:, None] meat = scores.T @ scores cov_params = XtX_inv @ meat @ XtX_inv / (n * n) * n / df_resid - elif self.cov_type == "clustered": + elif self._cov_type == "clustered": if cluster is None: raise ValueError("cluster is required for cov_type='clustered'") cluster_arr, _ = factorize_panel_labels( cluster, xp, ref_arr=X, name="cluster", expected_n=n, ) cov_params = clustered_covariance(X, resid, cluster_arr, xp) - elif self.cov_type == "hac": + elif self._cov_type == "hac": cov_params = hac_covariance(X, resid, bandwidth=self.bandwidth, kernel=self.kernel, xp=xp) @@ -265,7 +265,7 @@ def _compute_inference( df = df_resid from statgpu.inference._distributions_backend import get_distribution - dist_name = "norm" if self.cov_type in ("robust", "clustered", "hac") else "t" + dist_name = "norm" if self._cov_type in ("robust", "clustered", "hac") else "t" t_dist = get_distribution(dist_name, backend=backend_name) if dist_name == "t": pvalues_dev = 2 * t_dist.sf(xp.abs(tvalues_dev), df) @@ -287,17 +287,9 @@ def _compute_inference( self.conf_int_ = _to_numpy(xp.stack([conf_low, conf_high], axis=1)) def get_params(self, deep=True): - params = super().get_params(deep=deep) - params["cov_type"] = self.cov_type - params["alpha"] = self.alpha - params["bandwidth"] = self.bandwidth - params["kernel"] = self.kernel - return params + """Return the shared exact-constructor parameter contract.""" + return super().get_params(deep) def set_params(self, **params): - for key in ["cov_type", "alpha", "bandwidth", "kernel"]: - if key in params: - setattr(self, key, params.pop(key)) - if params: - super().set_params(**params) - return self + """Delegate parameter updates to the shared estimator contract.""" + return super().set_params(**params) diff --git a/statgpu/penalties/__init__.py b/statgpu/penalties/__init__.py index 5ef8d5be9..987293194 100644 --- a/statgpu/penalties/__init__.py +++ b/statgpu/penalties/__init__.py @@ -36,15 +36,10 @@ class CustomPenalty(Penalty): def _torch_compile_ok(): - """Check if torch.compile is usable (CUDA capability >= 7.0 required).""" - try: - import torch - if torch.cuda.is_available(): - cap = torch.cuda.get_device_capability() - return cap[0] >= 7 - return True # CPU-only torch can compile - except Exception: - return False + """Compatibility alias for the centralized Torch compile policy.""" + from statgpu.backends._torch_compile import torch_compile_available + + return torch_compile_available() __all__ = [ diff --git a/statgpu/penalties/_adaptive_l1.py b/statgpu/penalties/_adaptive_l1.py index c1b31bdcd..4b4fe8f14 100644 --- a/statgpu/penalties/_adaptive_l1.py +++ b/statgpu/penalties/_adaptive_l1.py @@ -12,6 +12,7 @@ __all__ = ["AdaptiveL1Penalty"] +from statgpu.backends._torch_compile import compile_torch from typing import Optional import numpy as np from statgpu.backends._array_ops import _xp @@ -25,20 +26,12 @@ def _get_adaptive_l1_torch_compiled(): global _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED if _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED is not None: return _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED - from statgpu.penalties import _torch_compile_ok - if not _torch_compile_ok(): - _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED = None - return None - try: - import torch - def _prox(w, thresh_tensor): - return torch.sign(w) * torch.relu(torch.abs(w) - thresh_tensor) - _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED = torch.compile(_prox, dynamic=True, mode='reduce-overhead') - except Exception: - _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED = None + import torch + def _prox(w, thresh_tensor): + return torch.sign(w) * torch.relu(torch.abs(w) - thresh_tensor) + _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED = compile_torch(_prox, dynamic=True, workload="iterative") return _ADAPTIVE_L1_PROXIMAL_TORCH_COMPILED - class AdaptiveL1Penalty(Penalty): """Adaptive L1 penalty (Adaptive Lasso). diff --git a/statgpu/penalties/_group_lasso.py b/statgpu/penalties/_group_lasso.py index 248de1c44..17cb48da5 100644 --- a/statgpu/penalties/_group_lasso.py +++ b/statgpu/penalties/_group_lasso.py @@ -11,6 +11,7 @@ __all__ = ["GroupLassoPenalty", "AdaptiveGroupLassoPenalty"] +from statgpu.backends._torch_compile import compile_torch from typing import Optional, List, Union import numpy as np from statgpu.penalties._base import Penalty @@ -24,25 +25,17 @@ def _get_group_lasso_torch_compiled_equal(): global _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL if _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL is not None: return _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL - from statgpu.penalties import _torch_compile_ok - if not _torch_compile_ok(): - _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL = None - return None - try: - import torch - def _prox(w_mat, sqrt_pg, alpha, step): - thresh = alpha * sqrt_pg * step - norms = torch.linalg.norm(w_mat, dim=1) - scale = torch.clamp(1.0 - thresh / (norms + 1e-12), min=0.0) - return (w_mat * scale[:, None]).reshape(-1) - _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL = torch.compile( - _prox, dynamic=True, mode='reduce-overhead' - ) - except Exception: - _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL = None + import torch + def _prox(w_mat, sqrt_pg, alpha, step): + thresh = alpha * sqrt_pg * step + norms = torch.linalg.norm(w_mat, dim=1) + scale = torch.clamp(1.0 - thresh / (norms + 1e-12), min=0.0) + return (w_mat * scale[:, None]).reshape(-1) + _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL = compile_torch( + _prox, dynamic=True, workload="iterative" + ) return _GROUP_LASSO_PROXIMAL_TORCH_COMPILED_EQUAL - def _vector_norm(x, xp, dim=None): """Backend-aware L2 norm along a dimension.""" if xp.__name__ == "torch": diff --git a/statgpu/penalties/_group_mcp.py b/statgpu/penalties/_group_mcp.py index 9ac071417..e613633b9 100644 --- a/statgpu/penalties/_group_mcp.py +++ b/statgpu/penalties/_group_mcp.py @@ -12,6 +12,7 @@ __all__ = ["GroupMCPPenalty"] +from statgpu.backends._torch_compile import compile_torch from typing import Optional, List, Union import numpy as np from statgpu.penalties._base import Penalty @@ -25,32 +26,24 @@ def _get_group_mcp_torch_compiled(): global _GROUP_MCP_PROXIMAL_TORCH_COMPILED if _GROUP_MCP_PROXIMAL_TORCH_COMPILED is not None: return _GROUP_MCP_PROXIMAL_TORCH_COMPILED - from statgpu.penalties import _torch_compile_ok - if not _torch_compile_ok(): - _GROUP_MCP_PROXIMAL_TORCH_COMPILED = None - return None - try: - import torch - def _prox(w_mat, sqrt_pg, alpha, step, gamma): - t_g = alpha * sqrt_pg * step - gamma_alpha_g = gamma * alpha * sqrt_pg - norms = torch.linalg.norm(w_mat, dim=1) - mask_zero = norms <= t_g - mask_shrink = (norms > t_g) & (norms <= gamma_alpha_g) - denom = norms * (1.0 - step / gamma) - denom = torch.where(mask_shrink, denom, torch.ones_like(denom)) - scale_shrink = (norms - t_g) / denom - scale = torch.where(mask_shrink, scale_shrink, 1.0) - scale = torch.where(mask_zero, 0.0, scale) - return (w_mat * scale[:, None]).reshape(-1) - _GROUP_MCP_PROXIMAL_TORCH_COMPILED = torch.compile( - _prox, dynamic=True, mode='reduce-overhead' - ) - except Exception: - _GROUP_MCP_PROXIMAL_TORCH_COMPILED = None + import torch + def _prox(w_mat, sqrt_pg, alpha, step, gamma): + t_g = alpha * sqrt_pg * step + gamma_alpha_g = gamma * alpha * sqrt_pg + norms = torch.linalg.norm(w_mat, dim=1) + mask_zero = norms <= t_g + mask_shrink = (norms > t_g) & (norms <= gamma_alpha_g) + denom = norms * (1.0 - step / gamma) + denom = torch.where(mask_shrink, denom, torch.ones_like(denom)) + scale_shrink = (norms - t_g) / denom + scale = torch.where(mask_shrink, scale_shrink, 1.0) + scale = torch.where(mask_zero, 0.0, scale) + return (w_mat * scale[:, None]).reshape(-1) + _GROUP_MCP_PROXIMAL_TORCH_COMPILED = compile_torch( + _prox, dynamic=True, workload="iterative" + ) return _GROUP_MCP_PROXIMAL_TORCH_COMPILED - class GroupMCPPenalty(Penalty): """Group MCP penalty. diff --git a/statgpu/penalties/_group_scad.py b/statgpu/penalties/_group_scad.py index 7755f24ee..41f4baf7f 100644 --- a/statgpu/penalties/_group_scad.py +++ b/statgpu/penalties/_group_scad.py @@ -12,6 +12,7 @@ __all__ = ["GroupSCADPenalty"] +from statgpu.backends._torch_compile import compile_torch from typing import Optional, List, Union import numpy as np from statgpu.penalties._base import Penalty @@ -25,35 +26,27 @@ def _get_group_scad_torch_compiled(): global _GROUP_SCAD_PROXIMAL_TORCH_COMPILED if _GROUP_SCAD_PROXIMAL_TORCH_COMPILED is not None: return _GROUP_SCAD_PROXIMAL_TORCH_COMPILED - from statgpu.penalties import _torch_compile_ok - if not _torch_compile_ok(): - _GROUP_SCAD_PROXIMAL_TORCH_COMPILED = None - return None - try: - import torch - def _prox(w_mat, sqrt_pg, alpha, step, a): - alpha_g = alpha * sqrt_pg - t_g = alpha_g * step - a_alpha_g = a * alpha_g - norms = torch.linalg.norm(w_mat, dim=1) - mask_r1 = norms <= alpha_g + t_g - safe_norms = torch.where(norms > 0.0, norms, torch.ones_like(norms)) - scale_r1 = torch.clamp((norms - t_g) / safe_norms, min=0.0) - mask_r2 = (norms > alpha_g + t_g) & (norms <= a_alpha_g) - denom = norms * (a - 1.0 - step) - denom = torch.where(mask_r2, denom, torch.ones_like(denom)) - scale_r2 = ((a - 1.0) * norms - a * t_g) / denom - scale = torch.where(mask_r1, scale_r1, 1.0) - scale = torch.where(mask_r2, scale_r2, scale) - return (w_mat * scale[:, None]).reshape(-1) - _GROUP_SCAD_PROXIMAL_TORCH_COMPILED = torch.compile( - _prox, dynamic=True, mode='reduce-overhead' - ) - except Exception: - _GROUP_SCAD_PROXIMAL_TORCH_COMPILED = None + import torch + def _prox(w_mat, sqrt_pg, alpha, step, a): + alpha_g = alpha * sqrt_pg + t_g = alpha_g * step + a_alpha_g = a * alpha_g + norms = torch.linalg.norm(w_mat, dim=1) + mask_r1 = norms <= alpha_g + t_g + safe_norms = torch.where(norms > 0.0, norms, torch.ones_like(norms)) + scale_r1 = torch.clamp((norms - t_g) / safe_norms, min=0.0) + mask_r2 = (norms > alpha_g + t_g) & (norms <= a_alpha_g) + denom = norms * (a - 1.0 - step) + denom = torch.where(mask_r2, denom, torch.ones_like(denom)) + scale_r2 = ((a - 1.0) * norms - a * t_g) / denom + scale = torch.where(mask_r1, scale_r1, 1.0) + scale = torch.where(mask_r2, scale_r2, scale) + return (w_mat * scale[:, None]).reshape(-1) + _GROUP_SCAD_PROXIMAL_TORCH_COMPILED = compile_torch( + _prox, dynamic=True, workload="iterative" + ) return _GROUP_SCAD_PROXIMAL_TORCH_COMPILED - class GroupSCADPenalty(Penalty): """Group SCAD penalty. diff --git a/statgpu/penalties/_l1.py b/statgpu/penalties/_l1.py index 4a13df54d..582f09bc7 100644 --- a/statgpu/penalties/_l1.py +++ b/statgpu/penalties/_l1.py @@ -7,6 +7,7 @@ __all__ = ["L1Penalty"] +from statgpu.backends._torch_compile import compile_torch from typing import Optional from statgpu.backends._array_ops import _xp import numpy as np @@ -20,20 +21,12 @@ def _get_l1_torch_compiled(): global _L1_PROXIMAL_TORCH_COMPILED if _L1_PROXIMAL_TORCH_COMPILED is not None: return _L1_PROXIMAL_TORCH_COMPILED - from statgpu.penalties import _torch_compile_ok - if not _torch_compile_ok(): - _L1_PROXIMAL_TORCH_COMPILED = None - return None - try: - import torch - def _prox(w, thresh): - return torch.sign(w) * torch.relu(torch.abs(w) - thresh) - _L1_PROXIMAL_TORCH_COMPILED = torch.compile(_prox, mode='reduce-overhead') - except Exception: - _L1_PROXIMAL_TORCH_COMPILED = None + import torch + def _prox(w, thresh): + return torch.sign(w) * torch.relu(torch.abs(w) - thresh) + _L1_PROXIMAL_TORCH_COMPILED = compile_torch(_prox, workload="iterative") return _L1_PROXIMAL_TORCH_COMPILED - class L1Penalty(Penalty): """ L1 penalty: P(w) = α * ||w||₁ diff --git a/statgpu/penalties/_mcp.py b/statgpu/penalties/_mcp.py index 7d612d1e8..b65423956 100644 --- a/statgpu/penalties/_mcp.py +++ b/statgpu/penalties/_mcp.py @@ -14,6 +14,7 @@ __all__ = ["MCPPenalty"] +from statgpu.backends._torch_compile import compile_torch from typing import Optional import numpy as np from statgpu.penalties._base import Penalty @@ -28,33 +29,25 @@ def _get_mcp_torch_compiled(): global _MCP_PROXIMAL_TORCH_COMPILED if _MCP_PROXIMAL_TORCH_COMPILED is not None: return _MCP_PROXIMAL_TORCH_COMPILED - from statgpu.penalties import _torch_compile_ok - if not _torch_compile_ok(): - _MCP_PROXIMAL_TORCH_COMPILED = None - return None - try: - import torch - def _prox(w, step, alpha, gamma): - max_step = 0.9 * gamma - step = torch.clamp(step, max=max_step) - t = alpha * step - abs_w = torch.abs(w) - sign_w = torch.sign(w) - r1 = abs_w <= t - r3 = abs_w > gamma * alpha - r2 = ~(r1 | r3) - result = torch.where(r1, - torch.zeros_like(w), - torch.where(r2, - sign_w * (abs_w - t) / (1.0 - step / gamma), - w)) - return result - _MCP_PROXIMAL_TORCH_COMPILED = torch.compile(_prox, dynamic=True, mode='reduce-overhead') - except Exception: - _MCP_PROXIMAL_TORCH_COMPILED = None + import torch + def _prox(w, step, alpha, gamma): + max_step = 0.9 * gamma + step = torch.clamp(step, max=max_step) + t = alpha * step + abs_w = torch.abs(w) + sign_w = torch.sign(w) + r1 = abs_w <= t + r3 = abs_w > gamma * alpha + r2 = ~(r1 | r3) + result = torch.where(r1, + torch.zeros_like(w), + torch.where(r2, + sign_w * (abs_w - t) / (1.0 - step / gamma), + w)) + return result + _MCP_PROXIMAL_TORCH_COMPILED = compile_torch(_prox, dynamic=True, workload="iterative") return _MCP_PROXIMAL_TORCH_COMPILED - class MCPPenalty(Penalty): """MCP penalty. diff --git a/statgpu/penalties/_scad.py b/statgpu/penalties/_scad.py index 350fe399a..7414d3b45 100644 --- a/statgpu/penalties/_scad.py +++ b/statgpu/penalties/_scad.py @@ -15,6 +15,7 @@ __all__ = ["SCADPenalty"] +from statgpu.backends._torch_compile import compile_torch from typing import Optional import numpy as np from statgpu.penalties._base import Penalty @@ -29,33 +30,25 @@ def _get_scad_torch_compiled(): global _SCAD_PROXIMAL_TORCH_COMPILED if _SCAD_PROXIMAL_TORCH_COMPILED is not None: return _SCAD_PROXIMAL_TORCH_COMPILED - from statgpu.penalties import _torch_compile_ok - if not _torch_compile_ok(): - _SCAD_PROXIMAL_TORCH_COMPILED = None - return None - try: - import torch - def _prox(w, step, alpha, a): - max_step = 0.9 * (a - 1.0) - step = torch.clamp(step, max=max_step) - t = alpha * step - abs_w = torch.abs(w) - sign_w = torch.sign(w) - r1 = abs_w <= alpha + t - r3 = abs_w > a * alpha - r2 = ~(r1 | r3) - result = torch.where(r1, - sign_w * torch.relu(abs_w - t), - torch.where(r2, - sign_w * ((a - 1.0) * abs_w - a * t) / (a - 1.0 - step), - w)) - return result - _SCAD_PROXIMAL_TORCH_COMPILED = torch.compile(_prox, dynamic=True, mode='reduce-overhead') - except Exception: - _SCAD_PROXIMAL_TORCH_COMPILED = None + import torch + def _prox(w, step, alpha, a): + max_step = 0.9 * (a - 1.0) + step = torch.clamp(step, max=max_step) + t = alpha * step + abs_w = torch.abs(w) + sign_w = torch.sign(w) + r1 = abs_w <= alpha + t + r3 = abs_w > a * alpha + r2 = ~(r1 | r3) + result = torch.where(r1, + sign_w * torch.relu(abs_w - t), + torch.where(r2, + sign_w * ((a - 1.0) * abs_w - a * t) / (a - 1.0 - step), + w)) + return result + _SCAD_PROXIMAL_TORCH_COMPILED = compile_torch(_prox, dynamic=True, workload="iterative") return _SCAD_PROXIMAL_TORCH_COMPILED - class SCADPenalty(Penalty): """SCAD penalty. diff --git a/statgpu/solvers/_admm.py b/statgpu/solvers/_admm.py index 5428d5a48..8016aa811 100644 --- a/statgpu/solvers/_admm.py +++ b/statgpu/solvers/_admm.py @@ -26,7 +26,9 @@ from ._convergence import ConvergenceWarning from ._utils import ( _nesterov_momentum, + _runtime_error_is_singular, _validate_uniform_sample_weight, + _as_backend_vector, ) __all__ = ["admm_solver"] @@ -89,24 +91,18 @@ def admm_solver( """ backend = _resolve_backend("auto", X) X_proc, y_proc = loss.preprocess(X, y) + _validate_uniform_sample_weight(sample_weight, X_proc.shape[0], "admm_solver") n_features = X_proc.shape[1] - # Initialize + # Initialize on the preprocessed design backend/device/dtype. if init_coef is not None: - w = ( - _copy_arr(init_coef) - if hasattr(init_coef, "copy") or hasattr(init_coef, "clone") - else np.array(init_coef).copy() - ) + w = _as_backend_vector(init_coef, backend, X_proc) else: - w = _zeros(n_features, backend, ref_tensor=X) + w = _zeros(n_features, backend, ref_tensor=X_proc) z = _copy_arr(w) u = _zeros_like(w) - if sample_weight is not None: - _validate_uniform_sample_weight(sample_weight, X_proc.shape[0], "admm_solver") - def _grad_w(w_vec, z_cur, u_cur): """Gradient of f(w) + (rho/2)||w - z_cur + u_cur||^2 w.r.t. w.""" g = loss.gradient(X_proc, y_proc, w_vec, sample_weight=sample_weight) @@ -139,19 +135,30 @@ def _grad_w(w_vec, z_cur, u_cur): _A_mat = _hess_const + rho * torch.eye(n_features, dtype=_hess_const.dtype, device=_hess_const.device) _L = torch.linalg.cholesky(_A_mat) _cholesky_ok = True - except (np.linalg.LinAlgError, ValueError, RuntimeError): - # Matrix not positive-definite (numerical issues, collinear features) - # Fall back to CG solver below + except np.linalg.LinAlgError: + # A genuinely non-positive-definite system may use the + # iterative fallback below. + _cholesky_ok = False + except RuntimeError as exc: + if not _runtime_error_is_singular(exc): + raise _cholesky_ok = False if not _cholesky_ok: use_cholesky = False - # Precompute -grad_f(0) = Xty/n for squared_error (the constant part) - _zero_coef = _zeros_like(w) - _neg_grad_zero = -loss.gradient(X_proc, y_proc, _zero_coef, sample_weight=sample_weight) # Xty/n - - else: - # Gradient descent step: 1/(L_f + rho) + if use_cholesky: + # Precompute -grad_f(0) = Xty/n for squared_error. + _zero_coef = _zeros_like(w) + _neg_grad_zero = -loss.gradient( + X_proc, + y_proc, + _zero_coef, + sample_weight=sample_weight, + ) + + if not use_cholesky: + # Gradient descent step: 1/(L_f + rho). This must also be + # initialized when a requested Cholesky path legitimately falls back. L_f = loss.lipschitz(X_proc, w, y=y_proc) if L_f <= 0: L_f = 1.0 diff --git a/statgpu/solvers/_fista.py b/statgpu/solvers/_fista.py index c18684103..aef59ad0f 100644 --- a/statgpu/solvers/_fista.py +++ b/statgpu/solvers/_fista.py @@ -92,6 +92,10 @@ def fista_solver( """ backend = _resolve_backend("auto", X) X_proc, y_proc = loss.preprocess(X, y) + # Validate before any weighted Lipschitz or matrix operation so direct + # solver callers receive the public contract error rather than a backend + # broadcast, NaN, or device-mismatch failure. + _validate_sample_weight(sample_weight, X_proc.shape[0]) _is_quadratic = getattr(loss, '_is_quadratic', False) # Momentum control via loss class attributes: # _momentum_beta_cap: if set, cap Nesterov beta at this value @@ -104,9 +108,9 @@ def fista_solver( n_features = X_proc.shape[1] if init_coef is not None: - coef = _as_backend_vector(init_coef, backend, X) + coef = _as_backend_vector(init_coef, backend, X_proc) else: - coef = _zeros(n_features, backend, ref_tensor=X) + coef = _zeros(n_features, backend, ref_tensor=X_proc) y_k = _copy_arr(coef) t_k = 1.0 @@ -129,7 +133,7 @@ def fista_solver( if getattr(loss, '_lipschitz_at_init', False): _lip_coef = _copy_arr(coef) else: - _lip_coef = _zeros(n_features, backend, ref_tensor=X) + _lip_coef = _zeros(n_features, backend, ref_tensor=X_proc) if sample_weight is not None: # Weighted Lipschitz: eigenvalue of X' diag(w) X / sum(w) _xp_mod = _get_xp(backend) @@ -205,7 +209,6 @@ def fista_solver( _conv_interval = 10 _div_interval = 25 _lip_interval = 25 - _validate_sample_weight(sample_weight, X_proc.shape[0]) # Convert sample_weight to backend-native array (prevent CPU/CUDA mismatch) _sw_arr = None diff --git a/statgpu/solvers/_fista_bb.py b/statgpu/solvers/_fista_bb.py index 442b34cbf..e93a07bf9 100644 --- a/statgpu/solvers/_fista_bb.py +++ b/statgpu/solvers/_fista_bb.py @@ -74,6 +74,7 @@ def fista_bb_solver( backend = _resolve_backend("auto", X) _is_gpu = backend in ("torch", "cupy") X_proc, y_proc = loss.preprocess(X, y) + _validate_sample_weight(sample_weight, X_proc.shape[0]) n_features = X_proc.shape[1] _pen_name = _penalty_name(penalty) @@ -103,9 +104,9 @@ def fista_bb_solver( # --- Initialize coefficients --- if init_coef is not None: - coef = _as_backend_vector(init_coef, backend, X) + coef = _as_backend_vector(init_coef, backend, X_proc) else: - coef = _zeros(n_features, backend, ref_tensor=X) + coef = _zeros(n_features, backend, ref_tensor=X_proc) y_k = _copy_arr(coef) t_k = 1.0 @@ -155,7 +156,7 @@ def fista_bb_solver( # Initial Lipschitz at zero (safe for all losses). Computing L at # init_coef can produce enormous values for exp-link families (mu = # exp(X@coef) explodes for warm-start coefs from OLS). - _zero_coef_bb = _zeros(n_features, backend, ref_tensor=X) + _zero_coef_bb = _zeros(n_features, backend, ref_tensor=X_proc) _cached_lipschitz_L = None if lipschitz_L is not None: try: @@ -202,7 +203,6 @@ def fista_bb_solver( step_k = step_L step_max = step_L * step_max_factor step_min = step_L * step_min_factor - _validate_sample_weight(sample_weight, X_proc.shape[0]) # Gradient at initial point for first BB difference grad_old = _call_with_weight(loss.gradient, X_proc, y_proc, coef, sample_weight=_sw_arr) diff --git a/statgpu/solvers/_fista_lla.py b/statgpu/solvers/_fista_lla.py index ade50ed8a..421d39b66 100644 --- a/statgpu/solvers/_fista_lla.py +++ b/statgpu/solvers/_fista_lla.py @@ -7,6 +7,7 @@ __all__ = ["fista_lla_path"] +from statgpu.backends._torch_compile import compile_torch import copy import numpy as np @@ -49,18 +50,16 @@ def _get_sqerr_proximal_torch(): # Fall back to JIT script for older GPUs (P100 = 6.0). _cap = torch.cuda.get_device_capability()[0] if torch.cuda.is_available() else 0 if _cap >= 7: - try: - @torch.compile(mode='reduce-overhead', backend='inductor') - def _fused_update(y_current, grad, step, thresh, coef_old, beta): - w = y_current - step * grad - abs_w = w.abs() - sign_w = w.sign() - coef_new = sign_w * (abs_w - thresh).clamp(min=0.0) - y_k = coef_new + beta * (coef_new - coef_old) - return coef_new, y_k - _SQERR_PROXIMAL_TORCH = _fused_update - except (RuntimeError, TypeError): - pass + def _fused_update(y_current, grad, step, thresh, coef_old, beta): + w = y_current - step * grad + abs_w = w.abs() + sign_w = w.sign() + coef_new = sign_w * (abs_w - thresh).clamp(min=0.0) + y_k = coef_new + beta * (coef_new - coef_old) + return coef_new, y_k + _SQERR_PROXIMAL_TORCH = compile_torch( + _fused_update, workload="iterative", backend="inductor" + ) if _SQERR_PROXIMAL_TORCH is None: def _fused_update_eager(y_current, grad, step, thresh, coef_old, beta): w = y_current - step * grad @@ -127,11 +126,9 @@ def _fused(grad, y_current, step, thresh, coef_old, beta, # Try torch.compile on capable GPUs _cap = torch.cuda.get_device_capability()[0] if torch.cuda.is_available() else 0 if _cap >= 7: - try: - _FUSED_PROXIMAL_CLIP_TORCH = torch.compile( - _fused, mode='reduce-overhead', backend='inductor') - except (RuntimeError, TypeError): - _FUSED_PROXIMAL_CLIP_TORCH = _fused + _FUSED_PROXIMAL_CLIP_TORCH = compile_torch( + _fused, workload="iterative", backend="inductor" + ) else: _FUSED_PROXIMAL_CLIP_TORCH = _fused return _FUSED_PROXIMAL_CLIP_TORCH @@ -487,19 +484,20 @@ def _record_path_alpha(alpha_value): break _record_path_alpha(cont_alpha) else: - # Generic path: fixed-step FISTA for quadratic/no-Hessian losses and - # proximal Newton for genuinely non-quadratic Hessian-equipped losses. - # For losses with Hessian: use Proximal Newton (5-10 iter per LLA step). - # For losses without Hessian: use FISTA (300+ iter per LLA step). + # Generic path: fixed-step FISTA is the correctness-preserving + # default for composite penalties. A proximal-Newton branch is used + # only when a loss explicitly advertises a correct Hessian-metric + # proximal subproblem implementation. # Cox partial likelihood has a Hessian, but the generic composite # proximal-Newton Armijo rule is not reliable for its risk-set # objective and frequently rejects every step. Use the backend-native # FISTA-LLA path for Cox until a Cox-specific proximal Newton line # search is available. _has_hessian = ( - getattr(loss, 'has_hessian', False) + getattr(loss, "has_hessian", False) + and getattr(loss, "_supports_metric_proximal_newton", False) and not _is_quadratic - and getattr(loss, 'name', '') != 'cox_ph' + and getattr(loss, "name", "") != "cox_ph" ) _is_numpy = backend == "numpy" diff --git a/statgpu/solvers/_lbfgs.py b/statgpu/solvers/_lbfgs.py index 40be0dd61..3f6ec08ca 100644 --- a/statgpu/solvers/_lbfgs.py +++ b/statgpu/solvers/_lbfgs.py @@ -32,6 +32,8 @@ _smooth_penalty_gradient, _smooth_penalty_value_dev, _validate_uniform_sample_weight, + _as_backend_vector, + _validate_smooth_penalty, ) @@ -58,7 +60,7 @@ def lbfgs_solver( Loss with ``fused_value_and_gradient(X, y, coef)`` and ``preprocess(X, y)`` methods. penalty : object or None - Smooth penalty (l2, elasticnet, none). + Smooth penalty (l2 or none). X, y : array-like Design matrix and response vector. max_iter : int @@ -79,19 +81,16 @@ def lbfgs_solver( n_iter : int Number of iterations performed. """ + _validate_smooth_penalty(penalty, "lbfgs_solver") backend = _resolve_backend("auto", X) X_proc, y_proc = loss.preprocess(X, y) n_features = X_proc.shape[1] _validate_uniform_sample_weight(sample_weight, X_proc.shape[0], "lbfgs_solver") if init_coef is not None: - params = ( - _copy_arr(init_coef) - if hasattr(init_coef, "copy") or hasattr(init_coef, "clone") - else np.array(init_coef).copy() - ) + params = _as_backend_vector(init_coef, backend, X_proc) else: - params = _zeros(n_features, backend, ref_tensor=X) + params = _zeros(n_features, backend, ref_tensor=X_proc) s_hist = [] y_hist = [] @@ -142,7 +141,7 @@ def lbfgs_solver( break if gdd >= 0: direction = -grad - gdd = -gn # -||grad||^2 + gdd = -gn * gn # grad'(-grad) = -||grad||^2 # Line search -- stays on device old_val_dev, _ = loss.fused_value_and_gradient(X_proc, y_proc, params) diff --git a/statgpu/solvers/_lbfgs_b.py b/statgpu/solvers/_lbfgs_b.py index a709ac04a..06bbd2851 100644 --- a/statgpu/solvers/_lbfgs_b.py +++ b/statgpu/solvers/_lbfgs_b.py @@ -31,6 +31,8 @@ _smooth_penalty_gradient, _smooth_penalty_value_dev, _validate_uniform_sample_weight, + _as_backend_vector, + _validate_smooth_penalty, ) @@ -55,7 +57,7 @@ def lbfgs_b_solver( Loss with ``fused_value_and_gradient(X, y, coef)`` and ``preprocess(X, y)`` methods. penalty : object or None - Smooth penalty (l2, elasticnet, none). + Smooth penalty (l2 or none). X, y : array-like Design matrix and response vector. max_iter : int @@ -80,36 +82,61 @@ def lbfgs_b_solver( n_iter : int Number of iterations performed. """ + _validate_smooth_penalty(penalty, "lbfgs_b_solver") backend = _resolve_backend("auto", X) X_proc, y_proc = loss.preprocess(X, y) n_features = X_proc.shape[1] _validate_uniform_sample_weight(sample_weight, X_proc.shape[0], "lbfgs_b_solver") - # Initialize params + # Initialize params on the preprocessed design backend/device/dtype. if init_coef is not None: - params = ( - _copy_arr(init_coef) - if hasattr(init_coef, "copy") or hasattr(init_coef, "clone") - else np.array(init_coef).copy() - ) + params = _as_backend_vector(init_coef, backend, X_proc) else: - params = _zeros(n_features, backend, ref_tensor=X) + params = _zeros(n_features, backend, ref_tensor=X_proc) - # Initialize bounds + # Initialize bounds on the same backend/device/dtype as params. if backend == "torch": import torch - _neg_inf = torch.full((n_features,), float("-inf"), dtype=torch.float64, device=params.device) - _pos_inf = torch.full((n_features,), float("inf"), dtype=torch.float64, device=params.device) + + _neg_inf = torch.full( + (n_features,), float("-inf"), dtype=params.dtype, device=params.device + ) + _pos_inf = torch.full( + (n_features,), float("inf"), dtype=params.dtype, device=params.device + ) + _as_bound = lambda value: torch.as_tensor( + value, dtype=params.dtype, device=params.device + ) + elif backend == "cupy": + import cupy as cp + + _neg_inf = cp.full((n_features,), float("-inf"), dtype=params.dtype) + _pos_inf = cp.full((n_features,), float("inf"), dtype=params.dtype) + _as_bound = lambda value: cp.asarray(value, dtype=params.dtype) else: - _neg_inf = np.full(n_features, float("-inf")) - _pos_inf = np.full(n_features, float("inf")) + _neg_inf = np.full(n_features, float("-inf"), dtype=params.dtype) + _pos_inf = np.full(n_features, float("inf"), dtype=params.dtype) + _as_bound = lambda value: np.asarray(value, dtype=params.dtype) + + lb = _neg_inf if lower_bounds is None else _as_bound(lower_bounds) + ub = _pos_inf if upper_bounds is None else _as_bound(upper_bounds) + if lb.shape != params.shape or ub.shape != params.shape: + raise ValueError("lower_bounds and upper_bounds must match coefficient shape") + if backend == "torch": + invalid_nan = bool((lb.isnan().any() | ub.isnan().any()).item()) + invalid_bounds = bool((lb > ub).any().item()) + elif backend == "cupy": + import cupy as cp - lb = _neg_inf if lower_bounds is None else ( - lower_bounds if hasattr(lower_bounds, "shape") else np.array(lower_bounds) - ) - ub = _pos_inf if upper_bounds is None else ( - upper_bounds if hasattr(upper_bounds, "shape") else np.array(upper_bounds) - ) + invalid_nan = bool((cp.isnan(lb).any() | cp.isnan(ub).any()).item()) + invalid_bounds = bool((lb > ub).any().item()) + else: + invalid_nan = bool(np.isnan(lb).any() or np.isnan(ub).any()) + invalid_bounds = bool(np.any(lb > ub)) + if invalid_nan: + raise ValueError("lower_bounds and upper_bounds must not contain NaN") + if invalid_bounds: + raise ValueError("lower_bounds must not exceed upper_bounds") # Clip initial params to bounds params = _clip_to_bounds(params, lb, ub, backend) @@ -126,7 +153,7 @@ def lbfgs_b_solver( for iteration in range(max_iter): # Projected gradient norm: only count free components - proj_grad = _projected_gradient(grad, params, lb, ub) + proj_grad = _projected_gradient(grad, params, lb, ub, backend) pg_norm_dev = _norm2_dev(proj_grad) # Two-loop recursion @@ -151,16 +178,15 @@ def lbfgs_b_solver( beta = rho * _dot_dev(y_vec, r) r = r + s_vec * (alpha - beta) - direction = -r + direction = _project_direction(-r, params, lb, ub, backend) gdd_dev = _dot_dev(grad, direction) pg_norm, gdd = _sync_scalars(pg_norm_dev, gdd_dev, backend=backend) if pg_norm < tol: break if gdd >= 0: - direction = -grad - gdd = -_norm2_dev(grad) - gdd = float(gdd) if not hasattr(gdd, "item") else float(gdd.item()) + direction = -proj_grad + gdd = -pg_norm * pg_norm # Line search with bounds clipping old_val_dev, _ = loss.fused_value_and_gradient(X_proc, y_proc, params) @@ -223,32 +249,35 @@ def lbfgs_b_solver( def _clip_to_bounds(params, lb, ub, backend): - """Clip parameters to [lb, ub]. Works on all backends.""" + """Clip parameters to [lb, ub] on their current backend.""" if backend == "torch": import torch - return torch.clamp(params, min=lb, max=ub) - else: - xp = np - return xp.maximum(xp.minimum(params, ub), lb) + return torch.maximum(torch.minimum(params, ub), lb) + if backend == "cupy": + import cupy as cp + return cp.maximum(cp.minimum(params, ub), lb) + return np.maximum(np.minimum(params, ub), lb) -def _projected_gradient(grad, params, lb, ub): +def _projected_gradient(grad, params, lb, ub, backend): """Projected gradient: zero out components at active bounds. A component is at a bound if: - params[i] == lb[i] and grad[i] > 0 (at lower bound, gradient points up) - params[i] == ub[i] and grad[i] < 0 (at upper bound, gradient points down) """ - backend = "torch" if hasattr(params, "device") else "numpy" + at_lower = (params <= lb) & (grad > 0) + at_upper = (params >= ub) & (grad < 0) + at_bound = at_lower | at_upper if backend == "torch": - import torch - at_lower = (params <= lb) & (grad > 0) - at_upper = (params >= ub) & (grad < 0) - at_bound = at_lower | at_upper return grad * (~at_bound).to(grad.dtype) - else: - at_lower = (params <= lb) & (grad > 0) - at_upper = (params >= ub) & (grad < 0) - at_bound = at_lower | at_upper - mask = (~at_bound).astype(grad.dtype) - return grad * mask + return grad * (~at_bound).astype(grad.dtype) + +def _project_direction(direction, params, lb, ub, backend): + """Remove direction components that would leave the feasible box.""" + blocked = ((params <= lb) & (direction < 0)) | ( + (params >= ub) & (direction > 0) + ) + if backend == "torch": + return direction * (~blocked).to(direction.dtype) + return direction * (~blocked).astype(direction.dtype) diff --git a/statgpu/solvers/_newton.py b/statgpu/solvers/_newton.py index d3f8f6342..175f3c332 100644 --- a/statgpu/solvers/_newton.py +++ b/statgpu/solvers/_newton.py @@ -27,6 +27,10 @@ _smooth_penalty_gradient, _smooth_penalty_hessian, _smooth_penalty_value_dev, + _runtime_error_is_singular, + _as_backend_vector, + _validate_smooth_penalty, + _trial_error_is_numerical, ) @@ -44,22 +48,19 @@ def newton_solver( Supports numpy / cupy / torch backends via auto-detection of X. - For losses with constant Hessian (e.g. Gamma log link), the Hessian - doesn't change across iterations, so the Newton step is always valid - and line search is skipped. + For losses with constant Hessian, the Hessian is computed once and + reused across iterations; Armijo backtracking still verifies each step. Requires: loss has hessian() and penalty is smooth. """ + _validate_smooth_penalty(penalty, "newton_solver") backend = _resolve_backend("auto", X) X_proc, y_proc = loss.preprocess(X, y) + _validate_uniform_sample_weight(sample_weight, X_proc.shape[0], "newton_solver") n_features = X_proc.shape[1] if init_coef is not None: - params = ( - _copy_arr(init_coef) - if hasattr(init_coef, "copy") or hasattr(init_coef, "clone") - else np.array(init_coef).copy() - ) + params = _as_backend_vector(init_coef, backend, X_proc) else: params = _zeros(n_features, backend, ref_tensor=X_proc) @@ -72,7 +73,6 @@ def newton_solver( penalty, params ) - _validate_uniform_sample_weight(sample_weight, X_proc.shape[0], "newton_solver") iteration = -1 line_search_failed = False @@ -135,7 +135,7 @@ def newton_solver( direction = torch.linalg.solve(hess_reg, grad.unsqueeze(1)) direction = direction.squeeze(1) - except (np.linalg.LinAlgError, ValueError, RuntimeError): + except np.linalg.LinAlgError: if backend == "numpy": direction = np.linalg.lstsq(hess_reg, grad, rcond=None)[0] elif backend == "cupy": @@ -147,6 +147,20 @@ def newton_solver( direction = torch.linalg.lstsq(hess_reg, grad.unsqueeze(1)).solution direction = direction.squeeze(1) + except RuntimeError as exc: + if not _runtime_error_is_singular(exc): + raise + if backend == "torch": + import torch + + direction = torch.linalg.lstsq(hess_reg, grad.unsqueeze(1)).solution + direction = direction.squeeze(1) + elif backend == "cupy": + import cupy as cp + + direction = cp.linalg.lstsq(hess_reg, grad)[0] + else: + direction = np.linalg.lstsq(hess_reg, grad, rcond=None)[0] # Armijo backtracking line search obj_old_dev, _ = loss.fused_value_and_gradient(X_proc, y_proc, params_old) @@ -172,8 +186,11 @@ def newton_solver( params = params_try accepted = True break - except (ValueError, RuntimeError, FloatingPointError): + except FloatingPointError: pass + except (ValueError, RuntimeError) as exc: + if not _trial_error_is_numerical(exc): + raise step *= 0.5 if not accepted: # Never accept an unverified trial step. A tiny rejected step diff --git a/statgpu/solvers/_proximal_newton.py b/statgpu/solvers/_proximal_newton.py index eaa766db4..e663dc8b6 100644 --- a/statgpu/solvers/_proximal_newton.py +++ b/statgpu/solvers/_proximal_newton.py @@ -1,18 +1,12 @@ -"""Proximal Newton solver for smooth loss + non-smooth penalty. +"""Newton solver with explicit non-smooth FISTA delegation. -Solves: min f(x) + g(x) -where f is smooth (loss) and g is non-smooth (penalty). +Solves smooth loss plus a smooth penalty with Newton updates. -Algorithm: -1. Compute Newton direction: d = -H^-1 @ (grad_f + prox_grad_g) -2. Line search: find step that decreases f(x + step*d) + g(x + step*d) -3. Update: x = x + step * d - -Much faster than FISTA for problems where: -- f has a Hessian (Huber, Bisquare, Fair, CoxPH) -- g is non-smooth but has a proximal operator (L1, SCAD/MCP via LLA) - -Typical convergence: 5-10 iterations vs 300+ for FISTA. +A general non-smooth proximal-Newton step requires solving the Hessian-metric +proximal subproblem. The historical Euclidean-prox approximation optimized a +different objective (and double-counted L2/ElasticNet curvature). Until a +metric proximal subproblem solver is implemented, non-smooth penalties are +explicitly delegated to the backend-native FISTA solver with a warning. """ __all__ = ["proximal_newton_solver"] @@ -30,7 +24,14 @@ _zeros, ) from statgpu.backends._utils import _to_float_scalar, _to_numpy -from ._utils import _smooth_penalty_gradient, _smooth_penalty_hessian +from ._utils import ( + _runtime_error_is_singular, + _smooth_penalty_gradient, + _smooth_penalty_hessian, + _validate_sample_weight, + _as_backend_vector, + _trial_error_is_numerical, +) def proximal_newton_solver( @@ -43,14 +44,18 @@ def proximal_newton_solver( init_coef=None, sample_weight=None, ): - """Proximal Newton solver for smooth loss + non-smooth penalty. + """Newton solver for smooth penalties with explicit FISTA delegation. + + L2/no-penalty objectives use Newton updates. A non-smooth penalty emits a + ``RuntimeWarning`` and is delegated to ``fista_solver`` because the + Hessian-metric proximal subproblem is not implemented. Parameters ---------- loss : LossBase - Must have gradient(), hessian(), fused_value_and_gradient(). - penalty : Penalty - Non-smooth penalty with proximal() method. + Must expose the operations required by the selected solver path. + penalty : Penalty or None + L2/None for Newton; non-smooth penalties are delegated to FISTA. X, y : array Data (preprocessed). max_iter : int @@ -68,16 +73,41 @@ def proximal_newton_solver( n_iter : int Number of iterations. """ + _pen_name = str(getattr(penalty, "name", "none")).lower() + _is_smooth_pen = _pen_name in ("l2", "none", "null", "") + if not _is_smooth_pen: + warnings.warn( + "proximal_newton_solver delegates non-smooth penalties to " + "fista_solver because the Hessian-metric proximal subproblem is " + "not implemented; this preserves the declared objective.", + RuntimeWarning, + stacklevel=2, + ) + from ._fista import fista_solver + + return fista_solver( + loss, + penalty, + X, + y, + max_iter=max_iter, + tol=tol, + init_coef=init_coef, + sample_weight=sample_weight, + ) + backend = _resolve_backend("auto", X) X_proc, y_proc = loss.preprocess(X, y) + _validate_sample_weight(sample_weight, X_proc.shape[0]) + _sw_arr = ( + None + if sample_weight is None + else _as_backend_vector(sample_weight, backend, X_proc) + ) n_features = X_proc.shape[1] if init_coef is not None: - params = ( - _copy_arr(init_coef) - if hasattr(init_coef, "copy") or hasattr(init_coef, "clone") - else np.array(init_coef).copy() - ) + params = _as_backend_vector(init_coef, backend, X_proc) else: params = _zeros(n_features, backend, ref_tensor=X_proc) @@ -88,20 +118,25 @@ def proximal_newton_solver( f"got '{getattr(loss, 'name', '?')}' which has has_hessian=False." ) - # Pre-allocate ridge matrix (reused every iteration) + # Pre-allocate a dtype/device-compatible ridge matrix. _n = n_features + _dtype = getattr(params, "dtype", np.float64) if backend == "numpy": - _ridge = 1e-10 * np.eye(_n, dtype=np.float64) + _ridge = 1e-10 * np.eye(_n, dtype=_dtype) elif backend == "cupy": import cupy as cp - _ridge = 1e-10 * cp.eye(_n, dtype=cp.float64) + _ridge = 1e-10 * cp.eye(_n, dtype=_dtype) else: import torch - _ridge = 1e-10 * torch.eye(_n, dtype=torch.float64, - device=params.device if hasattr(params, 'device') else 'cpu') + _ridge = 1e-10 * torch.eye( + _n, + dtype=_dtype, + device=params.device if hasattr(params, "device") else "cpu", + ) # Check if loss supports fused gradient+hessian _has_fused = hasattr(loss, 'fused_gradient_and_hessian') + iteration = -1 # max_iter=0 returns the initialized coefficient vector for iteration in range(max_iter): params_old = _copy_arr(params) @@ -109,22 +144,16 @@ def proximal_newton_solver( # Gradient and Hessian of smooth loss if _has_fused: loss_grad, loss_hess = loss.fused_gradient_and_hessian( - X_proc, y_proc, params, sample_weight=sample_weight + X_proc, y_proc, params, sample_weight=_sw_arr ) else: - loss_grad = loss.gradient(X_proc, y_proc, params, sample_weight=sample_weight) - loss_hess = loss.hessian(X_proc, y_proc, params, sample_weight=sample_weight) - - # Add smooth penalty gradient/hessian only for smooth penalties. - # Non-smooth penalties (L1, AdaptiveL1) are handled by proximal operator. - _pen_name = getattr(penalty, 'name', '') - _is_smooth_pen = _pen_name in ('l2', 'none', 'null', '', 'elasticnet') - if _is_smooth_pen: - grad = loss_grad + _smooth_penalty_gradient(penalty, params) - hess = loss_hess + _smooth_penalty_hessian(penalty, params) - else: - grad = loss_grad - hess = loss_hess + loss_grad = loss.gradient(X_proc, y_proc, params, sample_weight=_sw_arr) + loss_hess = loss.hessian(X_proc, y_proc, params, sample_weight=_sw_arr) + + # Only smooth penalties reach this path. Their gradient and + # curvature are included exactly once in the Newton system. + grad = loss_grad + _smooth_penalty_gradient(penalty, params) + hess = loss_hess + _smooth_penalty_hessian(penalty, params) hess = 0.5 * (hess + hess.T) # Check convergence via gradient norm @@ -145,25 +174,22 @@ def proximal_newton_solver( else: import torch direction = torch.linalg.solve(hess_reg, grad.unsqueeze(1)).squeeze(1) - except (np.linalg.LinAlgError, ValueError) as e: - # Fallback to gradient descent if Hessian is singular/ill-conditioned + except np.linalg.LinAlgError: + # Fall back only for a true singular/ill-conditioned Hessian. direction = grad - except RuntimeError as e: - # Only catch singular/ill-conditioned errors, re-raise others (OOM, device mismatch, etc.) - err_msg = str(e).lower() - if "singular" in err_msg or "ill-conditioned" in err_msg or "not invertible" in err_msg: - direction = grad - else: + except RuntimeError as exc: + if not _runtime_error_is_singular(exc): raise + direction = grad # Armijo backtracking line search with proximal step - obj_old_dev, _ = loss.fused_value_and_gradient(X_proc, y_proc, params_old, sample_weight=sample_weight) + obj_old_dev, _ = loss.fused_value_and_gradient(X_proc, y_proc, params_old, sample_weight=_sw_arr) _has_pen_value = hasattr(penalty, 'value') if _has_pen_value: pen_old = float(_to_numpy(penalty.value(params_old[:n_features]))) else: pen_old = 0.0 - if iteration == 0: + if iteration == 0 and _pen_name not in ("none", "null", ""): warnings.warn( f"proximal_newton: penalty '{getattr(penalty, 'name', '?')}' " f"has no value() method. Armijo condition ignores penalty value.", @@ -183,13 +209,11 @@ def proximal_newton_solver( # Trial point: params_old - step * direction params_try = params_old - step * direction - # Apply proximal operator (handles non-smooth penalty) - if hasattr(penalty, 'proximal'): - # For weighted L1 (AdaptiveL1 from LLA): proximal is soft-threshold - params_try = penalty.proximal(params_try, step, backend=backend) - + # Smooth penalty terms are already represented in the Newton + # direction; applying their proximal operator here would count the + # same penalty a second time. try: - obj_try_dev, _ = loss.fused_value_and_gradient(X_proc, y_proc, params_try, sample_weight=sample_weight) + obj_try_dev, _ = loss.fused_value_and_gradient(X_proc, y_proc, params_try, sample_weight=_sw_arr) pen_try = float(_to_numpy(penalty.value(params_try[:n_features]))) if _has_pen_value else 0.0 # Composite Armijo: f(x_new) + g(x_new) <= f(x_old) + g(x_old) + c*step*gdd @@ -197,17 +221,14 @@ def proximal_newton_solver( params = params_try accepted = True break - except (ValueError, FloatingPointError): + except FloatingPointError: pass - except RuntimeError as e: - # Only swallow numerical errors, re-raise infrastructure bugs - err_msg = str(e).lower() - if any(kw in err_msg for kw in ("singular", "ill-conditioned", - "not invertible", "overflow", "invalid value", "nan")): - pass - else: + except (ValueError, RuntimeError) as exc: + # Only swallow recognized trial-point numerical-domain + # failures; input-contract, infrastructure, and device errors + # remain visible to the caller. + if not _trial_error_is_numerical(exc): raise - pass step *= 0.5 if not accepted: diff --git a/statgpu/solvers/_utils.py b/statgpu/solvers/_utils.py index 480fe09ba..e8367775c 100644 --- a/statgpu/solvers/_utils.py +++ b/statgpu/solvers/_utils.py @@ -25,19 +25,116 @@ ) +def _scalar_bool(value): + return bool(value.item() if hasattr(value, "item") else value) + + +def _runtime_error_is_singular(exc): + """Return whether a backend RuntimeError reports a solvable rank failure.""" + message = str(exc).lower() + return any( + marker in message + for marker in ( + "singular", + "not invertible", + "zero pivot", + "rank deficient", + "ill-conditioned", + "not positive-definite", + "not positive definite", + ) + ) + + +def _trial_error_is_numerical(exc): + """Return whether a trial-point exception is an expected numeric-domain failure.""" + message = str(exc).lower() + return any( + marker in message + for marker in ( + "overflow", + "invalid value", + "nan", + "non-finite", + "nonfinite", + "domain error", + ) + ) + + +def _native_sample_weight(sample_weight): + """Return sample weights on their current backend without a full D2H copy.""" + backend = _resolve_backend("auto", sample_weight) + xp = _get_xp(backend) + if backend == "torch": + import torch + + try: + values = ( + sample_weight + if torch.is_tensor(sample_weight) + else torch.as_tensor(sample_weight) + ) + except (TypeError, ValueError) as exc: + raise ValueError("sample_weight must be a real numeric array-like") from exc + if torch.is_complex(values): + raise ValueError("sample_weight must contain real numeric values") + else: + try: + values = xp.asarray(sample_weight) + except (TypeError, ValueError) as exc: + raise ValueError("sample_weight must be a real numeric array-like") from exc + if getattr(values.dtype, "kind", "") not in "biuf": + raise ValueError("sample_weight must contain real numeric values") + return backend, xp, values + + +def _validated_sample_weight(sample_weight, n_samples): + backend, xp, values = _native_sample_weight(sample_weight) + if int(values.ndim) != 1 or int(values.shape[0]) != int(n_samples): + raise ValueError("sample_weight must be 1D with length n_samples") + try: + finite = xp.all(xp.isfinite(values)) + negative = xp.any(values < 0) + if backend == "torch": + import torch + + if not torch.is_floating_point(values): + values = values.to(dtype=torch.float64) + elif getattr(values.dtype, "kind", "") in "biu": + values = values.astype(xp.float64, copy=False) + total_dev = xp.sum(values) + total = float(total_dev.item() if hasattr(total_dev, "item") else total_dev) + except (TypeError, ValueError) as exc: + raise ValueError("sample_weight must contain real finite values") from exc + if not _scalar_bool(finite): + raise ValueError("sample_weight must contain only finite values") + if _scalar_bool(negative): + raise ValueError("sample_weight must be non-negative") + if not np.isfinite(total) or total <= 0.0: + raise ValueError("sample_weight must have a finite positive sum") + return backend, xp, values + + def _validate_uniform_sample_weight(sample_weight, n_samples, solver_name): if sample_weight is None: return - _sw = _to_numpy(sample_weight) - if _sw.ndim != 1 or _sw.shape[0] != n_samples: - raise ValueError("sample_weight must be a 1D array with length n_samples") - if not np.all(np.isfinite(_sw)): - raise ValueError("sample_weight must contain only finite values") - if np.any(_sw < 0): - raise ValueError("sample_weight must be non-negative") - if np.sum(_sw) <= 0.0: - raise ValueError("sample_weight must contain at least one positive value") - if not np.allclose(_sw, _sw[0]): + backend, xp, values = _validated_sample_weight(sample_weight, n_samples) + if backend == "torch": + import torch + + uniform = ( + torch.allclose(values, values[0]) + if torch.is_floating_point(values) + else torch.all(values == values[0]) + ) + else: + uniform = ( + xp.allclose(values, values[0]) + if getattr(values.dtype, "kind", "") == "f" + else xp.all(values == values[0]) + ) + if not _scalar_bool(uniform): raise ValueError( f"{solver_name} does not support non-uniform sample_weight yet; " "use solver='irls' for weighted GLM fits." @@ -45,17 +142,8 @@ def _validate_uniform_sample_weight(sample_weight, n_samples, solver_name): def _validate_sample_weight(sample_weight, n_samples): - if sample_weight is None: - return - _sw = _to_numpy(sample_weight) - if _sw.ndim != 1 or _sw.shape[0] != n_samples: - raise ValueError("sample_weight must be 1D with length n_samples") - if not np.all(np.isfinite(_sw)): - raise ValueError("sample_weight must contain only finite values") - if np.any(_sw < 0): - raise ValueError("sample_weight must be non-negative") - if np.sum(_sw) <= 0: - raise ValueError("sample_weight must contain at least one positive value") + if sample_weight is not None: + _validated_sample_weight(sample_weight, n_samples) def _as_backend_vector(arr, backend, ref): @@ -136,6 +224,16 @@ def _penalty_name(penalty): return str(getattr(penalty, "name", "none")).lower() +def _validate_smooth_penalty(penalty, solver_name): + """Reject penalties whose non-smooth terms a smooth solver cannot optimize.""" + name = _penalty_name(penalty) + if name not in ("l2", "none", "null", ""): + raise ValueError( + f"{solver_name} supports only l2/none penalties; got penalty='{name}'. " + "Use fista or another proximal solver for non-smooth penalties." + ) + + def _smooth_penalty_value(penalty, coef): if penalty is None: return 0.0 diff --git a/statgpu/survival/_cox.py b/statgpu/survival/_cox.py index 89a899ac6..2c4e1705e 100644 --- a/statgpu/survival/_cox.py +++ b/statgpu/survival/_cox.py @@ -197,6 +197,9 @@ class CoxPH(BaseEstimator): """ _estimator_type = "regressor" + _DEFERRED_SET_PARAMS = frozenset({ + "compute_inference", "compute_cindex", "gpu_memory_cleanup" + }) _canonical_fit_path = "counting_process" def __sklearn_tags__(self): @@ -363,7 +366,7 @@ def _reset_fit_state(self): def _cleanup_cuda_memory(self): """Best-effort CuPy memory pool cleanup.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -374,7 +377,7 @@ def _cleanup_cuda_memory(self): def _cleanup_torch_memory(self): """Best-effort Torch CUDA cache cleanup.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch @@ -393,12 +396,12 @@ def __del__(self): def _validate_optimization_controls(self): """Validate mutable optimization controls before every fit attempt.""" - if isinstance(self.max_iter, (bool, np.bool_)) or not isinstance( - self.max_iter, numbers.Integral - ) or int(self.max_iter) < 1: + if isinstance(self._max_iter, (bool, np.bool_)) or not isinstance( + self._max_iter, numbers.Integral + ) or int(self._max_iter) < 1: raise ValueError("max_iter must be a positive integer") try: - tol = float(self.tol) + tol = float(self._tol) penalty = float(self.penalty) except (TypeError, ValueError) as exc: raise ValueError( @@ -1410,8 +1413,8 @@ def _format_fit_call(self): "counting_process": bool(self._is_counting_process), "stratified": self._strata is not None, "subject_grouped": self._subject_id is not None, - "clustered": self.cov_type == "cluster", - "ties": self.ties, + "clustered": self._cov_type == "cluster", + "ties": self._ties, } parts = [] if call["interface"] == "formula": @@ -1435,12 +1438,12 @@ def summary(self): raise RuntimeError("Model has not been fitted yet.") controls = self._fit_controls fitted_cov_type = ( - controls.cov_type if controls is not None else str(self.cov_type) + controls.cov_type if controls is not None else str(self._cov_type) ) fitted_compute_inference = ( controls.compute_inference if controls is not None - else bool(self.compute_inference) + else bool(self._compute_inference_enabled) ) fitted_penalty = ( controls.penalty if controls is not None else float(self.penalty) diff --git a/statgpu/survival/_cox_cv.py b/statgpu/survival/_cox_cv.py index 381bbe8be..0190a2ff7 100644 --- a/statgpu/survival/_cox_cv.py +++ b/statgpu/survival/_cox_cv.py @@ -1686,7 +1686,7 @@ def _reset_fit_state(self): def _cleanup_cuda_memory(self): """Best-effort CuPy memory pool cleanup.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import cupy as cp @@ -1698,7 +1698,7 @@ def _cleanup_cuda_memory(self): def _cleanup_torch_memory(self): """Best-effort Torch CUDA cache cleanup.""" - if not self.gpu_memory_cleanup: + if not self._gpu_memory_cleanup: return try: import torch @@ -1773,14 +1773,14 @@ def _fit_cv( "robust covariance is not yet defined for ties='exact'; " "use cov_type='nonrobust' or compute_inference=False" ) - max_iter = _validate_positive_integer(self.max_iter, "max_iter") - tol = _validate_finite_positive(self.tol, "tol") + max_iter = _validate_positive_integer(self._max_iter, "max_iter") + tol = _validate_finite_positive(self._tol, "tol") if self.cv_splits is None: - cv_folds = _validate_positive_integer(self.cv, "cv") + cv_folds = _validate_positive_integer(self._cv, "cv") if cv_folds < 2: raise ValueError("cv must be at least 2") else: - cv_folds = self.cv + cv_folds = self._cv if self.penalties is None: n_penalties = _validate_positive_integer( self.n_penalties, "n_penalties" diff --git a/statgpu/survival/_cox_cv_split_lifecycle_contract.py b/statgpu/survival/_cox_cv_split_lifecycle_contract.py index 70b558810..d55258436 100644 --- a/statgpu/survival/_cox_cv_split_lifecycle_contract.py +++ b/statgpu/survival/_cox_cv_split_lifecycle_contract.py @@ -100,8 +100,19 @@ def _getstate_with_reusable_splits(self): else: state = dict(_ORIGINAL_COXPHCV_GETSTATE(self)) - if _is_one_shot_iterator(state.get("cv_splits")): - state["cv_splits"] = copy.deepcopy(_materialize_cv_splits(self)) + raw_params = state.get("_constructor_params_raw") + public_one_shot = _is_one_shot_iterator(state.get("cv_splits")) + raw_one_shot = isinstance(raw_params, dict) and _is_one_shot_iterator( + raw_params.get("cv_splits") + ) + if public_one_shot or raw_one_shot: + reusable_splits = copy.deepcopy(_materialize_cv_splits(self)) + state["cv_splits"] = reusable_splits + if isinstance(raw_params, dict): + raw_params = raw_params.copy() + raw_params["cv_splits"] = copy.deepcopy(reusable_splits) + state["_constructor_params_raw"] = raw_params + state["_cox_cv_split_source"] = None state["_cox_cv_split_snapshot"] = None return state diff --git a/statgpu/unsupervised/_agglomerative.py b/statgpu/unsupervised/_agglomerative.py index 2c85edc4a..5b17fc026 100644 --- a/statgpu/unsupervised/_agglomerative.py +++ b/statgpu/unsupervised/_agglomerative.py @@ -62,7 +62,7 @@ def _validate_params(self, n_samples: int): raise NotImplementedError("AgglomerativeClustering only supports metric='euclidean'") def _use_gpu_path(self) -> bool: - return self.device in (Device.CUDA, Device.TORCH) + return self._device in (Device.CUDA, Device.TORCH) def _check_gpu_memory(self, n_samples: int): required = int(n_samples) * int(n_samples) * 8 diff --git a/statgpu/unsupervised/_dbscan.py b/statgpu/unsupervised/_dbscan.py index 96ee22d6d..28601a01b 100644 --- a/statgpu/unsupervised/_dbscan.py +++ b/statgpu/unsupervised/_dbscan.py @@ -69,8 +69,8 @@ def _validate_params(self, n_samples: int): raise NotImplementedError("DBSCAN only supports metric='euclidean'") if self.algorithm not in ("auto", "brute", "ball_tree", "kd_tree"): raise ValueError("algorithm must be one of: 'auto', 'brute', 'ball_tree', 'kd_tree'") - if self.batch_size is not None: - if not isinstance(self.batch_size, (int, np.integer)) or int(self.batch_size) < 1: + if self._batch_size is not None: + if not isinstance(self._batch_size, (int, np.integer)) or int(self._batch_size) < 1: raise ValueError("batch_size must be None or a positive integer") if n_samples < 1: raise ValueError("DBSCAN requires at least one sample") @@ -251,8 +251,8 @@ def _neighbor_graph_sparse(self, backend, X): n_samples = X.shape[0] eps = float(self.eps) - if self.batch_size is not None: - batch_size = min(int(self.batch_size), n_samples) + if self._batch_size is not None: + batch_size = min(int(self._batch_size), n_samples) else: batch_size = min(n_samples, max(1000, 400_000_000 // (n_samples * 4))) @@ -313,8 +313,8 @@ def _fit_gpu(self, backend, X_arr, n_samples, n_features): x_norm = (X_f32 * X_f32).sum(dim=1, keepdim=True) eps_sq = eps ** 2 - if self.batch_size is not None: - batch_size = min(int(self.batch_size), n_samples) + if self._batch_size is not None: + batch_size = min(int(self._batch_size), n_samples) else: batch_size = min(n_samples, max(2000, 2_000_000_000 // (n_samples * 4))) diff --git a/statgpu/unsupervised/_gmm.py b/statgpu/unsupervised/_gmm.py index d83f794e3..177bef497 100644 --- a/statgpu/unsupervised/_gmm.py +++ b/statgpu/unsupervised/_gmm.py @@ -47,11 +47,11 @@ def _validate_params(self, n_samples: int): raise ValueError("covariance_type must be one of: 'diag', 'spherical', 'tied', 'full'") if self.init_params not in ("kmeans", "random"): raise ValueError("init_params must be one of: 'kmeans', 'random'") - if float(self.tol) < 0.0: + if float(self._tol) < 0.0: raise ValueError("tol must be non-negative") if float(self.reg_covar) < 0.0: raise ValueError("reg_covar must be non-negative") - if not isinstance(self.max_iter, (int, np.integer)) or int(self.max_iter) < 1: + if not isinstance(self._max_iter, (int, np.integer)) or int(self._max_iter) < 1: raise ValueError("max_iter must be a positive integer") if not isinstance(self.n_init, (int, np.integer)) or int(self.n_init) < 1: raise ValueError("n_init must be a positive integer") @@ -177,9 +177,9 @@ def _initialize(self, backend, X, seed): km = KMeans( n_clusters=int(self.n_components), n_init=1, - max_iter=min(50, int(self.max_iter)), + max_iter=min(50, int(self._max_iter)), random_state=seed, - device=self.device, + device=self._device, ).fit(X) means = km.cluster_centers_ else: @@ -231,11 +231,11 @@ def fit(self, X, y=None): lower_bound = -np.inf converged = False n_iter = 0 - for n_iter in range(1, int(self.max_iter) + 1): + for n_iter in range(1, int(self._max_iter) + 1): prev_lower_bound = lower_bound lower_bound, resp = self._e_step(backend, X_arr, weights, means, covariances) weights, means, covariances = self._m_step(backend, X_arr, resp) - if abs(lower_bound - prev_lower_bound) < float(self.tol): + if abs(lower_bound - prev_lower_bound) < float(self._tol): converged = True break if best is None or lower_bound > best[0]: diff --git a/statgpu/unsupervised/_incremental_pca.py b/statgpu/unsupervised/_incremental_pca.py index 853f723f4..9806cd50b 100644 --- a/statgpu/unsupervised/_incremental_pca.py +++ b/statgpu/unsupervised/_incremental_pca.py @@ -45,8 +45,8 @@ def _validate_params(self, n_samples: int, n_features: int, first_pass: bool): raise ValueError("n_components must be less than or equal to n_features") if first_pass and n_samples < n_components: raise ValueError("first partial_fit batch must contain at least n_components samples") - if self.batch_size is not None: - if not isinstance(self.batch_size, (int, np.integer)) or int(self.batch_size) < 1: + if self._batch_size is not None: + if not isinstance(self._batch_size, (int, np.integer)) or int(self._batch_size) < 1: raise ValueError("batch_size must be None or a positive integer") return n_components @@ -123,8 +123,8 @@ def fit(self, X, y=None): n_samples, n_features = X_arr.shape n_components = self._validate_params(n_samples, n_features, first_pass=True) - if self.batch_size is not None: - batch_size = int(self.batch_size) + if self._batch_size is not None: + batch_size = int(self._batch_size) else: # Default: process all at once (fast for single fit). # Only batch when data is too large or user explicitly sets batch_size. diff --git a/statgpu/unsupervised/_kmeans.py b/statgpu/unsupervised/_kmeans.py index 1a936d469..dc9a7d003 100644 --- a/statgpu/unsupervised/_kmeans.py +++ b/statgpu/unsupervised/_kmeans.py @@ -71,9 +71,9 @@ def _validate_params(self, n_samples: int): if not isinstance(self.n_init, (int, np.integer)) or int(self.n_init) < 1: raise ValueError("n_init must be 'auto' or a positive integer") n_init = int(self.n_init) - if not isinstance(self.max_iter, (int, np.integer)) or int(self.max_iter) < 1: + if not isinstance(self._max_iter, (int, np.integer)) or int(self._max_iter) < 1: raise ValueError("max_iter must be a positive integer") - if float(self.tol) < 0.0: + if float(self._tol) < 0.0: raise ValueError("tol must be non-negative") return n_clusters, n_init @@ -175,13 +175,13 @@ def _run_single(self, backend, X, rng, n_clusters): labels = None min_dist_sq = None n_iter = 0 - for n_iter in range(1, int(self.max_iter) + 1): + for n_iter in range(1, int(self._max_iter) + 1): distances = self._squared_distances_with_x_norm(backend, X, x_norm, centers) labels, min_dist_sq = self._labels_min_distances(backend, distances) new_centers = self._compute_centers(backend, X, labels, min_dist_sq, centers) center_shift = backend.sum((new_centers - centers) ** 2) centers = new_centers - if scalar_to_float(center_shift) <= float(self.tol): + if scalar_to_float(center_shift) <= float(self._tol): break distances = self._squared_distances_with_x_norm(backend, X, x_norm, centers) labels, min_dist_sq = self._labels_min_distances(backend, distances) diff --git a/statgpu/unsupervised/_minibatch_kmeans.py b/statgpu/unsupervised/_minibatch_kmeans.py index 928932297..64bf53e2b 100644 --- a/statgpu/unsupervised/_minibatch_kmeans.py +++ b/statgpu/unsupervised/_minibatch_kmeans.py @@ -62,14 +62,14 @@ def _validate_params(self, n_samples: int, n_features: int): if not isinstance(self.n_init, (int, np.integer)) or int(self.n_init) < 1: raise ValueError("n_init must be 'auto' or a positive integer") n_init = int(self.n_init) - if not isinstance(self.batch_size, (int, np.integer)) or int(self.batch_size) < 1: + if not isinstance(self._batch_size, (int, np.integer)) or int(self._batch_size) < 1: raise ValueError("batch_size must be a positive integer") - if not isinstance(self.max_iter, (int, np.integer)) or int(self.max_iter) < 1: + if not isinstance(self._max_iter, (int, np.integer)) or int(self._max_iter) < 1: raise ValueError("max_iter must be a positive integer") if self.max_no_improvement is not None: if not isinstance(self.max_no_improvement, (int, np.integer)) or int(self.max_no_improvement) < 0: raise ValueError("max_no_improvement must be None or a non-negative integer") - if float(self.tol) < 0.0: + if float(self._tol) < 0.0: raise ValueError("tol must be non-negative") return n_clusters, n_init @@ -80,9 +80,9 @@ def _helper(self): init=init, n_init=1, max_iter=1, - tol=self.tol, + tol=self._tol, random_state=self.random_state, - device=self.device, + device=self._device, n_jobs=self.n_jobs, ) @@ -133,7 +133,7 @@ def _run_single(self, backend, X, rng, n_clusters, helper): centers = self._init_centers(backend, X, rng, n_clusters, helper) counts = backend.zeros((n_clusters,), dtype=backend.float64) n_samples = X.shape[0] - batch_size = min(int(self.batch_size), n_samples) + batch_size = min(int(self._batch_size), n_samples) best_batch_inertia = None no_improvement = 0 n_steps = 0 @@ -141,7 +141,7 @@ def _run_single(self, backend, X, rng, n_clusters, helper): last_centers = backend.copy(centers) track_improvement = self.max_no_improvement is not None - for n_iter in range(1, int(self.max_iter) + 1): + for n_iter in range(1, int(self._max_iter) + 1): order = rng.permutation(n_samples) order_backend = backend.asarray(order, dtype=backend.int64) for start in range(0, n_samples, batch_size): @@ -162,7 +162,7 @@ def _run_single(self, backend, X, rng, n_clusters, helper): center_shift = scalar_to_float(backend.sum((centers - last_centers) ** 2)) last_centers = backend.copy(centers) - if center_shift <= float(self.tol): + if center_shift <= float(self._tol): break if track_improvement and no_improvement >= int(self.max_no_improvement): break diff --git a/statgpu/unsupervised/_minibatch_nmf.py b/statgpu/unsupervised/_minibatch_nmf.py index 47a46e2aa..1be746344 100644 --- a/statgpu/unsupervised/_minibatch_nmf.py +++ b/statgpu/unsupervised/_minibatch_nmf.py @@ -42,12 +42,12 @@ def _validate_params(self, n_samples: int, n_features: int): n_components = int(self.n_components) if self.init != "random": raise NotImplementedError("MiniBatchNMF v1 only supports init='random'") - if self.batch_size is not None: - if not isinstance(self.batch_size, (int, np.integer)) or int(self.batch_size) < 1: + if self._batch_size is not None: + if not isinstance(self._batch_size, (int, np.integer)) or int(self._batch_size) < 1: raise ValueError("batch_size must be a positive integer or None") - if not isinstance(self.max_iter, (int, np.integer)) or int(self.max_iter) < 1: + if not isinstance(self._max_iter, (int, np.integer)) or int(self._max_iter) < 1: raise ValueError("max_iter must be a positive integer") - if float(self.tol) < 0.0: + if float(self._tol) < 0.0: raise ValueError("tol must be non-negative") return n_components @@ -169,8 +169,8 @@ def fit(self, X, y=None): self._fitted = True # Auto-size batch: large enough for GPU efficiency, small enough for mini-batch benefit - if self.batch_size is not None: - batch_size = min(int(self.batch_size), n_samples) + if self._batch_size is not None: + batch_size = min(int(self._batch_size), n_samples) else: batch_size = min(n_samples, max(20000, n_samples // 5)) @@ -182,7 +182,7 @@ def fit(self, X, y=None): check_interval = 5 if backend.name != "numpy" else 1 last_W = None - for epoch in range(1, int(self.max_iter) + 1): + for epoch in range(1, int(self._max_iter) + 1): A_epoch = backend.zeros((self.n_components_, self.n_components_), dtype=backend.float64) B_epoch = backend.zeros((self.n_components_, self.n_features_in_), dtype=backend.float64) # Pre-compute HtH once per epoch (H is frozen within epoch) @@ -206,13 +206,13 @@ def fit(self, X, y=None): last_B = B_epoch # Throttled convergence check - if epoch % check_interval == 0 or epoch == int(self.max_iter): + if epoch % check_interval == 0 or epoch == int(self._max_iter): delta = scalar_to_float(backend.xp.linalg.norm(new_components - old_components) / (backend.xp.linalg.norm(old_components) + eps)) - if previous_delta is not None and delta <= float(self.tol): + if previous_delta is not None and delta <= float(self._tol): break previous_delta = delta else: - epoch = int(self.max_iter) + epoch = int(self._max_iter) self.n_iter_ = int(epoch) if last_A is None or last_B is None: W_full = self.transform(X_arr) @@ -239,7 +239,7 @@ def transform(self, X): eps = np.finfo(np.float64).eps HtH = backend.matmul(self.components_, self.components_.T) W = self._init_w_from_data(backend, X_arr, self.components_, eps) - n_steps = max(100, min(300, int(self.max_iter) * 5)) + n_steps = max(100, min(300, int(self._max_iter) * 5)) for _ in range(n_steps): W = self._update_w(backend, X_arr, W, self.components_, HtH, eps) return W @@ -252,7 +252,7 @@ def fit_transform(self, X, y=None): eps = np.finfo(np.float64).eps HtH = backend.matmul(self.components_, self.components_.T) W = self._init_w_from_data(backend, X_arr, self.components_, eps) - n_steps = max(100, min(300, int(self.max_iter) * 5)) + n_steps = max(100, min(300, int(self._max_iter) * 5)) for _ in range(n_steps): W = self._update_w(backend, X_arr, W, self.components_, HtH, eps) return W diff --git a/statgpu/unsupervised/_nmf.py b/statgpu/unsupervised/_nmf.py index e5c61a685..2e30aae23 100644 --- a/statgpu/unsupervised/_nmf.py +++ b/statgpu/unsupervised/_nmf.py @@ -50,13 +50,13 @@ def _validate_params(self, n_samples: int, n_features: int): n_components = int(self.n_components) if self.init != "random": raise NotImplementedError("NMF v1 only supports init='random'") - if self.solver != "mu": + if self._solver != "mu": raise NotImplementedError("NMF v1 only supports solver='mu'") if self.beta_loss != "frobenius": raise NotImplementedError("NMF v1 only supports beta_loss='frobenius'") - if not isinstance(self.max_iter, (int, np.integer)) or int(self.max_iter) < 1: + if not isinstance(self._max_iter, (int, np.integer)) or int(self._max_iter) < 1: raise ValueError("max_iter must be a positive integer") - if float(self.tol) < 0.0: + if float(self._tol) < 0.0: raise ValueError("tol must be non-negative") return n_components @@ -120,14 +120,14 @@ def fit(self, X, y=None): else: # GPU/torch backends check less frequently than CPU to reduce host-sync # overhead while still preserving tol-based early stopping. - error_check_interval = max(1, min(25, int(self.max_iter) // 5)) - for n_iter in range(1, int(self.max_iter) + 1): + error_check_interval = max(1, min(25, int(self._max_iter) // 5)) + for n_iter in range(1, int(self._max_iter) + 1): W = self._update_w(backend, X_arr, W, H, eps) H = self._update_h(backend, X_arr, W, H, eps) - if n_iter % error_check_interval == 0 or n_iter == int(self.max_iter): + if n_iter % error_check_interval == 0 or n_iter == int(self._max_iter): error = self._reconstruction_error(backend, X_arr, W, H) if previous_error is not None: - if abs(previous_error - error) / max(previous_error, eps) <= float(self.tol): + if abs(previous_error - error) / max(previous_error, eps) <= float(self._tol): break previous_error = error @@ -155,7 +155,7 @@ def transform(self, X): raise ValueError(f"X has {X_arr.shape[1]} features, expected {self.n_features_in_}") eps = np.finfo(np.float64).eps W = self._init_w_from_data(backend, X_arr, self.components_, eps) - for _ in range(int(self.max_iter)): + for _ in range(int(self._max_iter)): W = self._update_w(backend, X_arr, W, self.components_, eps) return W diff --git a/statgpu/unsupervised/_tsne.py b/statgpu/unsupervised/_tsne.py index 7d642b964..beff75c18 100644 --- a/statgpu/unsupervised/_tsne.py +++ b/statgpu/unsupervised/_tsne.py @@ -56,7 +56,7 @@ def _validate_params(self, n_samples: int): raise ValueError("perplexity must be in (0, n_samples)") if float(self.early_exaggeration) <= 0.0: raise ValueError("early_exaggeration must be positive") - if not isinstance(self.max_iter, (int, np.integer)) or int(self.max_iter) < 250: + if not isinstance(self._max_iter, (int, np.integer)) or int(self._max_iter) < 250: raise ValueError("max_iter must be an integer >= 250") if self.init not in ("pca", "random"): raise ValueError("init must be one of: 'pca', 'random'") @@ -101,7 +101,7 @@ def _initial_embedding(self, backend, X): n_components=int(self.n_components), svd_solver="auto", random_state=self.random_state, - device=self.device, + device=self._device, n_jobs=self.n_jobs, ) init = pca.fit_transform(X) @@ -133,10 +133,10 @@ def fit(self, X, y=None): velocity = backend.zeros_like(Y) gains = backend.ones_like(Y) off_diag = 1.0 - eye(backend, n_samples, dtype=backend.float64) - exaggeration_iters = min(250, int(self.max_iter) // 2) + exaggeration_iters = min(250, int(self._max_iter) // 2) kl = None - for it in range(int(self.max_iter)): + for it in range(int(self._max_iter)): P_use = P * float(self.early_exaggeration) if it < exaggeration_iters else P dist_sq = squared_euclidean_distances(backend, Y) inv = (1.0 / (1.0 + dist_sq)) * off_diag @@ -160,7 +160,7 @@ def fit(self, X, y=None): self.embedding_ = Y self.kl_divergence_ = scalar_to_float(kl) - self.n_iter_ = int(self.max_iter) + self.n_iter_ = int(self._max_iter) self.n_features_in_ = int(n_features) self._backend_name = backend.name self._fitted = True