From e2c5f6c39a6762d9bdef731215de349d490a712d Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Thu, 30 Oct 2025 14:57:13 +0900 Subject: [PATCH 1/9] [feature] add solver option --- pyproject.toml | 1 + src/fastl2lir/fastl2lir.py | 24 ++++++++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 461cc25..5c3fd60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ "numpy>=1.22.0", + "scipy>=1.2.3", "threadpoolctl>=2.1.0", "tqdm>=4.64.1", ] diff --git a/src/fastl2lir/fastl2lir.py b/src/fastl2lir/fastl2lir.py index decb9f6..e67d6cb 100644 --- a/src/fastl2lir/fastl2lir.py +++ b/src/fastl2lir/fastl2lir.py @@ -7,15 +7,28 @@ import numpy as np from threadpoolctl import threadpool_limits from tqdm import tqdm +from scipy import linalg as sp_linalg class FastL2LiR(object): """Fast L2-regularized linear regression class.""" - def __init__(self, W=np.array([]), b=np.array([]), verbose=False): + def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver='scipy'): self.__W = W self.__b = b self.__verbose = verbose + self.__solver = solver + + # Choose linear solver once to avoid branching at call sites + if solver == 'scipy': + def _solve(a, b): + return sp_linalg.solve(a, b, assume_a='sym', check_finite=False) + elif solver == 'numpy': + def _solve(a, b): + return np.linalg.solve(a, b) + else: + raise ValueError('Unknown solver: %s' % solver) + self.__solve = _solve @property def W(self): @@ -271,7 +284,7 @@ def __sub_fit( # Choose the more efficient method based on matrix dimensions if X.shape[0] > X.shape[1]: # Use primal form for tall matrices (more samples than features) - Wb = np.linalg.solve( + Wb = self.__solve( np.matmul(X.T, X) + alpha * np.eye(X.shape[1], dtype=dtype), np.matmul(X.T, Y), ) @@ -279,11 +292,10 @@ def __sub_fit( # Use dual form for wide matrices (more features than samples) Wb = np.matmul( X.T, - np.linalg.solve( + self.__solve( np.matmul(X, X.T) + alpha * np.eye(X.shape[0], dtype=dtype), Y ), ) - W = Wb[0:-1, :] b = Wb[-1, :][np.newaxis, :] # Returning b as a 2D array else: @@ -312,7 +324,7 @@ def __sub_fit( ).ravel() ] ).reshape(feat_idx.size, feat_idx.size) - Wb = np.linalg.solve(W0_sub, W1[index_outputDim, feat_idx]) + Wb = self.__solve(W0_sub, W1[index_outputDim, feat_idx]) W[index_outputDim, feat_idx[:-1]] = Wb[:-1] b[0, index_outputDim] = Wb[-1] W = W.T @@ -379,7 +391,7 @@ def __sub_fit_save_select_feat( newX.shape[1], dtype=dtype ) rhs = np.matmul(selY.ravel(), newX) - Wb = np.linalg.solve(W0, rhs) + Wb = self.__solve(W0, rhs) W[index_outputDim, feat_idx] = Wb[:-1] b[0, index_outputDim] = Wb[-1] W = W.T From f5e1989f7e30cd9317e70cc5ea4ef7846a470054 Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Thu, 21 May 2026 16:02:22 +0900 Subject: [PATCH 2/9] [test] add tests for numpy solver --- tests/test_fastl2lir.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_fastl2lir.py b/tests/test_fastl2lir.py index cd144e3..852a1f3 100644 --- a/tests/test_fastl2lir.py +++ b/tests/test_fastl2lir.py @@ -136,6 +136,24 @@ def test_chunk(self): np.testing.assert_array_almost_equal(yp_2d, data["yp_2d"]) + def test_solver_numpy_matches_scipy(self): + '''numpy solver produces same result as scipy solver (default).''' + data = np.load('./tests/testdata_basic.npz') + + model_scipy = fastl2lir.FastL2LiR(solver='scipy') + model_numpy = fastl2lir.FastL2LiR(solver='numpy') + + model_scipy.fit(data['x_tr'], data['y_2d']) + model_numpy.fit(data['x_tr'], data['y_2d']) + + np.testing.assert_array_almost_equal(model_numpy.W, model_scipy.W) + np.testing.assert_array_almost_equal(model_numpy.b, model_scipy.b) + + def test_solver_invalid(self): + '''Invalid solver name raises ValueError.''' + with self.assertRaises(ValueError): + fastl2lir.FastL2LiR(solver='cholesky') + def test_reshape(self): """Test for reshaping.""" Y_shape = (200, 10, 10, 5) From e2fd04ba33a83f9acf40f612e84a00b158d9e86f Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Thu, 21 May 2026 17:42:49 +0900 Subject: [PATCH 3/9] [misc] try positive-definite solver before symmetric fallback --- src/fastl2lir/fastl2lir.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fastl2lir/fastl2lir.py b/src/fastl2lir/fastl2lir.py index e67d6cb..716f4b5 100644 --- a/src/fastl2lir/fastl2lir.py +++ b/src/fastl2lir/fastl2lir.py @@ -22,7 +22,10 @@ def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver='scipy' # Choose linear solver once to avoid branching at call sites if solver == 'scipy': def _solve(a, b): - return sp_linalg.solve(a, b, assume_a='sym', check_finite=False) + try: + return sp_linalg.solve(a, b, assume_a='pos', check_finite=False) + except sp_linalg.LinAlgError: + return sp_linalg.solve(a, b, assume_a='sym', check_finite=False) elif solver == 'numpy': def _solve(a, b): return np.linalg.solve(a, b) From 439c73efd14accc850c28b6e30ff50a747affe26 Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Mon, 8 Jun 2026 12:08:32 +0000 Subject: [PATCH 4/9] [chore] apply ruff format https://claude.ai/code/session_016xWp5Yz2EQz2F4TnWuaymM --- src/fastl2lir/fastl2lir.py | 14 ++++++++------ tests/test_fastl2lir.py | 23 +++++++++++------------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/fastl2lir/fastl2lir.py b/src/fastl2lir/fastl2lir.py index 716f4b5..1479d56 100644 --- a/src/fastl2lir/fastl2lir.py +++ b/src/fastl2lir/fastl2lir.py @@ -13,24 +13,26 @@ class FastL2LiR(object): """Fast L2-regularized linear regression class.""" - def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver='scipy'): + def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver="scipy"): self.__W = W self.__b = b self.__verbose = verbose self.__solver = solver # Choose linear solver once to avoid branching at call sites - if solver == 'scipy': + if solver == "scipy": + def _solve(a, b): try: - return sp_linalg.solve(a, b, assume_a='pos', check_finite=False) + return sp_linalg.solve(a, b, assume_a="pos", check_finite=False) except sp_linalg.LinAlgError: - return sp_linalg.solve(a, b, assume_a='sym', check_finite=False) - elif solver == 'numpy': + return sp_linalg.solve(a, b, assume_a="sym", check_finite=False) + elif solver == "numpy": + def _solve(a, b): return np.linalg.solve(a, b) else: - raise ValueError('Unknown solver: %s' % solver) + raise ValueError("Unknown solver: %s" % solver) self.__solve = _solve @property diff --git a/tests/test_fastl2lir.py b/tests/test_fastl2lir.py index 852a1f3..47a339c 100644 --- a/tests/test_fastl2lir.py +++ b/tests/test_fastl2lir.py @@ -137,22 +137,22 @@ def test_chunk(self): np.testing.assert_array_almost_equal(yp_2d, data["yp_2d"]) def test_solver_numpy_matches_scipy(self): - '''numpy solver produces same result as scipy solver (default).''' - data = np.load('./tests/testdata_basic.npz') + """numpy solver produces same result as scipy solver (default).""" + data = np.load("./tests/testdata_basic.npz") - model_scipy = fastl2lir.FastL2LiR(solver='scipy') - model_numpy = fastl2lir.FastL2LiR(solver='numpy') + model_scipy = fastl2lir.FastL2LiR(solver="scipy") + model_numpy = fastl2lir.FastL2LiR(solver="numpy") - model_scipy.fit(data['x_tr'], data['y_2d']) - model_numpy.fit(data['x_tr'], data['y_2d']) + model_scipy.fit(data["x_tr"], data["y_2d"]) + model_numpy.fit(data["x_tr"], data["y_2d"]) np.testing.assert_array_almost_equal(model_numpy.W, model_scipy.W) np.testing.assert_array_almost_equal(model_numpy.b, model_scipy.b) def test_solver_invalid(self): - '''Invalid solver name raises ValueError.''' + """Invalid solver name raises ValueError.""" with self.assertRaises(ValueError): - fastl2lir.FastL2LiR(solver='cholesky') + fastl2lir.FastL2LiR(solver="cholesky") def test_reshape(self): """Test for reshaping.""" @@ -172,12 +172,11 @@ def test_reshape(self): np.testing.assert_array_equal(model_test.b.shape, (1,) + Y_shape[1:]) np.testing.assert_array_equal(pred_test.shape, Y_shape) - def test_save_select_feat_default_select_sample(self): - '''save_select_feat=True with default select_sample=None should not raise.''' - data = np.load('./tests/testdata_nfeat.npz') + """save_select_feat=True with default select_sample=None should not raise.""" + data = np.load("./tests/testdata_nfeat.npz") model = fastl2lir.FastL2LiR() - model.fit(data['x_tr'], data['y_1d'], n_feat=20, save_select_feat=True) + model.fit(data["x_tr"], data["y_1d"], n_feat=20, save_select_feat=True) if __name__ == "__main__": From 992ac8ffe4933537d6a55e9b67c04440d277855d Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Tue, 9 Jun 2026 05:14:07 +0000 Subject: [PATCH 5/9] [fix] improve ValueError message for unknown solver https://claude.ai/code/session_016xWp5Yz2EQz2F4TnWuaymM --- src/fastl2lir/fastl2lir.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastl2lir/fastl2lir.py b/src/fastl2lir/fastl2lir.py index 1479d56..bd936d5 100644 --- a/src/fastl2lir/fastl2lir.py +++ b/src/fastl2lir/fastl2lir.py @@ -32,7 +32,9 @@ def _solve(a, b): def _solve(a, b): return np.linalg.solve(a, b) else: - raise ValueError("Unknown solver: %s" % solver) + raise ValueError( + f"Unknown solver: {solver!r}. Expected one of: 'scipy', 'numpy'." + ) self.__solve = _solve @property From 386b9d3ce8c2acf7e6c9587ecaf181921dfc4474 Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Tue, 9 Jun 2026 11:21:22 +0000 Subject: [PATCH 6/9] [refactor] move solver helpers to module level; add pickle and fallback tests - Extract _solve_scipy and _solve_numpy as module-level functions so that FastL2LiR instances are picklable and solver logic is statically analysable - Remove unused self.__solver instance variable - Add test_solver_pickle: verifies pickle round-trip preserves W and b - Add test_solver_scipy_fallback: verifies pos->sym fallback completes without error and satisfies the linear system https://claude.ai/code/session_016xWp5Yz2EQz2F4TnWuaymM --- src/fastl2lir/fastl2lir.py | 25 +++++++++++++------------ tests/test_fastl2lir.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/fastl2lir/fastl2lir.py b/src/fastl2lir/fastl2lir.py index bd936d5..f7c59db 100644 --- a/src/fastl2lir/fastl2lir.py +++ b/src/fastl2lir/fastl2lir.py @@ -10,6 +10,17 @@ from scipy import linalg as sp_linalg +def _solve_scipy(a, b): + try: + return sp_linalg.solve(a, b, assume_a="pos", check_finite=False) + except sp_linalg.LinAlgError: + return sp_linalg.solve(a, b, assume_a="sym", check_finite=False) + + +def _solve_numpy(a, b): + return np.linalg.solve(a, b) + + class FastL2LiR(object): """Fast L2-regularized linear regression class.""" @@ -17,25 +28,15 @@ def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver="scipy" self.__W = W self.__b = b self.__verbose = verbose - self.__solver = solver - # Choose linear solver once to avoid branching at call sites if solver == "scipy": - - def _solve(a, b): - try: - return sp_linalg.solve(a, b, assume_a="pos", check_finite=False) - except sp_linalg.LinAlgError: - return sp_linalg.solve(a, b, assume_a="sym", check_finite=False) + self.__solve = _solve_scipy elif solver == "numpy": - - def _solve(a, b): - return np.linalg.solve(a, b) + self.__solve = _solve_numpy else: raise ValueError( f"Unknown solver: {solver!r}. Expected one of: 'scipy', 'numpy'." ) - self.__solve = _solve @property def W(self): diff --git a/tests/test_fastl2lir.py b/tests/test_fastl2lir.py index 47a339c..5c18633 100644 --- a/tests/test_fastl2lir.py +++ b/tests/test_fastl2lir.py @@ -149,6 +149,29 @@ def test_solver_numpy_matches_scipy(self): np.testing.assert_array_almost_equal(model_numpy.W, model_scipy.W) np.testing.assert_array_almost_equal(model_numpy.b, model_scipy.b) + def test_solver_pickle(self): + """FastL2LiR instance with scipy solver can be pickled and unpickled.""" + import pickle + + data = np.load("./tests/testdata_basic.npz") + model = fastl2lir.FastL2LiR(solver="scipy") + model.fit(data["x_tr"], data["y_2d"]) + restored = pickle.loads(pickle.dumps(model)) + np.testing.assert_array_almost_equal(model.W, restored.W) + np.testing.assert_array_almost_equal(model.b, restored.b) + + def test_solver_scipy_fallback(self): + """scipy solver falls back from assume_a='pos' to 'sym' for indefinite matrix.""" + from fastl2lir.fastl2lir import _solve_scipy + + n = 20 + rng = np.random.default_rng(0) + A = np.diag([1.0] * (n - 1) + [-1.0]) + b = rng.standard_normal((n, 5)) + result = _solve_scipy(A, b) + residual = np.linalg.norm(A @ result - b) / np.linalg.norm(b) + self.assertLess(residual, 1e-10) + def test_solver_invalid(self): """Invalid solver name raises ValueError.""" with self.assertRaises(ValueError): From 35051f6f97067e071ca35261d5a324e752458ae1 Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Tue, 9 Jun 2026 12:31:26 +0000 Subject: [PATCH 7/9] [test] add solver-level unit test comparing numpy and scipy solvers https://claude.ai/code/session_016xWp5Yz2EQz2F4TnWuaymM --- tests/test_fastl2lir.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_fastl2lir.py b/tests/test_fastl2lir.py index 5c18633..8e238c4 100644 --- a/tests/test_fastl2lir.py +++ b/tests/test_fastl2lir.py @@ -149,6 +149,22 @@ def test_solver_numpy_matches_scipy(self): np.testing.assert_array_almost_equal(model_numpy.W, model_scipy.W) np.testing.assert_array_almost_equal(model_numpy.b, model_scipy.b) + def test_solver_helpers_agree(self): + """FastL2LiR's numpy and scipy solvers agree on the same linear system.""" + rng = np.random.default_rng(0) + n = 50 + A = rng.standard_normal((n, n)) + A = A.T @ A + np.eye(n) # symmetric positive definite + b = rng.standard_normal((n, 10)) + + model_numpy = fastl2lir.FastL2LiR(solver="numpy") + model_scipy = fastl2lir.FastL2LiR(solver="scipy") + + result_numpy = model_numpy._FastL2LiR__solve(A, b) + result_scipy = model_scipy._FastL2LiR__solve(A, b) + + np.testing.assert_array_almost_equal(result_numpy, result_scipy) + def test_solver_pickle(self): """FastL2LiR instance with scipy solver can be pickled and unpickled.""" import pickle From 7fc620cb87d3491c0a5c5b323972bf5d74ce6318 Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Thu, 11 Jun 2026 01:43:23 +0000 Subject: [PATCH 8/9] [fix] improve pickle compatibility and raise scipy version floor - Add _get_solver_func() to centralize solver selection logic - Reintroduce self.__solver for serializable solver name - Add __getstate__/__setstate__ for cross-version pickle compatibility - Legacy pickles without solver info fall back to numpy with UserWarning - Raise scipy lower bound from >=1.2.3 to >=1.8 - Update test_solver_helpers_agree to use module-level helpers directly - Strengthen test_solver_pickle with predict comparison - Add test_solver_getstate and test_solver_legacy_setstate https://claude.ai/code/session_016xWp5Yz2EQz2F4TnWuaymM --- pyproject.toml | 2 +- src/fastl2lir/fastl2lir.py | 35 +++++++++++++++++++++++++++-------- tests/test_fastl2lir.py | 38 +++++++++++++++++++++++++++++--------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5c3fd60..27d2fea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ "numpy>=1.22.0", - "scipy>=1.2.3", + "scipy>=1.8", "threadpoolctl>=2.1.0", "tqdm>=4.64.1", ] diff --git a/src/fastl2lir/fastl2lir.py b/src/fastl2lir/fastl2lir.py index f7c59db..6629cd5 100644 --- a/src/fastl2lir/fastl2lir.py +++ b/src/fastl2lir/fastl2lir.py @@ -21,6 +21,14 @@ def _solve_numpy(a, b): return np.linalg.solve(a, b) +def _get_solver_func(solver): + if solver == "scipy": + return _solve_scipy + if solver == "numpy": + return _solve_numpy + raise ValueError(f"Unknown solver: {solver!r}. Expected one of: 'scipy', 'numpy'.") + + class FastL2LiR(object): """Fast L2-regularized linear regression class.""" @@ -28,15 +36,26 @@ def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver="scipy" self.__W = W self.__b = b self.__verbose = verbose - - if solver == "scipy": - self.__solve = _solve_scipy - elif solver == "numpy": - self.__solve = _solve_numpy - else: - raise ValueError( - f"Unknown solver: {solver!r}. Expected one of: 'scipy', 'numpy'." + self.__solver = solver + self.__solve = _get_solver_func(solver) + + def __getstate__(self): + state = self.__dict__.copy() + state.pop("_FastL2LiR__solve", None) + return state + + def __setstate__(self, state): + self.__dict__.update(state) + solver_key = "_FastL2LiR__solver" + if solver_key not in self.__dict__: + warnings.warn( + "Unpickled a FastL2LiR object without solver information. " + "Restoring it with solver='numpy' for backward compatibility.", + UserWarning, + stacklevel=2, ) + self.__dict__[solver_key] = "numpy" + self.__solve = _get_solver_func(self.__dict__[solver_key]) @property def W(self): diff --git a/tests/test_fastl2lir.py b/tests/test_fastl2lir.py index 8e238c4..e9de8b0 100644 --- a/tests/test_fastl2lir.py +++ b/tests/test_fastl2lir.py @@ -150,20 +150,16 @@ def test_solver_numpy_matches_scipy(self): np.testing.assert_array_almost_equal(model_numpy.b, model_scipy.b) def test_solver_helpers_agree(self): - """FastL2LiR's numpy and scipy solvers agree on the same linear system.""" + """_solve_numpy and _solve_scipy return the same result.""" + from fastl2lir.fastl2lir import _solve_numpy, _solve_scipy + rng = np.random.default_rng(0) n = 50 A = rng.standard_normal((n, n)) - A = A.T @ A + np.eye(n) # symmetric positive definite + A = A.T @ A + np.eye(n) b = rng.standard_normal((n, 10)) - model_numpy = fastl2lir.FastL2LiR(solver="numpy") - model_scipy = fastl2lir.FastL2LiR(solver="scipy") - - result_numpy = model_numpy._FastL2LiR__solve(A, b) - result_scipy = model_scipy._FastL2LiR__solve(A, b) - - np.testing.assert_array_almost_equal(result_numpy, result_scipy) + np.testing.assert_array_almost_equal(_solve_numpy(A, b), _solve_scipy(A, b)) def test_solver_pickle(self): """FastL2LiR instance with scipy solver can be pickled and unpickled.""" @@ -175,6 +171,30 @@ def test_solver_pickle(self): restored = pickle.loads(pickle.dumps(model)) np.testing.assert_array_almost_equal(model.W, restored.W) np.testing.assert_array_almost_equal(model.b, restored.b) + np.testing.assert_array_almost_equal( + model.predict(data["x_te"]), restored.predict(data["x_te"]) + ) + + def test_solver_getstate(self): + """__getstate__ does not store the solver function.""" + model = fastl2lir.FastL2LiR(solver="scipy") + state = model.__getstate__() + self.assertNotIn("_FastL2LiR__solve", state) + self.assertEqual(state["_FastL2LiR__solver"], "scipy") + + def test_solver_legacy_setstate(self): + """__setstate__ falls back to numpy for legacy pickles without solver info.""" + model = fastl2lir.FastL2LiR.__new__(fastl2lir.FastL2LiR) + legacy_state = { + "_FastL2LiR__W": np.array([]), + "_FastL2LiR__b": np.array([]), + "_FastL2LiR__verbose": False, + } + with self.assertWarns(UserWarning): + model.__setstate__(legacy_state) + self.assertEqual(model._FastL2LiR__solver, "numpy") + data = np.load("./tests/testdata_basic.npz") + model.fit(data["x_tr"], data["y_2d"]) def test_solver_scipy_fallback(self): """scipy solver falls back from assume_a='pos' to 'sym' for indefinite matrix.""" From 7a58cb7e911c51a42d60a10d22c02958075b16a8 Mon Sep 17 00:00:00 2001 From: KenyaOtsuka Date: Thu, 18 Jun 2026 08:44:53 +0000 Subject: [PATCH 9/9] [fix] change default solver from scipy to numpy https://claude.ai/code/session_016xWp5Yz2EQz2F4TnWuaymM --- src/fastl2lir/fastl2lir.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastl2lir/fastl2lir.py b/src/fastl2lir/fastl2lir.py index 6629cd5..5a2170a 100644 --- a/src/fastl2lir/fastl2lir.py +++ b/src/fastl2lir/fastl2lir.py @@ -32,7 +32,7 @@ def _get_solver_func(solver): class FastL2LiR(object): """Fast L2-regularized linear regression class.""" - def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver="scipy"): + def __init__(self, W=np.array([]), b=np.array([]), verbose=False, solver="numpy"): self.__W = W self.__b = b self.__verbose = verbose