Thanks to visit codestin.com
Credit goes to github.com

Skip to content

ENH add Array API to newton-cg in LogisticRegression - #34412

Merged
OmarManzoor merged 11 commits into
scikit-learn:mainfrom
lorentzenchr:newton_cg_array_api
Jul 3, 2026
Merged

ENH add Array API to newton-cg in LogisticRegression#34412
OmarManzoor merged 11 commits into
scikit-learn:mainfrom
lorentzenchr:newton_cg_array_api

Conversation

@lorentzenchr

@lorentzenchr lorentzenchr commented Jun 29, 2026

Copy link
Copy Markdown
Member

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

  • Test/benchmark generation (the quadratic polynomial for negative curvature test)
  • Research and understanding

Any other comments?

@OmarManzoor OmarManzoor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sklearn/linear_model/_linear_loss.py
Comment thread sklearn/linear_model/_linear_loss.py Outdated
Comment thread sklearn/linear_model/_linear_loss.py
Comment thread sklearn/linear_model/_linear_loss.py Outdated
@lorentzenchr

Copy link
Copy Markdown
Member Author

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 😄

@OmarManzoor

Copy link
Copy Markdown
Contributor

You can directly push into this PR (how do you do that?)

Well I guess there might be a few ways to do it. What I do is

  • Add the other fork (yours for example) using git add fork (the repo url)
  • Then fetch the respective branch in the fork
  • Checkout a branch to track the fork one
  • Make changes to the respective branch and push directly to the fork
  • Remove the fork once all required changes are finalized.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lorentzenchr I am a bit confused about this change. Shouldn't we move the final coefs on the input namespace and device?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh okay this is just the default numpy case where everything including X is in numpy. Does this avoid a copy?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sklearn/utils/tests/test_optimize.py Outdated
@OmarManzoor

Copy link
Copy Markdown
Contributor

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.

@OmarManzoor

Copy link
Copy Markdown
Contributor

So we get a nice 2x speedup using mps on a reasonable sized dataset
n_samples, n_features, n_classes = 100000, 300, 20

Details
from 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

@OmarManzoor

Copy link
Copy Markdown
Contributor

With cuda on Colab with a similar script and a dataset of size

n_samples, n_features, n_classes = 100000, 300, 50

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.35X

So overall this looks good I think

@OmarManzoor OmarManzoor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thank you for this excellent work @lorentzenchr

@OmarManzoor

Copy link
Copy Markdown
Contributor

@ogrisel @virchan Could you kindly also review?

@OmarManzoor OmarManzoor added the Waiting for Second Reviewer First reviewer is done, need a second one! label Jun 30, 2026

@virchan virchan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ogrisel

ogrisel commented Jul 1, 2026

Copy link
Copy Markdown
Member

For reference, I triggered an Intel GPU CI run against this branch at:

and all tests are green.

@ogrisel ogrisel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I won't have time to double-check today, but there might be a numerical stability regression introduced in that PR. See below:

Comment thread sklearn/utils/optimize.py
Ap = fhess_p(psupi)
# check curvature
curv = psupi @ Ap
if 0 <= curv <= eps * psupi_norm2:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case is rigorously tested in test_glm.py:: test_glm_regression_unpenalized_hstacked and test_glm_regression_unpenalized_vstacked_X.

@ogrisel ogrisel Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@lorentzenchr lorentzenchr Jul 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ogrisel thanks for your analysis

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ;)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ogrisel ogrisel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is both an enhancement (array API support) and convergence robustness improvement/fix. I think it should be documented as such:

Comment thread doc/whats_new/upcoming_changes/array-api/34412.enhancement.rst
@OmarManzoor OmarManzoor removed the Waiting for Second Reviewer First reviewer is done, need a second one! label Jul 2, 2026
@OmarManzoor

Copy link
Copy Markdown
Contributor

I think the updated change log looks good. Thank you @lorentzenchr

Let's enable auto-merge!

@OmarManzoor
OmarManzoor enabled auto-merge (squash) July 3, 2026 06:35
@OmarManzoor
OmarManzoor merged commit add0e83 into scikit-learn:main Jul 3, 2026
36 checks passed
@github-project-automation github-project-automation Bot moved this to Done in Array API Jul 3, 2026
@lorentzenchr

Copy link
Copy Markdown
Member Author

Thanks to all involved in this PR and it's predecessors.

@lorentzenchr
lorentzenchr deleted the newton_cg_array_api branch July 3, 2026 06:45
@ogrisel ogrisel moved this to Done in Labs Jul 6, 2026
@ogrisel ogrisel added this to Labs Jul 6, 2026
prady0t pushed a commit to prady0t/scikit-learn that referenced this pull request Sep 2, 2026
@jeremiedbb jeremiedbb mentioned this pull request Sep 8, 2026
14 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

4 participants