ENH add Array API to newton-cg in LogisticRegression - #34412
Conversation
OmarManzoor
left a comment
There was a problem hiding this comment.
Thank you for the PR @lorentzenchr
I added an initial set of comments. I could also help with fixing the errors if you would like that.
Very well, go ahead. You can directly push into this PR (how do you do that?). You name is already listed in the whatsnew 😄 |
Well I guess there might be a few ways to do it. What I do is
|
| xp.asarray(w0.copy(order=coefs_order), dtype=X.dtype, device=device_) | ||
| ) | ||
| if _is_numpy_namespace(xp): | ||
| coefs.append(np.asarray(w0.copy(order=coefs_order), dtype=X.dtype)) |
There was a problem hiding this comment.
@lorentzenchr I am a bit confused about this change. Shouldn't we move the final coefs on the input namespace and device?
There was a problem hiding this comment.
Oh okay this is just the default numpy case where everything including X is in numpy. Does this avoid a copy?
There was a problem hiding this comment.
I think this change is to refactor. So, order is passed only to the NumPy namespace, because torch.clone() doesn't accept an order argument.
If this is the case, we could extend it to CuPy as well.
|
The failing test is only in the min dependencies CI: FAILED model_selection/tests/test_search.py::test_search_cv_sample_weight_equivalence[estimator0] - AssertionError:
Not equal to tolerance rtol=1e-07, atol=1e-09
Comparing the output of decision_function revealed that fitting with `sample_weight` is not equivalent to fitting with removed or repeated data points.
Mismatched elements: 1 / 360 (0.278%)
Max absolute difference: 5.24508881e-09
Max relative difference: 1.82473929e-07
x: array([[-2.271287, -0.429781, 2.701068],
[ 2.718942, -1.253827, -1.465115],
[ 2.703851, -1.935405, -0.768446],...
y: array([[-2.271287, -0.429781, 2.701068],
[ 2.718942, -1.253827, -1.465115],
[ 2.703851, -1.935405, -0.768446],...
= 1 failed, 38113 passed, 9840 skipped, 230 xfailed, 119 xpassed, 5732 warnings in 835.87s (0:13:55) =I think we can slightly adjust the tolerance of this test and it should pass. |
|
So we get a nice 2x speedup using Detailsfrom time import time
import numpy as np
import torch as xp
from tqdm import tqdm
from sklearn import config_context
from sklearn.linear_model import LogisticRegression
n_samples, n_features, n_classes = 100000, 300, 20
device = "mps"
n_iter = 10
X_np = np.random.rand(n_samples, n_features).astype(np.float32)
y_np = np.random.randint(0, n_classes, n_samples)
numpy_fit_times = []
numpy_predict_times = []
for _ in tqdm(range(n_iter), desc="Numpy"):
lr = LogisticRegression(
C=0.01,
solver="newton-cg",
max_iter=500,
tol=1e-4,
)
start = time()
lr.fit(X_np, y_np)
numpy_fit_times.append(round(time() - start, 3))
start = time()
pred = lr.predict_proba(X_np)
numpy_predict_times.append(round(time() - start, 3))
avg_numpy_fit = round(sum(numpy_fit_times) / n_iter, 3)
avg_numpy_predict = round(sum(numpy_predict_times) / n_iter, 3)
torch_fit_times = []
torch_predict_times = []
X_xp = xp.rand((n_samples, n_features), dtype=xp.float32, device=device)
y_xp = xp.randint(0, n_classes, (n_samples,), device=device)
for _ in tqdm(range(n_iter), desc=f"Torch {device}"):
with config_context(array_api_dispatch=True):
lr = LogisticRegression(
C=0.01,
solver="newton-cg",
max_iter=500,
tol=1e-4,
)
start = time()
lr.fit(X_xp, y_xp)
torch_fit_times.append(round(time() - start, 3))
start = time()
pred = lr.predict_proba(X_xp)
first = float(pred[0, 0])
torch_predict_times.append(round(time() - start, 3))
avg_torch_fit = round(sum(torch_fit_times) / n_iter, 3)
avg_torch_predict = round(sum(torch_predict_times) / n_iter, 3)
print(f"Average fit time numpy: {avg_numpy_fit}")
print(f"Average fit time torch {device}: {avg_torch_fit}")
print(f"Torch {device} fit speedup: {round(avg_numpy_fit / avg_torch_fit, 2)}X")
print(f"Average predict time numpy: {avg_numpy_predict}")
print(f"Average predict time torch {device}: {avg_torch_predict}")
print(
f"Torch {device} predict speedup: {round(avg_numpy_predict / avg_torch_predict, 2)}"
"X"
)
Average fit time numpy: 0.288
Average fit time torch mps: 0.158
Torch mps fit speedup: 1.82X
Average predict time numpy: 0.018
Average predict time torch mps: 0.009
Torch mps predict speedup: 2.0X |
|
With
Average fit time numpy: 4.02
Average fit time torch cuda: 0.41
Torch cuda fit speedup: 9.8X
Average predict time numpy: 0.227
Average predict time torch cuda: 0.02
Torch cuda predict speedup: 11.35XSo overall this looks good I think |
OmarManzoor
left a comment
There was a problem hiding this comment.
LGTM. Thank you for this excellent work @lorentzenchr
virchan
left a comment
There was a problem hiding this comment.
LGTM! Thanks, @lorentzenchr!
I think we can merge this once Omar's questions are addressed, because I'm curious too!
| xp.asarray(w0.copy(order=coefs_order), dtype=X.dtype, device=device_) | ||
| ) | ||
| if _is_numpy_namespace(xp): | ||
| coefs.append(np.asarray(w0.copy(order=coefs_order), dtype=X.dtype)) |
There was a problem hiding this comment.
I think this change is to refactor. So, order is passed only to the NumPy namespace, because torch.clone() doesn't accept an order argument.
If this is the case, we could extend it to CuPy as well.
|
For reference, I triggered an Intel GPU CI run against this branch at: and all tests are green. |
ogrisel
left a comment
There was a problem hiding this comment.
I won't have time to double-check today, but there might be a numerical stability regression introduced in that PR. See below:
| Ap = fhess_p(psupi) | ||
| # check curvature | ||
| curv = psupi @ Ap | ||
| if 0 <= curv <= eps * psupi_norm2: |
There was a problem hiding this comment.
This guard against division by near zero yet positive curv values (at line alphai = dri0 / curv) seems to have been dropped.
It might be a problem for linearly separable data with collinear features and very low regularization (C >= 1e6). In this case we could expect the curvature to be very close to zero and the division might become numerically unstable.
There was a problem hiding this comment.
This case is rigorously tested in test_glm.py:: test_glm_regression_unpenalized_hstacked and test_glm_regression_unpenalized_vstacked_X.
There was a problem hiding this comment.
For information, I also started to conduct some empirical evaluation of this particular change with extra instrumentation to count numbers of iterations/LS fallbacks and overall runtime, and it seems that this PR is actually an improvement overall (contrary to what I originally thought).
There was a problem hiding this comment.
This PR seems to cause more line search warnings to be triggered but in the end a valid step is still found (even for large updates in low curvature regions) and as a result, the solution quality is better.
There was a problem hiding this comment.
I vibecoded the following script:
Details
"""Cross-branch benchmark for LogisticRegression(solver="newton-cg").
Minimal, public-API only (no monkeypatching, no private instrumentation). Run
the SAME script once per branch and diff the two outputs:
git checkout main && mamba run -n dev python bench_newton_cg_branches.py
git checkout <this> && mamba run -n dev python bench_newton_cg_branches.py
For each data shape it aggregates, over several seeds:
* number of outer iterations via ``clf.n_iter_``
* fit duration
* warning counts (ConvergenceWarning / line-search / other)
* convergence success rate (converged := max(n_iter_) < max_iter)
"Ill-conditioned" shapes use nearly-collinear column pairs with the label
signal placed along the tiny-curvature (difference) directions; these are the
problems that exercise the small/zero-curvature CG guard present on ``main``.
Everything is unregularized (C=inf) so the Hessian is not lifted away from
singularity.
"""
import subprocess
import time
import warnings
import numpy as np
import sklearn
from sklearn.exceptions import ConvergenceWarning
from sklearn.linear_model import LogisticRegression
TOL = 1e-8
MAX_ITER = 300
N_SEEDS = 30
def git_label():
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"], text=True
).strip()
commit = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"], text=True
).strip()
return f"{branch}@{commit}"
except Exception: # noqa: BLE001
return "unknown"
def make_problem(kind, seed, n_samples, n_features, dtype):
"""Build a binary-classification problem of the requested shape/kind."""
rng = np.random.RandomState(seed)
if kind == "wellcond":
X = rng.randn(n_samples, n_features)
w = rng.randn(n_features)
y = (X @ w + 0.5 * rng.randn(n_samples) > 0).astype(int)
elif kind == "illcond":
# Nearly-collinear column pairs; the signal lives along the tiny-
# curvature "difference" directions => low-curvature CG steps.
delta_exp = (-6, -3) if np.dtype(dtype) == np.float32 else (-11, -7)
delta = 10 ** rng.uniform(*delta_exp)
cols, signal = [], np.zeros(n_samples)
for _ in range(n_features // 2):
u, v = rng.randn(n_samples), rng.randn(n_samples)
cols += [u, u + delta * v]
signal += rng.uniform(0.5, 4.0) * v
while len(cols) < n_features: # pad if n_features is odd
z = rng.randn(n_samples)
cols.append(z)
signal += rng.uniform(0.5, 2.0) * z
X = np.column_stack(cols)
y = (signal + 0.3 * rng.randn(n_samples) > 0).astype(int)
else:
raise ValueError(kind)
return X.astype(dtype), y
def run_config(kind, n_samples, n_features, dtype):
iters, times = [], []
n_converged = w_conv = w_ls = w_other = 0
for seed in range(N_SEEDS):
X, y = make_problem(kind, seed, n_samples, n_features, dtype)
clf = LogisticRegression(
solver="newton-cg", C=np.inf, max_iter=MAX_ITER, tol=TOL
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
t0 = time.perf_counter()
clf.fit(X, y)
times.append(time.perf_counter() - t0)
n_it = int(np.max(clf.n_iter_))
iters.append(n_it)
if n_it < MAX_ITER:
n_converged += 1
for wm in caught:
msg = str(wm.message).lower()
if issubclass(wm.category, ConvergenceWarning):
w_conv += 1
elif "line search" in msg or "linesearch" in wm.category.__name__.lower():
w_ls += 1
else:
w_other += 1
return dict(
iters_mean=float(np.mean(iters)),
iters_max=int(np.max(iters)),
t_total=float(np.sum(times)),
t_mean_ms=float(np.mean(times)) * 1e3,
conv_rate=100.0 * n_converged / N_SEEDS,
w_conv=w_conv,
w_ls=w_ls,
w_other=w_other,
)
CONFIGS = [
# (kind, n_samples, n_features, dtype)
("wellcond", 500, 20, np.float64),
("wellcond", 3000, 50, np.float64),
("illcond", 300, 2, np.float64),
("illcond", 1000, 10, np.float64),
("illcond", 2000, 30, np.float64),
("illcond", 300, 2, np.float32),
("illcond", 1000, 10, np.float32),
]
def main():
print(f"sklearn {sklearn.__version__} git {git_label()}")
print(f"solver=newton-cg C=inf tol={TOL} max_iter={MAX_ITER} "
f"n_seeds={N_SEEDS}\n")
hdr = (f"{'kind':9} {'shape':12} {'dtype':7} {'iter_mean':>9} {'iter_max':>8} "
f"{'t_total_s':>9} {'t_mean_ms':>9} {'conv_%':>7} "
f"{'W_conv':>6} {'W_ls':>6} {'W_oth':>6}")
print(hdr)
print("-" * len(hdr))
for kind, n, p, dtype in CONFIGS:
m = run_config(kind, n, p, dtype)
shape = f"{n}x{p}"
print(f"{kind:9} {shape:12} {np.dtype(dtype).name:7} "
f"{m['iters_mean']:>9.1f} {m['iters_max']:>8d} "
f"{m['t_total']:>9.3f} {m['t_mean_ms']:>9.2f} {m['conv_rate']:>7.1f} "
f"{m['w_conv']:>6d} {m['w_ls']:>6d} {m['w_other']:>6d}")
if __name__ == "__main__":
main()Here are the results:
main
kind shape dtype iter_mean iter_max t_total_s t_mean_ms conv_% W_conv W_ls W_oth
wellcond 500x20 float64 13.2 27 0.101 3.37 100.0 0 0 0
wellcond 3000x50 float64 11.6 12 0.259 8.63 100.0 0 0 0
illcond 300x2 float64 22.7 300 0.072 2.39 93.3 2 0 0
illcond 1000x10 float64 26.8 300 0.192 6.41 93.3 2 8 0
illcond 2000x30 float64 14.3 126 0.186 6.19 100.0 0 4 0
illcond 300x2 float32 83.8 300 0.233 7.75 76.7 7 68 0
illcond 1000x10 float32 122.5 300 0.536 17.86 63.3 11 57 0
newton_cg_array_api@3e27c19185
kind shape dtype iter_mean iter_max t_total_s t_mean_ms conv_% W_conv W_ls W_oth
wellcond 500x20 float64 13.2 27 0.121 4.04 100.0 0 0 0
wellcond 3000x50 float64 11.6 12 0.288 9.60 100.0 0 0 0
illcond 300x2 float64 4.2 17 0.032 1.05 100.0 0 2 0
illcond 1000x10 float64 8.6 43 0.110 3.66 100.0 0 16 0
illcond 2000x30 float64 7.8 21 0.185 6.17 100.0 0 34 0
illcond 300x2 float32 9.5 33 0.169 5.63 100.0 0 68 0
illcond 1000x10 float32 10.1 20 0.217 7.23 100.0 0 68 0
So as expected, very little impact on well conditioned problems and dramatically improved convergence (speed and success rate) on severely ill-conditioned problems.
There was a problem hiding this comment.
that this PR is actually an improvement overall (contrary to what I originally thought)
Out of interest: why did you first think that?
FYI: If someone finds a case with near or exact zero curvature where CG fails, I can reinsert the gradient step failsafe. Or if some reviewer insists.
I just never encountered it (but can construct an artificial case).
There was a problem hiding this comment.
that this PR is actually an improvement overall (contrary to what I originally thought)
Out of interest: why did you first think that?
I naively thought that the previous near-zero curvature guard was there for a valid reason ;)
There was a problem hiding this comment.
It was. It predates the research (on negative curvature) that it implemented here. Honestly, I am also a bit surprised that the new way works so well.
|
I think the updated change log looks good. Thank you @lorentzenchr Let's enable auto-merge! |
|
Thanks to all involved in this PR and it's predecessors. |
) Co-authored-by: Omar Salman <[email protected]>
Reference Issues/PRs
Follow-up of #33765, #34321 and contribution to #26024.
What does this implement/fix? Explain your changes.
This PR makes
solver="newton-cg"100% Array API compatible.In contrast to lbfgs, no conversion to (CPU) numpy is required anywhere.
AI usage disclosure
Any other comments?