# Matrix derivative of determinant in non-matrix expressions

```python
from sympy import *
from sympy.abc import i, j, k

X = MatrixSymbol("X", 3, 3)

dX = Determinant(X)

expr = k*dX

expr.diff(X)

expr = 1/dX
expr.diff(X)
```

This issue is related to the NotImplementedError issue.

This one works:
```python
expr = k + dX
expr.diff(X)
```

diff --git a/sympy/matrices/expressions/tests/test_derivatives.py b/sympy/matrices/expressions/tests/test_derivatives.py
index 45b78d1abeea..31c562dbcb0f 100644
--- a/sympy/matrices/expressions/tests/test_derivatives.py
+++ b/sympy/matrices/expressions/tests/test_derivatives.py
@@ -150,6 +150,18 @@ def test_issue_28708():
     assert expr.diff(X) == - X.T.inv()/Determinant(X)

+def test_issue_28838_det_in_non_matrix_expr():
+
+    expr = k * Determinant(X)
+    assert expr.diff(X) == k * Determinant(X) * X.T ** (-1)
+
+    expr = 1 / Determinant(X)
+    assert expr.diff(X) == -1 / Determinant(X) * X.T ** (-1)
+
+    expr = k / Determinant(X)
+    assert expr.diff(X) == -k / Determinant(X) * X.T ** (-1)
+
+
 def test_matrix_derivative_with_inverse():

     # Cookbook example 61:
diff --git a/sympy/tensor/array/expressions/tests/test_array_expressions.py b/sympy/tensor/array/expressions/tests/test_array_expressions.py
index 439d9363fa13..afe8eed053b8 100644
--- a/sympy/tensor/array/expressions/tests/test_array_expressions.py
+++ b/sympy/tensor/array/expressions/tests/test_array_expressions.py
@@ -819,6 +819,9 @@ def test_array_expr_as_explicit_with_explicit_component_arrays():

 def test_array_sum():
+    X = MatrixSymbol("X", k, k)
+    Y = MatrixSymbol("Y", k, k)
+
     expr = ArraySum(X, (i, 1, j))
     assert isinstance(expr, Sum)
     assert expr.doit() == j*X
@@ -842,7 +845,7 @@ def test_array_sum():
     assert expr.doit().dummy_eq(ArraySum(ArrayTensorProduct(X**sin(m), i*Y), (m, 1, j)))

     expr = ArrayContraction(ArraySum(X, (i, 0, j)), (0, 1))
-    # assert expr.doit() == ArrayContraction(j*T, (0, 1))  # TODO: Not working!
+    assert expr.doit() == ArrayContraction((j + 1)*X, (0, 1))

     T = MatrixSymbol("T", 3, 3)
     expr = ArrayContraction(ArraySum(T, (i, 1, j)), (0, 1))
diff --git a/sympy/tensor/array/expressions/tests/test_convert_array_to_matrix.py b/sympy/tensor/array/expressions/tests/test_convert_array_to_matrix.py
index ac205806acdb..4f0ff1aa1e2d 100644
--- a/sympy/tensor/array/expressions/tests/test_convert_array_to_matrix.py
+++ b/sympy/tensor/array/expressions/tests/test_convert_array_to_matrix.py
@@ -694,7 +694,7 @@ def test_convert_array_elementwise_function_to_matrix():
     d = Dummy("d")

     expr = ArrayElementwiseApplyFunc(Lambda(d, sin(d)), x.T*y)
-    assert convert_array_to_matrix(expr).dummy_eq(sin((x.T*y)[0, 0]))
+    assert convert_array_to_matrix(expr) == sin(MatrixElement(x.T*y, 0, 0))

     expr = ArrayElementwiseApplyFunc(Lambda(d, d**2), x.T*y)
     assert convert_array_to_matrix(expr) == (x.T*y)**2
diff --git a/sympy/tensor/array/expressions/tests/test_convert_matrix_to_array.py b/sympy/tensor/array/expressions/tests/test_convert_matrix_to_array.py
index a63cc4e094e0..121bf6c3369f 100644
--- a/sympy/tensor/array/expressions/tests/test_convert_matrix_to_array.py
+++ b/sympy/tensor/array/expressions/tests/test_convert_matrix_to_array.py
@@ -1,4 +1,4 @@
-from sympy import Lambda, KroneckerProduct, sqrt
+from sympy import Lambda, KroneckerProduct, sqrt, sin
 from sympy.core.symbol import symbols, Dummy
 from sympy.matrices.expressions.hadamard import (HadamardPower, HadamardProduct)
 from sympy.matrices.expressions.inverse import Inverse
@@ -140,3 +140,15 @@ def test_arrayexpr_convert_matrix_to_array():
     expr = sqrt(Trace(X.T*X))
     cg = convert_matrix_to_array(expr)
     assert cg.dummy_eq(ArrayElementwiseApplyFunc(sqrt, ArrayContraction(ArrayTensorProduct(X, X), (0, 2), (1, 3))))
+
+    expr = i**3
+    assert convert_matrix_to_array(expr) == expr
+
+    expr = i**j
+    assert convert_matrix_to_array(expr) == expr
+
+    expr = X ** sin(i)
+    assert convert_matrix_to_array(expr) == expr
+
+    expr = X ** sin(1)
+    assert convert_matrix_to_array(expr) == expr

diff --git a/sympy/tensor/array/expressions/array_expressions.py b/sympy/tensor/array/expressions/array_expressions.py
index aaddf5a2060c..9ad840d61843 100644
--- a/sympy/tensor/array/expressions/array_expressions.py
+++ b/sympy/tensor/array/expressions/array_expressions.py
@@ -850,7 +850,7 @@ def _canonicalize(self):
         if isinstance(expr, ArrayAdd):
             return self._ArrayDiagonal_denest_ArrayAdd(expr, *diagonal_indices)
         if isinstance(expr, ArrayDiagonal):
-            return self._ArrayDiagonal_denest_ArrayDiagonal(expr, *diagonal_indices)
+            return self._ArrayDiagonal_denest_ArrayDiagonal(expr, get_rank(self), *diagonal_indices)
         if isinstance(expr, PermuteDims):
             return self._ArrayDiagonal_denest_PermuteDims(expr, *diagonal_indices)
         if isinstance(expr, (ZeroArray, ZeroMatrix)):
@@ -886,9 +886,9 @@ def diagonal_indices(self):
         return self.args[1:]

     @staticmethod
-    def _flatten(expr: ArrayDiagonal, *outer_diagonal_indices):
-        inddown = ArrayDiagonal._push_indices_down(outer_diagonal_indices, list(range(get_rank(expr))), get_rank(expr))
-        inddown = tuple(i for i in inddown if i)
+    def _flatten(expr: ArrayDiagonal, outer_dims, *outer_diagonal_indices):
+        inddown = ArrayDiagonal._push_indices_down(outer_diagonal_indices, list(range(outer_dims)), get_rank(expr))
+        inddown = tuple(i for i in inddown)
         inddow2 = ArrayDiagonal._push_indices_down(expr.diagonal_indices, inddown, get_rank(expr.expr))
         new_diag_indices = []
         for i in inddow2:
@@ -2031,6 +2031,8 @@ def nest_permutation(expr):

 def _array_tensor_product(*args, **kwargs):
+    if all(not isinstance(i, (_ArrayExpr, _CodegenArrayAbstract)) and get_shape(i) == () for i in args):
+        return Mul.fromiter(args)
     return ArrayTensorProduct(*args, canonicalize=True, **kwargs)

diff --git a/sympy/tensor/array/expressions/arrayexpr_derivatives.py b/sympy/tensor/array/expressions/arrayexpr_derivatives.py
index 13fd8920836a..ede5b905267d 100644
--- a/sympy/tensor/array/expressions/arrayexpr_derivatives.py
+++ b/sympy/tensor/array/expressions/arrayexpr_derivatives.py
@@ -2,7 +2,7 @@
 from functools import reduce, singledispatch

 from sympy.core.singleton import S
-from sympy import MatrixBase, derive_by_array, Integer, Determinant, Function, MatPow, Dummy
+from sympy import MatrixBase, derive_by_array, Integer, Determinant, Function, MatPow, Dummy, Pow, Mul
 from sympy.tensor.array import NDimArray
 from sympy.core.expr import Expr
 from sympy.matrices.expressions.hadamard import HadamardProduct
@@ -37,6 +37,20 @@ def _(expr: Expr, x: _ArrayExpr):
     return ZeroArray(*x.shape)

+@array_derive.register(Mul)
+def _(expr: Mul, x: _ArrayExpr):
+    args = expr.args
+    return ArrayAdd.fromiter([
+        _array_tensor_product(Mul.fromiter(args[:i]), array_derive(arg, x), Mul.fromiter(args[(i+1):]))
+        for i, arg in enumerate(args)
+    ])
+
+
+@array_derive.register(Pow)
+def _(expr: Pow, x: _ArrayExpr):
+    return Pow._eval_derivative(expr, x)
+
+
 @array_derive.register(Function)
 def _(expr: Function, x: _ArrayExpr):
     if len(expr.args) != 1:
diff --git a/sympy/tensor/array/expressions/from_array_to_matrix.py b/sympy/tensor/array/expressions/from_array_to_matrix.py
index 19b985d0ee02..0ecacda423f2 100644
--- a/sympy/tensor/array/expressions/from_array_to_matrix.py
+++ b/sympy/tensor/array/expressions/from_array_to_matrix.py
@@ -570,7 +570,7 @@ def _(expr: ElementwiseApplyFunction):
     subexpr, removed = _remove_trivial_dims(expr.expr)
     if subexpr.shape == (1, 1) and isinstance(subexpr, MatrixExpr):
         # TODO: move this to ElementwiseApplyFunction
-        return expr.function(subexpr[0, 0]), removed + [0, 1]
+        return expr.function(MatrixElement(subexpr, 0, 0)), removed + [0, 1]
     return ElementwiseApplyFunction(expr.function, subexpr), []

diff --git a/sympy/tensor/array/expressions/from_matrix_to_array.py b/sympy/tensor/array/expressions/from_matrix_to_array.py
index 3d49512f0069..929c0db1d54d 100644
--- a/sympy/tensor/array/expressions/from_matrix_to_array.py
+++ b/sympy/tensor/array/expressions/from_matrix_to_array.py
@@ -15,7 +15,13 @@
 from sympy.matrices.expressions.matexpr import MatrixExpr
 from sympy.tensor.array.expressions.array_expressions import \
     ArrayElementwiseApplyFunc, _array_tensor_product, _array_contraction, \
-    _array_diagonal, _array_add, _permute_dims, Reshape, get_shape
+    _array_diagonal, _array_add, _permute_dims, Reshape, get_shape, _ArrayExpr, _CodegenArrayAbstract
+
+
+def _array_elementwise_apply_func(function, element):
+    if not isinstance(element, (MatrixExpr, _ArrayExpr, _CodegenArrayAbstract)):
+        return function(element)
+    return ArrayElementwiseApplyFunc(function, element)

 def convert_matrix_to_array(expr: Basic) -> Basic:
@@ -58,12 +64,12 @@ def convert_matrix_to_array(expr: Basic) -> Basic:
         if (expr.exp > 0) == True:
             base_conv = convert_matrix_to_array(base)
             if get_shape(base_conv) == ():
-                return ArrayElementwiseApplyFunc(lambda x: x**expr.exp, base_conv)
+                return _array_elementwise_apply_func(lambda x: x**expr.exp, base_conv)
             else:
                 return _array_tensor_product(*[base_conv for i in range(expr.exp)])
         elif get_shape(base) == ():
             d = Dummy("d")
-            return ArrayElementwiseApplyFunc(Lambda(d, d**expr.exp), base)
+            return _array_elementwise_apply_func(Lambda(d, d**expr.exp), base)
         else:
             return expr
     elif isinstance(expr, MatPow):
@@ -71,7 +77,7 @@ def convert_matrix_to_array(expr: Basic) -> Basic:
         if expr.exp.is_Integer != True:
             if get_shape(expr) == (1, 1):
                 b = symbols("b", cls=Dummy)
-                return ArrayElementwiseApplyFunc(Lambda(b, b ** expr.exp), convert_matrix_to_array(base))
+                return _array_elementwise_apply_func(Lambda(b, b ** expr.exp), convert_matrix_to_array(base))
             return expr
         elif (expr.exp > 0) == True:
             return convert_matrix_to_array(MatMul.fromiter(base for i in range(expr.exp)))
@@ -87,7 +93,7 @@ def convert_matrix_to_array(expr: Basic) -> Basic:
             return convert_matrix_to_array(HadamardProduct.fromiter(base for i in range(exp)))
         else:
             d = Dummy("d")
-            return ArrayElementwiseApplyFunc(Lambda(d, d**exp), base)
+            return _array_elementwise_apply_func(Lambda(d, d**exp), base)
     elif isinstance(expr, KroneckerProduct):
         kp_args = [convert_matrix_to_array(arg) for arg in expr.args]
         permutation = [2*i for i in range(len(kp_args))] + [2*i + 1 for i in range(len(kp_args))]


# [Bug]: ax.relim() ignores scatter artist

### Bug summary

**ax.relim()** completely ignores changes to scatterplots with **.set_offsets()**.

### Code for reproduction

```Python
import tkinter as tk
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
from matplotlib.figure import Figure
import numpy as np

class App:
    def __init__(self, root):
        self.root = root
        fig = Figure(figsize=(5, 5), dpi=100)
        self.ax = fig.add_subplot(111)

        self.canvas = FigureCanvasTkAgg(fig, root)
        self.canvas.get_tk_widget().pack(fill="both", expand=True)

        self.toolbar = NavigationToolbar2Tk(self.canvas, root)

        # Create artists once
        self.scatter = self.ax.scatter([], [])
        self.line, = self.ax.plot([], [])

        root.after(1000, self.update_plot)

    def update_plot(self):
        # New data every update
        xs = np.linspace(0, 10, 100)
        ys = np.sin(xs) + np.random.normal(scale=0.1, size=100)

        # Update artists
        self.scatter.set_offsets(np.column_stack((xs, ys)))
        #self.line.set_data(xs, ys) #Uncomment this to see the expected behaviour

        # Autoscale (not working)
        self.ax.relim()
        self.ax.autoscale_view()

        self.canvas.draw_idle()

        # Keep updating
        self.root.after(1000, self.update_plot)

root = tk.Tk()
App(root)
root.mainloop()
```

### Actual outcome

### Expected outcome

### Additional information

_No response_

### Operating system

Windows

### Matplotlib Version

3.9.1

### Matplotlib Backend

tkAgg

### Python version

3.11.9

### Jupyter version

None

### Installation

pip

diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index 46843841fe93..6e6614090311 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -10159,3 +10159,27 @@ def test_animated_artists_not_drawn_by_default():

     mocked_im_draw.assert_not_called()
     mocked_ln_draw.assert_not_called()
+
+
+def test_relim_updates_scatter_offsets():
+    import numpy as np
+    import matplotlib.pyplot as plt
+
+    fig, ax = plt.subplots()
+
+    xs = np.linspace(0, 10, 100)
+    ys = np.sin(xs)
+    scatter = ax.scatter(xs, ys)
+
+    # Shift scatter upward
+    new_ys = np.sin(xs) + 5
+    scatter.set_offsets(np.column_stack((xs, new_ys)))
+
+    ax.relim()
+    ax.autoscale_view()
+
+    ymin, ymax = ax.get_ylim()
+
+    # New limits should reflect shifted data
+    assert ymin > 3
+    assert ymax > 5

diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py
index f89c231815dc..9e515a7a8aa0 100644
--- a/lib/matplotlib/axes/_base.py
+++ b/lib/matplotlib/axes/_base.py
@@ -29,6 +29,7 @@
 import matplotlib.text as mtext
 import matplotlib.ticker as mticker
 import matplotlib.transforms as mtransforms
+import matplotlib.collections as mcollections

 _log = logging.getLogger(__name__)

@@ -2415,6 +2416,12 @@ def _update_image_limits(self, image):
         xmin, xmax, ymin, ymax = image.get_extent()
         self.axes.update_datalim(((xmin, ymin), (xmax, ymax)))

+    def _update_collection_limits(self, collection):
+     offsets = collection.get_offsets()
+     if offsets is not None and len(offsets):
+        self.update_datalim(offsets)
+
+
     def add_line(self, line):
         """
         Add a `.Line2D` to the Axes; return the line.
@@ -2605,6 +2612,8 @@ def relim(self, visible_only=False):
                     self._update_patch_limits(artist)
                 elif isinstance(artist, mimage.AxesImage):
                     self._update_image_limits(artist)
+                elif isinstance(artist, mcollections.Collection):
+                 self._update_collection_limits(artist)

     def update_datalim(self, xys, updatex=True, updatey=True):
         """


# [ENH]: Modifier key to discretize rotations for 3D plots

### Problem

Inspired by https://github.com/matplotlib/matplotlib/issues/23544 and talked about on the dev call today.

Want to make it easier to find a repeatable view angles when rotating 3D plots.

### Proposed solution

Add a modifier key (ctrl) so that when rotating 3D plots with this pressed, the view snaps to every 5 degree azimuth/elevation/roll increment instead of rotating continuously. Maybe 15 degrees, should play with it. 10 probably not good because it excludes the 45 deg angle.

diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
index e9809ce2a106..314a3dcdb3b0 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
@@ -2785,3 +2785,64 @@ def test_axis_get_tightbbox_includes_offset_text():
             f"bbox.x1 ({bbox.x1}) should be >= offset_bbox.x1 ({offset_bbox.x1})"
         assert bbox.y1 >= offset_bbox.y1 - 1e-6, \
             f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})"
+
+
+def test_ctrl_rotation_snaps_to_5deg():
+    fig = plt.figure()
+    ax = fig.add_subplot(projection='3d')
+
+    initial = (12.3, 33.7, 2.2)
+    ax.view_init(*initial)
+    fig.canvas.draw()
+
+    s = 0.25
+    step = plt.rcParams["axes3d.snap_rotation"]
+
+    # First rotation without Ctrl
+    with mpl.rc_context({'axes3d.mouserotationstyle': 'azel'}):
+        MouseEvent._from_ax_coords(
+            "button_press_event", ax, (0, 0), MouseButton.LEFT
+        )._process()
+
+        MouseEvent._from_ax_coords(
+            "motion_notify_event",
+            ax,
+            (s * ax._pseudo_w, s * ax._pseudo_h),
+            MouseButton.LEFT,
+        )._process()
+
+    fig.canvas.draw()
+
+    rotated_elev = ax.elev
+    rotated_azim = ax.azim
+    rotated_roll = ax.roll
+
+    # Reset before ctrl rotation
+    ax.view_init(*initial)
+    fig.canvas.draw()
+
+    # Now rotate with Ctrl
+    with mpl.rc_context({'axes3d.mouserotationstyle': 'azel'}):
+        MouseEvent._from_ax_coords(
+            "button_press_event", ax, (0, 0), MouseButton.LEFT
+        )._process()
+
+        MouseEvent._from_ax_coords(
+            "motion_notify_event",
+            ax,
+            (s * ax._pseudo_w, s * ax._pseudo_h),
+            MouseButton.LEFT,
+            key="control"
+        )._process()
+
+    fig.canvas.draw()
+
+    expected_elev = step * round(rotated_elev / step)
+    expected_azim = step * round(rotated_azim / step)
+    expected_roll = step * round(rotated_roll / step)
+
+    assert ax.elev == pytest.approx(expected_elev)
+    assert ax.azim == pytest.approx(expected_azim)
+    assert ax.roll == pytest.approx(expected_roll)
+
+    plt.close(fig)

diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py
index e0867fc3d999..a215bee9ad47 100644
--- a/lib/matplotlib/rcsetup.py
+++ b/lib/matplotlib/rcsetup.py
@@ -1181,6 +1181,7 @@ def _convert_validator_spec(key, conv):
     "axes3d.mouserotationstyle": ["azel", "trackball", "sphere", "arcball"],
     "axes3d.trackballsize": validate_float,
     "axes3d.trackballborder": validate_float,
+    "axes3d.snap_rotation": validate_float,

     # scatter props
     "scatter.marker":     _validate_marker,
@@ -2143,6 +2144,12 @@ class _Param:
         description="trackball border width, in units of the Axes bbox (only for "
                     "'sphere' and 'arcball' style)"
     ),
+    _Param(
+        "axes3d.snap_rotation",
+        default=5.0,
+        validator=validate_float,
+        description="Snap angle (in degrees) for 3D rotation when holding Control."
+   ),
     _Param(
         "xaxis.labellocation",
         default="center",
diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py
index d2e12c6e08d9..93cd724080d6 100644
--- a/lib/matplotlib/typing.py
+++ b/lib/matplotlib/typing.py
@@ -222,6 +222,7 @@
     "axes3d.grid",
     "axes3d.mouserotationstyle",
     "axes3d.trackballborder",
+    "axes3d.snap_rotation",
     "axes3d.trackballsize",
     "axes3d.xaxis.panecolor",
     "axes3d.yaxis.panecolor",
diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py
index 53beaf97ffeb..d915fb0c4f17 100644
--- a/lib/mpl_toolkits/mplot3d/axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/axes3d.py
@@ -1597,6 +1597,11 @@ def _on_move(self, event):

                 q = dq * q
                 elev, azim, roll = np.rad2deg(q.as_cardan_angles())
+            step = mpl.rcParams["axes3d.snap_rotation"]
+            if step > 0 and getattr(event, "key", None) == "control":
+                elev = step * round(elev / step)
+                azim = step * round(azim / step)
+                roll = step * round(roll / step)

             # update view
             vertical_axis = self._axis_names[self._vertical_axis]


# [ENH]: move .matplotlib folder from %USERPROFILE% on Windows

### Problem

The folder contains "fontlist-v330.json" in my case.

### Proposed solution

Move the directory to a subdirectory in %APPDATA%, where it belongs (as the name suggests).

### Additional context and prior art

_No response_

diff --git a/lib/matplotlib/tests/test_matplotlib.py b/lib/matplotlib/tests/test_matplotlib.py
index d0a3f8c617e1..f17dc2e42354 100644
--- a/lib/matplotlib/tests/test_matplotlib.py
+++ b/lib/matplotlib/tests/test_matplotlib.py
@@ -94,3 +94,49 @@ def test_get_executable_info_timeout(mock_check_output):

     with pytest.raises(matplotlib.ExecutableNotFoundError, match='Timed out'):
         matplotlib._get_executable_info.__wrapped__('inkscape')
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
+def test_configdir_uses_localappdata_on_windows(tmp_path):
+    """Test that on Windows, config/cache dir uses LOCALAPPDATA for fresh installs."""
+    localappdata = tmp_path / "AppData/Local"
+    localappdata.mkdir(parents=True)
+    # Set USERPROFILE to tmp_path so the old location check finds nothing
+    fake_home = tmp_path / "home"
+    fake_home.mkdir()
+
+    proc = subprocess_run_for_testing(
+        [sys.executable, "-c",
+         "import matplotlib; print(matplotlib.get_configdir())"],
+        env={**os.environ, "LOCALAPPDATA": str(localappdata),
+             "USERPROFILE": str(fake_home), "MPLCONFIGDIR": ""},
+        capture_output=True, text=True, check=True)
+
+    configdir = proc.stdout.strip()
+    # On Windows with no existing old config, should use LOCALAPPDATA\matplotlib
+    assert configdir == str(localappdata / "matplotlib")
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
+def test_configdir_uses_userprofile_on_windows_if_exists(tmp_path):
+    """
+    Test that on Windows, config/cache dir uses %USERPROFILE% if .matplotlib
+    exists.
+    """
+    localappdata = tmp_path / "AppData/Local"
+    localappdata.mkdir(parents=True)
+    fake_home = tmp_path / "home"
+    fake_home.mkdir()
+    old_configdir = fake_home / ".matplotlib"
+    old_configdir.mkdir()
+
+    proc = subprocess_run_for_testing(
+        [sys.executable, "-c",
+         "import matplotlib; print(matplotlib.get_configdir())"],
+        env={**os.environ, "LOCALAPPDATA": str(localappdata),
+             "USERPROFILE": str(fake_home), "MPLCONFIGDIR": ""},
+        capture_output=True, text=True, check=True)
+
+    configdir = proc.stdout.strip()
+    # On Windows with existing old config, should continue using it
+    assert configdir == str(old_configdir)

diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py
index 6b7296e85dca..b4f3cc7d21df 100644
--- a/lib/matplotlib/__init__.py
+++ b/lib/matplotlib/__init__.py
@@ -536,6 +536,28 @@ def _get_config_or_cache_dir(xdg_base_getter):
             configdir = Path(xdg_base_getter(), "matplotlib")
         except RuntimeError:  # raised if Path.home() is not available
             pass
+    elif sys.platform == 'win32':
+        # On Windows, prefer %LOCALAPPDATA%\matplotlib which is the proper
+        # location for non-roaming application data (cache and config).
+        # See: https://docs.microsoft.com/en-us/windows/apps/design/app-settings/store-and-retrieve-app-data
+        #
+        # However, for backwards compatibility, if the old location
+        # (%USERPROFILE%\.matplotlib) exists, continue using it so existing
+        # users don't lose their config.
+        try:
+            old_configdir = Path.home() / ".matplotlib"
+            if old_configdir.is_dir():
+                configdir = old_configdir
+            else:
+                localappdata = os.environ.get('LOCALAPPDATA')
+                if localappdata:
+                    configdir = Path(localappdata) / "matplotlib"
+                else:
+                    configdir = old_configdir
+        except RuntimeError:  # raised if Path.home() is not available
+            localappdata = os.environ.get('LOCALAPPDATA')
+            if localappdata:
+                configdir = Path(localappdata) / "matplotlib"
     else:
         try:
             configdir = Path.home() / ".matplotlib"
@@ -586,8 +608,9 @@ def get_configdir():

     1. If the MPLCONFIGDIR environment variable is supplied, choose that.
     2. On Linux, follow the XDG specification and look first in
-       ``$XDG_CONFIG_HOME``, if defined, or ``$HOME/.config``.  On other
-       platforms, choose ``$HOME/.matplotlib``.
+       ``$XDG_CONFIG_HOME``, if defined, or ``$HOME/.config``.  On Windows,
+       use ``%LOCALAPPDATA%\\matplotlib``.  On other platforms, choose
+       ``$HOME/.matplotlib``.
     3. If the chosen directory exists and is writable, use that as the
        configuration directory.
     4. Else, create a temporary directory, and use it as the configuration
@@ -602,7 +625,8 @@ def get_cachedir():
     Return the string path of the cache directory.

     The procedure used to find the directory is the same as for
-    `get_configdir`, except using ``$XDG_CACHE_HOME``/``$HOME/.cache`` instead.
+    `get_configdir`, except using ``$XDG_CACHE_HOME``/``$HOME/.cache`` instead
+    on Linux.  On Windows, uses ``%LOCALAPPDATA%\\matplotlib`` (same as config).
     """
     return _get_config_or_cache_dir(_get_xdg_cache_dir)


# [Bug]: alpha array-type not working with RGB image in imshow()

### Bug summary

Hi,
Whereas `alpha = constant` works with RGB image, this is not the case when working with an `array-type` for alpha.
(In my real case, I can only pass standard imshow parameters like `alpha `to the function of the library I use, and not a RGBA array).
Patrick

### Code for reproduction

```python
import numpy as np
import matplotlib.pyplot as plt
from skimage.color import gray2rgb

arr = np.random.random((10, 10))
arr_rgb = gray2rgb(arr)

alpha = np.ones_like(arr)
alpha[:5] = 0.2

plt.figure()
plt.tight_layout()
plt.subplot(121)
plt.title("Expected outcome")
plt.imshow(arr, alpha=alpha, cmap='gray')
plt.subplot(122)
plt.title("Actual outcome")
plt.imshow(arr_rgb, alpha=alpha)
plt.show()
```

### Actual outcome

![image](https://github.com/matplotlib/matplotlib/assets/16154687/89322c0b-4ccd-46e1-9f2a-50b5f3511dfa)

### Expected outcome

![image](https://github.com/matplotlib/matplotlib/assets/16154687/4d7fcedf-2c54-4f07-a35d-ad23afeb9df6)

### Additional information

_No response_

### Operating system

Windows

### Matplotlib Version

3.7.1

### Matplotlib Backend

TkAgg

### Python version

Python 3.10.11

### Jupyter version

_No response_

### Installation

pip

diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py
index 02af308963a3..0f051afbc894 100644
--- a/lib/matplotlib/tests/test_image.py
+++ b/lib/matplotlib/tests/test_image.py
@@ -1857,7 +1857,7 @@ def test_interpolation_stage_rgba_respects_alpha_param(fig_test, fig_ref, intp_s
     axs_ref[0][2].imshow(im_rgba, interpolation_stage=intp_stage)

     # When the image already has an alpha channel, multiply it by the
-    # scalar alpha param, or replace it by the array alpha param
+    # alpha param (both scalar and array alpha multiply the existing alpha)
     axs_tst[1][0].imshow(im_rgba)
     axs_ref[1][0].imshow(im_rgb, alpha=array_alpha)
     axs_tst[1][1].imshow(im_rgba, interpolation_stage=intp_stage, alpha=scalar_alpha)
@@ -1869,7 +1869,8 @@ def test_interpolation_stage_rgba_respects_alpha_param(fig_test, fig_ref, intp_s
     new_array_alpha = np.random.rand(ny, nx)
     axs_tst[1][2].imshow(im_rgba, interpolation_stage=intp_stage, alpha=new_array_alpha)
     axs_ref[1][2].imshow(
-        np.concatenate(  # combine rgb channels with new array alpha
-            (im_rgb, new_array_alpha.reshape((ny, nx, 1))), axis=-1
+        np.concatenate(  # combine rgb channels with multiplied array alpha
+            (im_rgb, array_alpha.reshape((ny, nx, 1))
+             * new_array_alpha.reshape((ny, nx, 1))), axis=-1
         ), interpolation_stage=intp_stage
     )

diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py
index 483526fcd0a0..e4d6eebcc5c0 100644
--- a/lib/matplotlib/image.py
+++ b/lib/matplotlib/image.py
@@ -512,8 +512,10 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0,
                     if A.shape[2] == 3:  # image has no alpha channel
                         A = np.dstack([A, np.ones(A.shape[:2])])
                 elif np.ndim(alpha) > 0:  # Array alpha
-                    # user-specified array alpha overrides the existing alpha channel
-                    A = np.dstack([A[..., :3], alpha])
+                    if A.shape[2] == 3:  # RGB: use array alpha directly
+                        A = np.dstack([A, alpha])
+                    else:  # RGBA: multiply existing alpha by array alpha
+                        A = np.dstack([A[..., :3], A[..., 3] * alpha])
                 else:  # Scalar alpha
                     if A.shape[2] == 3:  # broadcast scalar alpha
                         A = np.dstack([A, np.full(A.shape[:2], alpha, np.float32)])


# [ENH]: Implement gapcolor for patch edges

### Problem

The gapcolor property allows drawing "stripey" Line2Ds and LineCollections with two different colors.  Another set of artists that could benefit from this feature is patches -- currently, their edge can be drawn with dashes, but not with two different colors.

An example use case is to draw (unfilled) patches on a background whose color is not controlled, and can in particular be very light or very dark.  In that case, using stripey patches alternating black and white ensures that the patch is visible no matter the underlying background.

### Proposed solution

Add support for gapcolor in Patches.  (Maybe the property should be named "edgegapcolor" for consistency.)

diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py
index ed608eebb6a7..f3057d7106fd 100644
--- a/lib/matplotlib/tests/test_patches.py
+++ b/lib/matplotlib/tests/test_patches.py
@@ -1101,3 +1101,81 @@ def test_empty_fancyarrow():
     fig, ax = plt.subplots()
     arrow = ax.arrow([], [], [], [])
     assert arrow is not None
+
+
+def test_patch_edgegapcolor_getter_setter():
+    """Test that edgegapcolor can be set and retrieved."""
+    patch = Rectangle((0, 0), 1, 1)
+    # Default is None
+    assert patch.get_edgegapcolor() is None
+
+    # Set to a color
+    patch.set_edgegapcolor('red')
+    assert mcolors.same_color(patch.get_edgegapcolor(), 'red')
+
+    # Set back to None
+    patch.set_edgegapcolor(None)
+    assert patch.get_edgegapcolor() is None
+
+
+def test_patch_edgegapcolor_init():
+    """Test that edgegapcolor can be passed in __init__."""
+    patch = Rectangle((0, 0), 1, 1, edgegapcolor='blue')
+    assert mcolors.same_color(patch.get_edgegapcolor(), 'blue')
+
+
+def test_patch_has_dashed_edge():
+    """Test _has_dashed_edge method for patches."""
+    patch = Rectangle((0, 0), 1, 1)
+    patch.set_linestyle('solid')
+    assert not patch._has_dashed_edge()
+
+    patch.set_linestyle('--')
+    assert patch._has_dashed_edge()
+
+    patch.set_linestyle(':')
+    assert patch._has_dashed_edge()
+
+    patch.set_linestyle('-.')
+    assert patch._has_dashed_edge()
+
+    # Test custom linestyle
+    patch.set_linestyle((0, (2, 2, 10, 2)))
+    assert patch._has_dashed_edge()
+
+
+def test_patch_edgegapcolor_update_from():
+    """Test that edgegapcolor is copied in update_from."""
+    patch1 = Rectangle((0, 0), 1, 1, edgegapcolor='green')
+    patch2 = Rectangle((1, 1), 2, 2)
+
+    patch2.update_from(patch1)
+    assert mcolors.same_color(patch2.get_edgegapcolor(), 'green')
+
+
+@image_comparison(['patch_edgegapcolor.png'], remove_text=True, style='mpl20')
+def test_patch_edgegapcolor_visual():
+    """Visual test for patch edgegapcolor (striped edges)."""
+    fig, ax = plt.subplots()
+
+    # Rectangle with edgegapcolor
+    rect = Rectangle((0.1, 0.1), 0.3, 0.3, fill=False,
+                      edgecolor='blue', edgegapcolor='orange',
+                      linestyle='--', linewidth=3)
+    ax.add_patch(rect)
+
+    # Ellipse with edgegapcolor
+    ellipse = Ellipse((0.7, 0.3), 0.3, 0.2, fill=False,
+                       edgecolor='red', edgegapcolor='yellow',
+                       linestyle=':', linewidth=3)
+    ax.add_patch(ellipse)
+
+    # Polygon with edgegapcolor
+    polygon = Polygon([[0.1, 0.6], [0.3, 0.9], [0.4, 0.6]], fill=False,
+                       edgecolor='green', edgegapcolor='purple',
+                       linestyle='-.', linewidth=3)
+    ax.add_patch(polygon)
+
+    ax.set_xlim(0, 1)
+    ax.set_ylim(0, 1)
+    ax.set_aspect('equal')

diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py
index 0c8d6b5fee15..4a4bd698db04 100644
--- a/lib/matplotlib/patches.py
+++ b/lib/matplotlib/patches.py
@@ -57,6 +57,7 @@ def __init__(self, *,
                  capstyle=None,
                  joinstyle=None,
                  hatchcolor=None,
+                 edgegapcolor=None,
                  **kwargs):
         """
         The following kwarg properties are supported
@@ -88,6 +89,7 @@ def __init__(self, *,
         self._linewidth = 0
         self._unscaled_dash_pattern = (0, None)  # offset, dash
         self._dash_pattern = (0, None)  # offset, dash (scaled by linewidth)
+        self._gapcolor = None

         self.set_linestyle(linestyle)
         self.set_linewidth(linewidth)
@@ -95,6 +97,7 @@ def __init__(self, *,
         self.set_hatch(hatch)
         self.set_capstyle(capstyle)
         self.set_joinstyle(joinstyle)
+        self.set_edgegapcolor(edgegapcolor)

         if len(kwargs):
             self._internal_update(kwargs)
@@ -294,6 +297,7 @@ def update_from(self, other):
         self._hatch_color = other._hatch_color
         self._original_hatchcolor = other._original_hatchcolor
         self._unscaled_dash_pattern = other._unscaled_dash_pattern
+        self._gapcolor = other._gapcolor
         self.set_linewidth(other._linewidth)  # also sets scaled dashes
         self.set_transform(other.get_data_transform())
         # If the transform of other needs further initialization, then it will
@@ -442,6 +446,42 @@ def set_hatchcolor(self, color):
         self._original_hatchcolor = color
         self._set_hatchcolor(color)

+    def get_edgegapcolor(self):
+        """
+        Return the edge gap color.
+
+        .. versionadded:: 3.11
+
+        See also `~.Patch.set_edgegapcolor`.
+        """
+        return self._gapcolor
+
+    def set_edgegapcolor(self, edgegapcolor):
+        """
+        Set a color to fill the gaps in the dashed edge style.
+
+        .. versionadded:: 3.11
+
+        .. note::
+
+            Striped edges are created by drawing two interleaved dashed lines.
+            There can be overlaps between those two, which may result in
+            artifacts when using transparency.
+
+            This functionality is experimental and may change.
+
+        Parameters
+        ----------
+        edgegapcolor : :mpltype:`color` or None
+            The color with which to fill the gaps. If None, the gaps are
+            unfilled.
+        """
+        if edgegapcolor is not None:
+            self._gapcolor = colors.to_rgba(edgegapcolor, self._alpha)
+        else:
+            self._gapcolor = None
+        self.stale = True
+
     def set_alpha(self, alpha):
         # docstring inherited
         super().set_alpha(alpha)
@@ -618,6 +658,17 @@ def get_hatch_linewidth(self):
         """Return the hatch linewidth."""
         return self._hatch_linewidth

+    def _has_dashed_edge(self):
+        """
+        Return whether the patch edge has a dashed linestyle.
+
+        A custom linestyle is assumed to be dashed, we do not inspect the
+        ``onoffseq`` directly.
+
+        See also `~.Patch.set_linestyle`.
+        """
+        return self._linestyle not in ('solid', '-')
+
     def _draw_paths_with_artist_properties(
             self, renderer, draw_path_args_list):
         """
@@ -632,13 +683,10 @@ def _draw_paths_with_artist_properties(
         renderer.open_group('patch', self.get_gid())
         gc = renderer.new_gc()

-        gc.set_foreground(self._edgecolor, isRGBA=True)
-
         lw = self._linewidth
         if self._edgecolor[3] == 0 or self._linestyle == 'None':
             lw = 0
         gc.set_linewidth(lw)
-        gc.set_dashes(*self._dash_pattern)
         gc.set_capstyle(self._capstyle)
         gc.set_joinstyle(self._joinstyle)

@@ -661,6 +709,18 @@ def _draw_paths_with_artist_properties(
             from matplotlib.patheffects import PathEffectRenderer
             renderer = PathEffectRenderer(self.get_path_effects(), renderer)

+        # Draw the gaps first if gapcolor is set
+        if self._has_dashed_edge() and self._gapcolor is not None:
+            gc.set_foreground(self._gapcolor, isRGBA=True)
+            offset_gaps, gaps = mlines._get_inverse_dash_pattern(
+                *self._dash_pattern)
+            gc.set_dashes(offset_gaps, gaps)
+            for draw_path_args in draw_path_args_list:
+                renderer.draw_path(gc, *draw_path_args)
+
+        # Draw the main edge
+        gc.set_foreground(self._edgecolor, isRGBA=True)
+        gc.set_dashes(*self._dash_pattern)
         for draw_path_args in draw_path_args_list:
             renderer.draw_path(gc, *draw_path_args)


# Off-axes scatter() points unnecessarily saved to PDF when coloured

Scatter plotting a bunch of points while specifying the colour of each point, then changing the axes limits so none of the points are visible, and then saving the result to a PDF, results in a file just as big as if the points were all visible within their default axes limits. This doesn't seem to happen if the colour arg isn't passed to `scatter()`. I haven't tried, but specifying other kinds of point specific attributes, like size, might also trigger the problem . Also, I haven't tried any of the other vector backends, but they may be affected as well.

This came out of #2423.

Example code:

``` python
import numpy as np
x = np.random.random(20000)
y = np.random.random(20000)
c = np.random.random(20000)

figure()
scatter(x, y)
pyplot.savefig('scatter.pdf')
xlim(2, 3) # move axes away for empty plot
pyplot.savefig('scatter_empty.pdf')
'''
file sizes in bytes:
scatter.pdf:       324187
scatter_empty.pdf:   6617
'''
figure()
scatter(x, y, c=c)
pyplot.savefig('scatter_color.pdf')
xlim(2, 3) # move axes away for empty plot
pyplot.savefig('scatter_color_empty.pdf')
'''
file sizes in bytes:
scatter_color.pdf:       410722
scatter_color_empty.pdf: 413541
'''
```

diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py
index f126fb543e78..9e34745a9774 100644
--- a/lib/matplotlib/tests/test_backend_pdf.py
+++ b/lib/matplotlib/tests/test_backend_pdf.py
@@ -478,3 +478,221 @@ def test_font_bitstream_charter():
     ax.text(0.1, 0.3, r"fi ffl 1234", usetex=True, fontsize=50)
     ax.set_xticks([])
     ax.set_yticks([])
+
+
+def test_scatter_offaxis_colored_pdf_size():
+    """
+    Test that off-axis scatter plots with per-point colors don't bloat PDFs.
+
+    Regression test for issue #2488. When scatter points with per-point colors
+    are completely outside the visible axes, the PDF backend should skip
+    writing those markers to significantly reduce file size.
+    """
+    # Use John Hunter's birthday as random seed for reproducibility
+    rng = np.random.default_rng(19680801)
+
+    n_points = 1000
+    x = rng.random(n_points) * 10
+    y = rng.random(n_points) * 10
+    c = rng.random(n_points)
+
+    # Test 1: Scatter with per-point colors, all points OFF-AXIS
+    fig1, ax1 = plt.subplots()
+    ax1.scatter(x, y, c=c)
+    ax1.set_xlim(20, 30)  # Move view completely away from data (x is 0-10)
+    ax1.set_ylim(20, 30)  # Move view completely away from data (y is 0-10)
+
+    buf1 = io.BytesIO()
+    fig1.savefig(buf1, format='pdf')
+    size_offaxis_colored = buf1.tell()
+    plt.close(fig1)
+
+    # Test 2: Empty scatter (baseline - accounts for scatter call overhead)
+    fig2, ax2 = plt.subplots()
+    ax2.scatter([], [])  # Empty scatter to match the axes structure
+    ax2.set_xlim(20, 30)
+    ax2.set_ylim(20, 30)
+
+    buf2 = io.BytesIO()
+    fig2.savefig(buf2, format='pdf')
+    size_empty = buf2.tell()
+    plt.close(fig2)
+
+    # Test 3: Scatter with visible markers (should be much larger)
+    fig3, ax3 = plt.subplots()
+    ax3.scatter(x + 20, y + 20, c=c)  # Shift points to be visible
+    ax3.set_xlim(20, 30)
+    ax3.set_ylim(20, 30)
+
+    buf3 = io.BytesIO()
+    fig3.savefig(buf3, format='pdf')
+    size_visible = buf3.tell()
+    plt.close(fig3)
+
+    # The off-axis colored scatter should be close to empty size.
+    # Since the axes are identical, the difference should be minimal
+    # (just the scatter collection setup, no actual marker data).
+    # Use a tight tolerance since axes output is identical.
+    assert size_offaxis_colored < size_empty + 5_000, (
+        f"Off-axis colored scatter PDF ({size_offaxis_colored} bytes) is too large. "
+        f"Expected close to empty scatter size ({size_empty} bytes). "
+        f"Markers may not be properly skipped."
+    )
+
+    # The visible scatter should be significantly larger than both empty and
+    # off-axis, demonstrating the optimization is working.
+    assert size_visible > size_empty + 15_000, (
+        f"Visible scatter PDF ({size_visible} bytes) should be much larger "
+        f"than empty ({size_empty} bytes) to validate the test."
+    )
+    assert size_visible > size_offaxis_colored + 15_000, (
+        f"Visible scatter PDF ({size_visible} bytes) should be much larger "
+        f"than off-axis ({size_offaxis_colored} bytes) to validate optimization."
+    )
+
+
+@check_figures_equal(extensions=["pdf"])
+def test_scatter_offaxis_colored_visual(fig_test, fig_ref):
+    """
+    Test that on-axis scatter with per-point colors still renders correctly.
+
+    Ensures the optimization for off-axis markers doesn't break normal
+    scatter rendering.
+    """
+    rng = np.random.default_rng(19680801)
+
+    n_points = 100
+    x = rng.random(n_points) * 5
+    y = rng.random(n_points) * 5
+    c = rng.random(n_points)
+
+    # Test figure: scatter with clipping optimization
+    ax_test = fig_test.subplots()
+    ax_test.scatter(x, y, c=c, s=50)
+    ax_test.set_xlim(0, 10)
+    ax_test.set_ylim(0, 10)
+
+    # Reference figure: should look identical
+    ax_ref = fig_ref.subplots()
+    ax_ref.scatter(x, y, c=c, s=50)
+    ax_ref.set_xlim(0, 10)
+    ax_ref.set_ylim(0, 10)
+
+
+@check_figures_equal(extensions=["pdf"])
+def test_scatter_mixed_onoff_axis(fig_test, fig_ref):
+    """
+    Test scatter with some points on-axis and some off-axis.
+
+    Ensures the optimization correctly handles the common case where only
+    some markers are outside the visible area.
+    """
+    rng = np.random.default_rng(19680801)
+
+    # Create points: half on-axis (0-5), half off-axis (15-20)
+    n_points = 50
+    x_on = rng.random(n_points) * 5
+    y_on = rng.random(n_points) * 5
+    x_off = rng.random(n_points) * 5 + 15
+    y_off = rng.random(n_points) * 5 + 15
+
+    x = np.concatenate([x_on, x_off])
+    y = np.concatenate([y_on, y_off])
+    c = rng.random(2 * n_points)
+
+    # Test figure: scatter with mixed points
+    ax_test = fig_test.subplots()
+    ax_test.scatter(x, y, c=c, s=50)
+    ax_test.set_xlim(0, 10)
+    ax_test.set_ylim(0, 10)
+
+    # Reference figure: only the on-axis points should be visible
+    ax_ref = fig_ref.subplots()
+    ax_ref.scatter(x_on, y_on, c=c[:n_points], s=50)
+    ax_ref.set_xlim(0, 10)
+    ax_ref.set_ylim(0, 10)
+
+
+@check_figures_equal(extensions=["pdf"])
+def test_scatter_large_markers_partial_clip(fig_test, fig_ref):
+    """
+    Test that large markers are rendered when partially visible.
+
+    Addresses reviewer concern: markers with centers outside the canvas but
+    with edges extending into the visible area should still be rendered.
+    """
+    # Create markers just outside the visible area
+    # Canvas is 0-10, markers at x=-0.5 and x=10.5
+    x = np.array([-0.5, 10.5, 5])  # left edge, right edge, center
+    y = np.array([5, 5, -0.5])  # center, center, bottom edge
+    c = np.array([0.2, 0.5, 0.8])
+
+    # Test figure: large markers (s=500 ≈ 11 points radius)
+    # Centers are outside, but marker edges extend into visible area
+    ax_test = fig_test.subplots()
+    ax_test.scatter(x, y, c=c, s=500)
+    ax_test.set_xlim(0, 10)
+    ax_test.set_ylim(0, 10)
+
+    # Reference figure: same plot (should render identically)
+    ax_ref = fig_ref.subplots()
+    ax_ref.scatter(x, y, c=c, s=500)
+    ax_ref.set_xlim(0, 10)
+    ax_ref.set_ylim(0, 10)
+
+
+@check_figures_equal(extensions=["pdf"])
+def test_scatter_logscale(fig_test, fig_ref):
+    """
+    Test scatter optimization with logarithmic scales.
+
+    Ensures bounds checking works correctly in log-transformed coordinates.
+    """
+    rng = np.random.default_rng(19680801)
+
+    # Create points across several orders of magnitude
+    n_points = 50
+    x = 10 ** (rng.random(n_points) * 4)  # 1 to 10000
+    y = 10 ** (rng.random(n_points) * 4)
+    c = rng.random(n_points)
+
+    # Test figure: log scale with points mostly outside view
+    ax_test = fig_test.subplots()
+    ax_test.scatter(x, y, c=c, s=50)
+    ax_test.set_xscale('log')
+    ax_test.set_yscale('log')
+    ax_test.set_xlim(100, 1000)  # Only show middle range
+    ax_test.set_ylim(100, 1000)
+
+    # Reference figure: should render identically
+    ax_ref = fig_ref.subplots()
+    ax_ref.scatter(x, y, c=c, s=50)
+    ax_ref.set_xscale('log')
+    ax_ref.set_yscale('log')
+    ax_ref.set_xlim(100, 1000)
+    ax_ref.set_ylim(100, 1000)
+
+
+@check_figures_equal(extensions=["pdf"])
+def test_scatter_polar(fig_test, fig_ref):
+    """
+    Test scatter optimization with polar coordinates.
+
+    Ensures bounds checking works correctly in polar projections.
+    """
+    rng = np.random.default_rng(19680801)
+
+    n_points = 50
+    theta = rng.random(n_points) * 2 * np.pi
+    r = rng.random(n_points) * 3
+    c = rng.random(n_points)
+
+    # Test figure: polar projection
+    ax_test = fig_test.subplots(subplot_kw={'projection': 'polar'})
+    ax_test.scatter(theta, r, c=c, s=50)
+    ax_test.set_ylim(0, 2)  # Limit radial range
+
+    # Reference figure: should render identically
+    ax_ref = fig_ref.subplots(subplot_kw={'projection': 'polar'})
+    ax_ref.scatter(theta, r, c=c, s=50)
+    ax_ref.set_ylim(0, 2)

diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py
index d63808eb3925..b9f3c71835c4 100644
--- a/lib/matplotlib/backends/backend_pdf.py
+++ b/lib/matplotlib/backends/backend_pdf.py
@@ -2104,11 +2104,28 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms,

         padding = np.max(linewidths)
         path_codes = []
+        path_extents = []
         for i, (path, transform) in enumerate(self._iter_collection_raw_paths(
                 master_transform, paths, all_transforms)):
             name = self.file.pathCollectionObject(
                 gc, path, transform, padding, filled, stroked)
             path_codes.append(name)
+            # Compute the extent of each marker path to enable per-marker
+            # bounds checking. This allows us to skip markers that are
+            # completely outside the visible canvas while preserving markers
+            # that are partially visible.
+            if len(path.vertices):
+                bbox = path.get_extents(transform)
+                # Store half-width and half-height for efficient bounds checking
+                path_extents.append((bbox.width / 2, bbox.height / 2))
+            else:
+                path_extents.append((0, 0))
+
+        # Create a mapping from path_id to extent for efficient lookup
+        path_extent_map = dict(zip(path_codes, path_extents))
+
+        canvas_width = self.file.width * 72
+        canvas_height = self.file.height * 72

         output = self.file.output
         output(*self.gc.push())
@@ -2118,6 +2135,28 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms,
                 facecolors, edgecolors, linewidths, linestyles,
                 antialiaseds, urls, offset_position, hatchcolors=hatchcolors):

+            # Optimization: Fast path for markers with centers inside canvas.
+            # This avoids the dictionary lookup for the common case where
+            # markers are visible, improving performance for large scatter plots.
+            if 0 <= xo <= canvas_width and 0 <= yo <= canvas_height:
+                # Marker center is inside canvas - definitely render it
+                self.check_gc(gc0, rgbFace)
+                dx, dy = xo - lastx, yo - lasty
+                output(1, 0, 0, 1, dx, dy, Op.concat_matrix, path_id,
+                       Op.use_xobject)
+                lastx, lasty = xo, yo
+                continue
+
+            # Marker center is outside canvas - check if partially visible.
+            # Skip markers completely outside visible canvas bounds to reduce
+            # PDF file size. Use per-marker extents to handle large markers
+            # correctly: only skip if the marker's bounding box doesn't
+            # intersect the canvas at all.
+            extent_x, extent_y = path_extent_map[path_id]
+            if not (-extent_x <= xo <= canvas_width + extent_x
+                    and -extent_y <= yo <= canvas_height + extent_y):
+                continue
+
             self.check_gc(gc0, rgbFace)
             dx, dy = xo - lastx, yo - lasty
             output(1, 0, 0, 1, dx, dy, Op.concat_matrix, path_id,


# [Bug]: Colorbar get_ticks() return the incorrect array

### Bug summary

When creating a colorbar using a ScalarMappable with the NoNorm normalization and a discrete colormap (e.g., viridis with a fixed number of colors), the ticks returned by the colorbar’s get_ticks() method do not align with the visually displayed tick positions. This will cause `cbar.set_ticks(cbar.get_ticks())` to change the ticks.

### Code for reproduction

```Python
import matplotlib.pyplot as plt
from matplotlib import cm, colors
data = [1, 2, 3, 4, 5]
fig, ax = plt.subplots()
cbar = fig.colorbar(cm.ScalarMappable(norm=colors.NoNorm(), cmap=plt.get_cmap("viridis", len(data))), ax=ax)
print(cbar.get_ticks())
cbar.set_ticks(cbar.get_ticks()) # this unexpectedly changes the ticks
```

### Actual outcome

[0. 1. 2. 3. 4. 5.]

### Expected outcome

[0. 1. 2. 3. 4.]

### Additional information

_No response_

### Operating system

_No response_

### Matplotlib Version

3.10.8

### Matplotlib Backend

_No response_

### Python version

_No response_

### Jupyter version

_No response_

### Installation

None

diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py
index c3c53ebaea73..478a54b8a317 100644
--- a/lib/matplotlib/tests/test_ticker.py
+++ b/lib/matplotlib/tests/test_ticker.py
@@ -604,6 +604,22 @@ def test_set_params(self):
         assert index._base == 7
         assert index.offset == 7

+    def test_tick_values_not_exceeding_vmax(self):
+        """
+        Test that tick_values does not return values greater than vmax.
+        """
+        # Test case where offset=0 could cause vmax to be included incorrectly
+        index = mticker.IndexLocator(base=1, offset=0)
+        assert_array_equal(index.tick_values(0, 4), [0, 1, 2, 3, 4])
+
+        # Test case with fractional offset
+        index = mticker.IndexLocator(base=1, offset=0.5)
+        assert_array_equal(index.tick_values(0, 4), [0.5, 1.5, 2.5, 3.5])
+
+        # Test case with base > 1
+        index = mticker.IndexLocator(base=2, offset=0)
+        assert_array_equal(index.tick_values(0, 5), [0, 2, 4])
+

 class TestSymmetricalLogLocator:
     def test_set_params(self):

diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py
index e27d71974471..83e13841677a 100644
--- a/lib/matplotlib/ticker.py
+++ b/lib/matplotlib/ticker.py
@@ -1767,8 +1767,11 @@ def __call__(self):
         return self.tick_values(dmin, dmax)

     def tick_values(self, vmin, vmax):
-        return self.raise_if_exceeds(
-            np.arange(vmin + self.offset, vmax + 1, self._base))
+        # We want tick values in the closed interval [vmin, vmax].
+        # Since np.arange(start, stop) returns values in the semi-open interval
+        # [start, stop), we add a minimal offset so that stop = vmax + eps
+        tick_values = np.arange(vmin + self.offset, vmax + 1e-12, self._base)
+        return self.raise_if_exceeds(tick_values)

 class FixedLocator(Locator):


# [Bug]: Axes.grid(color) ignores alpha

### Bug summary

When using Color like described here https://matplotlib.org/stable/tutorials/colors/colors.html for the Axes grid, the alpha value is ignored.

### Code for reproduction

```python
import numpy as np
fig, ax = plt.subplots()
rng = np.random.default_rng(12345)
ax.plot(rng.integers(low=0, high=10, size=3))
ax.grid(color=(0.8, 0.8, 0.8, 0))
fig.show()
```

### Actual outcome

![image](https://user-images.githubusercontent.com/572453/149469905-ea89d2fb-3caf-454b-835d-77797999c1e6.png)

### Expected outcome

![image](https://user-images.githubusercontent.com/572453/149469938-64b48de9-0d50-4280-a9e1-23d952571ff4.png)

### Additional information

_No response_

### Operating system

Windows

### Matplotlib Version

3.5.1

### Matplotlib Backend

module://backend_interagg

### Python version

3.9.2

### Jupyter version

_No response_

### Installation

pip

diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index 6e839ef2f189..b9076d86dc98 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -6133,6 +6133,21 @@ def test_grid():
     assert not ax.xaxis.majorTicks[0].gridline.get_visible()

+def test_grid_color_with_alpha():
+    """Test that grid(color=(..., alpha)) respects the alpha value."""
+    fig, ax = plt.subplots()
+    ax.grid(True, color=(0.5, 0.6, 0.7, 0.3))
+
+    # Check that alpha is extracted from color tuple
+    for tick in ax.xaxis.get_major_ticks():
+        assert tick.gridline.get_alpha() == 0.3, \
+            f"Expected alpha=0.3, got {tick.gridline.get_alpha()}"
+
+    for tick in ax.yaxis.get_major_ticks():
+        assert tick.gridline.get_alpha() == 0.3, \
+            f"Expected alpha=0.3, got {tick.gridline.get_alpha()}"
+
+
 def test_reset_grid():
     fig, ax = plt.subplots()
     ax.tick_params(reset=True, which='major', labelsize=10)

diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py
index c3b6fcac569f..900682511713 100644
--- a/lib/matplotlib/axis.py
+++ b/lib/matplotlib/axis.py
@@ -140,17 +140,20 @@ def __init__(
             f"grid.{major_minor}.linewidth",
             "grid.linewidth",
         )
-        if grid_alpha is None and not mcolors._has_alpha_channel(grid_color):
-            # alpha precedence: kwarg > color alpha > rcParams['grid.alpha']
-            # Note: only resolve to rcParams if the color does not have alpha
-            # otherwise `grid(color=(1, 1, 1, 0.5))` would work like
-            #   grid(color=(1, 1, 1, 0.5), alpha=rcParams['grid.alpha'])
-            # so the that the rcParams default would override color alpha.
-            grid_alpha = mpl._val_or_rc(
-                # grid_alpha is None so we can use the first key
-                mpl.rcParams[f"grid.{major_minor}.alpha"],
-                "grid.alpha",
-            )
+        if grid_alpha is None:
+            if mcolors._has_alpha_channel(grid_color):
+                # Extract alpha from the color
+                # alpha precedence: kwarg > color alpha > rcParams['grid.alpha']
+                rgba = mcolors.to_rgba(grid_color)
+                grid_color = rgba[:3]  # RGB only
+                grid_alpha = rgba[3]    # Alpha from color
+            else:
+                # No alpha in color, use rcParams
+                grid_alpha = mpl._val_or_rc(
+                    # grid_alpha is None so we can use the first key
+                    mpl.rcParams[f"grid.{major_minor}.alpha"],
+                    "grid.alpha",
+                )

         grid_kw = {k[5:]: v for k, v in kwargs.items() if k != "rotation_mode"}

@@ -348,6 +351,15 @@ def _apply_params(self, **kwargs):

         grid_kw = {k[5:]: v for k, v in kwargs.items()
                    if k in _gridline_param_names}
+        # If grid_color has an alpha channel and grid_alpha is not explicitly
+        # set, extract the alpha from the color.
+        if 'color' in grid_kw and 'alpha' not in grid_kw:
+            grid_color = grid_kw['color']
+            if mcolors._has_alpha_channel(grid_color):
+                # Convert to rgba to extract alpha
+                rgba = mcolors.to_rgba(grid_color)
+                grid_kw['color'] = rgba[:3]  # RGB only
+                grid_kw['alpha'] = rgba[3]    # Alpha channel
         self.gridline.set(**grid_kw)

     def update_position(self, loc):


# SVG backend - handle font weight as integer

<!--
Thank you so much for your PR!  To help us review your contribution, please check
out the development guide https://matplotlib.org/devdocs/devel/index.html
-->

## PR summary

This fixes a small bug with handling integer font weights when rendering text in SVGs as elements vs paths (i.e. `plt.rcParams['svg.fonttype'] = 'none'`).

To reproduce:

```python

plt.rcParams['svg.fonttype'] = 'none'
fig, ax = plt.subplots()
ax.set_title('bold-title', fontweight=600)
```
Raises:
```
KeyError: 600
lib/matplotlib/backends/backend_svg.py:1138: KeyError
```

The problem is that the font [`weight_dict` is keyed on the string values](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/font_manager.py#L82), but `get_weight` returns an integer.

## PR checklist
<!-- Please mark any checkboxes that do not apply to this PR as [N/A].-->

I couldn't find an open issue for this, but let me know if one exists already. This is also my first PR to matplotlib, so let me know if there's anything I missed in your contribution process. Appreciate any feedback! And, thanks so much for maintaining this library! It's the backbone of [Starplot](https://github.com/steveberardi/starplot), a sky mapping library I maintain.

- [ ] "closes #0000" is in the body of the PR description to [link the related issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- [X] new and changed code is [tested](https://matplotlib.org/devdocs/devel/testing.html)
- [ ] *Plotting related* features are demonstrated in an [example](https://matplotlib.org/devdocs/devel/document.html#write-examples-and-tutorials)
- [ ] *New Features* and *API Changes* are noted with a [directive and release note](https://matplotlib.org/devdocs/devel/api_changes.html#announce-changes-deprecations-and-new-features)
- [ ] Documentation complies with [general](https://matplotlib.org/devdocs/devel/document.html#write-rest-pages) and [docstring](https://matplotlib.org/devdocs/devel/document.html#write-docstrings) guidelines

<!--We understand that PRs can sometimes be overwhelming, especially as the
reviews start coming in.  Please let us know if the reviews are unclear or
the recommended next step seems overly demanding, if you would like help in
addressing a reviewer's comments, or if you have been waiting too long to hear
back on your PR.-->

diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py
index 509136fe323e..0f9f5f19afb5 100644
--- a/lib/matplotlib/tests/test_backend_svg.py
+++ b/lib/matplotlib/tests/test_backend_svg.py
@@ -73,7 +73,8 @@ def test_bold_font_output():
     ax.plot(np.arange(10), np.arange(10))
     ax.set_xlabel('nonbold-xlabel')
     ax.set_ylabel('bold-ylabel', fontweight='bold')
-    ax.set_title('bold-title', fontweight='bold')
+    # set weight as integer to assert it's handled properly
+    ax.set_title('bold-title', fontweight=600)

 @image_comparison(['bold_font_output_with_none_fonttype.svg'])
@@ -83,7 +84,8 @@ def test_bold_font_output_with_none_fonttype():
     ax.plot(np.arange(10), np.arange(10))
     ax.set_xlabel('nonbold-xlabel')
     ax.set_ylabel('bold-ylabel', fontweight='bold')
-    ax.set_title('bold-title', fontweight='bold')
+    # set weight as integer to assert it's handled properly
+    ax.set_title('bold-title', fontweight=600)

 @check_figures_equal(tol=20)

diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py
index 2193dc6b6cdc..7789ec2cd882 100644
--- a/lib/matplotlib/backends/backend_svg.py
+++ b/lib/matplotlib/backends/backend_svg.py
@@ -1132,7 +1132,8 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None):
                 font_style['font-style'] = prop.get_style()
             if prop.get_variant() != 'normal':
                 font_style['font-variant'] = prop.get_variant()
-            weight = fm.weight_dict[prop.get_weight()]
+            weight = prop.get_weight()
+            weight = fm.weight_dict.get(weight, weight)  # convert to int
             if weight != 400:
                 font_style['font-weight'] = f'{weight}'

diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py
index 9aa8dccde444..a44d5bd76b9a 100644
--- a/lib/matplotlib/font_manager.py
+++ b/lib/matplotlib/font_manager.py
@@ -744,7 +744,7 @@ def get_variant(self):

     def get_weight(self):
         """
-        Set the font weight.  Options are: A numeric value in the
+        Get the font weight.  Options are: A numeric value in the
         range 0-1000 or one of 'light', 'normal', 'regular', 'book',
         'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold',
         'heavy', 'extra bold', 'black'


# ENH Turn `TargetEncoder` into a metadata router and route `groups` to `cv` object

#### Reference Issues/PRs
closes #32076
supersedes #32239 and #32843

#### What does this implement/fix? Explain your changes.
- turns `TargetEncoder` into a metadata router that routes `groups` to the internal splitter
- adds more input options for `cv` init param (as discussed here https://github.com/scikit-learn/scikit-learn/issues/32076#issuecomment-3307697377)
- exposes cv_ attribute
- add tests

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our Automated Contributions Policy:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [x] Research and understanding

diff --git a/sklearn/preprocessing/tests/test_target_encoder.py b/sklearn/preprocessing/tests/test_target_encoder.py
index 6965df1779080..427dececda9df 100644
--- a/sklearn/preprocessing/tests/test_target_encoder.py
+++ b/sklearn/preprocessing/tests/test_target_encoder.py
@@ -23,6 +23,7 @@
     TargetEncoder,
 )
 from sklearn.utils.fixes import parse_version
+from sklearn.utils.multiclass import type_of_target

 def _encode_target(X_ordinal, y_numeric, n_categories, smooth):
@@ -130,8 +131,7 @@ def test_encoding(categories, unknown_value, global_random_seed, smooth, target_
     target_encoder = TargetEncoder(
         smooth=smooth,
         categories=categories,
-        cv=n_splits,
-        random_state=global_random_seed,
+        cv=cv,
     )

     X_fit_transform = target_encoder.fit_transform(X_train, y_train)
@@ -220,8 +220,7 @@ def test_encoding_multiclass(

     target_encoder = TargetEncoder(
         smooth=smooth,
-        cv=n_splits,
-        random_state=global_random_seed,
+        cv=cv,
     )
     X_fit_transform = target_encoder.fit_transform(X_train, y_train)

@@ -366,9 +365,10 @@ def test_feature_names_out_set_output(y, feature_names):

     X_df = pd.DataFrame({"A": ["a", "b"] * 10, "B": [1, 2] * 10})

-    enc_default = TargetEncoder(cv=2, smooth=3.0, random_state=0)
+    cv = StratifiedKFold(n_splits=2, random_state=0, shuffle=True)
+    enc_default = TargetEncoder(cv=cv, smooth=3.0)
     enc_default.set_output(transform="default")
-    enc_pandas = TargetEncoder(cv=2, smooth=3.0, random_state=0)
+    enc_pandas = TargetEncoder(cv=cv, smooth=3.0)
     enc_pandas.set_output(transform="pandas")

     X_default = enc_default.fit_transform(X_df, y)
@@ -452,7 +452,7 @@ def test_multiple_features_quick(to_pandas, smooth, target_type):
         dtype=np.float64,
     )

-    enc = TargetEncoder(smooth=smooth, cv=2, random_state=0)
+    enc = TargetEncoder(smooth=smooth, cv=cv)
     X_fit_transform = enc.fit_transform(X_train, y_train)
     assert_allclose(X_fit_transform, expected_X_fit_transform)

@@ -479,7 +479,11 @@ def test_constant_target_and_feature(y, y_mean, smooth):
     X = np.array([[1] * 20]).T
     n_samples = X.shape[0]

-    enc = TargetEncoder(cv=2, smooth=smooth, random_state=0)
+    if type_of_target(y) == "continuous":
+        cv = KFold(n_splits=2, random_state=0, shuffle=True)
+    else:
+        cv = StratifiedKFold(n_splits=2, random_state=0, shuffle=True)
+    enc = TargetEncoder(cv=cv, smooth=smooth)
     X_trans = enc.fit_transform(X, y)
     assert_allclose(X_trans, np.repeat([[y_mean]], n_samples, axis=0))
     assert enc.encodings_[0][0] == pytest.approx(y_mean)
@@ -504,10 +508,12 @@ def test_fit_transform_not_associated_with_y_if_ordinal_categorical_is_not(
     y_train = y_train[y_sorted_indices]
     X_train = X_train[y_sorted_indices]

-    target_encoder = TargetEncoder(shuffle=True, random_state=global_random_seed)
+    target_encoder = TargetEncoder(
+        cv=KFold(n_splits=2, random_state=global_random_seed, shuffle=True)
+    )
     X_encoded_train_shuffled = target_encoder.fit_transform(X_train, y_train)

-    target_encoder = TargetEncoder(shuffle=False)
+    target_encoder = TargetEncoder(cv=KFold(n_splits=2, shuffle=False))
     X_encoded_train_no_shuffled = target_encoder.fit_transform(X_train, y_train)

     # Check that no information about y_train has leaked into X_train:
@@ -541,7 +547,7 @@ def test_smooth_zero():
     X = np.array([[0, 0, 0, 0, 0, 1, 1, 1, 1, 1]]).T
     y = np.array([2.1, 4.3, 1.2, 3.1, 1.0, 9.0, 10.3, 14.2, 13.3, 15.0])

-    enc = TargetEncoder(smooth=0.0, shuffle=False, cv=2)
+    enc = TargetEncoder(smooth=0.0, cv=KFold(n_splits=2, shuffle=False))
     X_trans = enc.fit_transform(X, y)

     # With cv = 2, category 0 does not exist in the second half, thus
@@ -578,7 +584,10 @@ def test_invariance_of_encoding_under_label_permutation(smooth, global_random_se
     X_train_permuted = permutated_labels[X_train.astype(np.int32)]
     X_test_permuted = permutated_labels[X_test.astype(np.int32)]

-    target_encoder = TargetEncoder(smooth=smooth, random_state=global_random_seed)
+    target_encoder = TargetEncoder(
+        smooth=smooth,
+        cv=KFold(n_splits=2, shuffle=True, random_state=global_random_seed),
+    )
     X_train_encoded = target_encoder.fit_transform(X_train, y_train)
     X_test_encoded = target_encoder.transform(X_test)

@@ -660,8 +669,9 @@ def test_target_encoding_for_linear_regression(smooth, global_random_seed):

     # Now do the same with target encoding using the internal CV mechanism
     # implemented when using fit_transform.
+    cv = KFold(shuffle=True, random_state=rng)
     model_with_cv = make_pipeline(
-        TargetEncoder(smooth=smooth, random_state=rng), linear_regression
+        TargetEncoder(smooth=smooth, cv=cv), linear_regression
     ).fit(X_train, y_train)

     # This model should be able to fit the data well and also generalise to the
@@ -682,9 +692,7 @@ def test_target_encoding_for_linear_regression(smooth, global_random_seed):

     # Let's now disable the internal cross-validation by calling fit and then
     # transform separately on the training set:
-    target_encoder = TargetEncoder(smooth=smooth, random_state=rng).fit(
-        X_train, y_train
-    )
+    target_encoder = TargetEncoder(smooth=smooth, cv=cv).fit(X_train, y_train)
     X_enc_no_cv_train = target_encoder.transform(X_train)
     X_enc_no_cv_test = target_encoder.transform(X_test)
     model_no_cv = linear_regression.fit(X_enc_no_cv_train, y_train)
@@ -752,3 +760,15 @@ def test_target_encoder_raises_cv_overlap(global_random_seed):
     msg = "Validation indices from `cv` must cover each sample index exactly once"
     with pytest.raises(ValueError, match=msg):
         encoder.fit_transform(X, y)
+
+
+# TODO(1.11): remove after deprecation
+def test_target_encoder_shuffle_random_state_deprecated():
+    X, y = make_regression(n_samples=100, n_features=3, random_state=0)
+    msg = "`TargetEncoder.shuffle` and `TargetEncoder.random_state` are deprecated"
+    with pytest.warns(FutureWarning, match=msg):
+        encoder = TargetEncoder(shuffle=False)
+        encoder.fit_transform(X, y)
+    with pytest.warns(FutureWarning, match=msg):
+        encoder = TargetEncoder(random_state=0)
+        encoder.fit_transform(X, y)

diff --git a/examples/ensemble/plot_gradient_boosting_categorical.py b/examples/ensemble/plot_gradient_boosting_categorical.py
index 5e6957b0945b4..c67aa716ea8f7 100644
--- a/examples/ensemble/plot_gradient_boosting_categorical.py
+++ b/examples/ensemble/plot_gradient_boosting_categorical.py
@@ -160,11 +160,14 @@
 # held-out part. This way, each sample is encoded using statistics from data it
 # was not part of, preventing information leakage from the target.

+from sklearn.model_selection import KFold
 from sklearn.preprocessing import TargetEncoder

 target_encoder = make_column_transformer(
     (
-        TargetEncoder(target_type="continuous", random_state=42),
+        TargetEncoder(
+            target_type="continuous", cv=KFold(shuffle=True, random_state=42)
+        ),
         make_column_selector(dtype_include="category"),
     ),
     remainder="passthrough",
diff --git a/examples/preprocessing/plot_target_encoder_cross_val.py b/examples/preprocessing/plot_target_encoder_cross_val.py
index d44ee2c6ba021..06635abd0d2e4 100644
--- a/examples/preprocessing/plot_target_encoder_cross_val.py
+++ b/examples/preprocessing/plot_target_encoder_cross_val.py
@@ -111,10 +111,13 @@
 # Next, we create a pipeline with the target encoder and ridge model. The pipeline
 # uses :meth:`TargetEncoder.fit_transform` which uses :term:`cross fitting`. We
 # see that the model fits the data well and generalizes to the test set:
+from sklearn.model_selection import KFold
 from sklearn.pipeline import make_pipeline
 from sklearn.preprocessing import TargetEncoder

-model_with_cf = make_pipeline(TargetEncoder(random_state=0), ridge)
+model_with_cf = make_pipeline(
+    TargetEncoder(cv=KFold(shuffle=True, random_state=0)), ridge
+)
 model_with_cf.fit(X_train, y_train)
 print("Model with CF on train set: ", model_with_cf.score(X_train, y_train))
 print("Model with CF on test set: ", model_with_cf.score(X_test, y_test))
diff --git a/examples/release_highlights/plot_release_highlights_1_3_0.py b/examples/release_highlights/plot_release_highlights_1_3_0.py
index fe352c2eb1746..f05abe874c4c3 100644
--- a/examples/release_highlights/plot_release_highlights_1_3_0.py
+++ b/examples/release_highlights/plot_release_highlights_1_3_0.py
@@ -73,12 +73,13 @@
 # More details in the :ref:`User Guide <target_encoder>`.
 import numpy as np

+from sklearn.model_selection import KFold
 from sklearn.preprocessing import TargetEncoder

 X = np.array([["cat"] * 30 + ["dog"] * 20 + ["snake"] * 38], dtype=object).T
 y = [90.3] * 30 + [20.4] * 20 + [21.2] * 38

-enc = TargetEncoder(random_state=0)
+enc = TargetEncoder(cv=KFold(shuffle=True, random_state=0))
 X_trans = enc.fit_transform(X, y)

 enc.encodings_
diff --git a/sklearn/preprocessing/_target_encoder.py b/sklearn/preprocessing/_target_encoder.py
index c5a927d9ddca6..c83ac38b3091a 100644
--- a/sklearn/preprocessing/_target_encoder.py
+++ b/sklearn/preprocessing/_target_encoder.py
@@ -1,6 +1,7 @@
 # Authors: The scikit-learn developers
 # SPDX-License-Identifier: BSD-3-Clause

+import warnings
 from numbers import Real

 import numpy as np
@@ -130,6 +131,10 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         applies if `cv` is an int or `None`. If `cv` is a cross-validation generator or
         an iterable, `shuffle` is ignored.

+        .. deprecated:: 1.9
+            `shuffle` is deprecated and will be removed in 1.11. Pass a cross-validation
+            generator as `cv` argument to specify the shuffling instead.
+
     random_state : int, RandomState instance or None, default=None
         When `shuffle` is True, `random_state` affects the ordering of the
         indices, which controls the randomness of each fold. Otherwise, this
@@ -137,6 +142,11 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         Pass an int for reproducible output across multiple function calls.
         See :term:`Glossary <random_state>`.

+        .. deprecated:: 1.9
+            `random_state` is deprecated and will be removed in 1.11. Pass a
+            cross-validation generator as `cv` argument to specify the random state of
+            the shuffling instead.
+
     Attributes
     ----------
     encodings_ : list of shape (n_features,) or (n_features * n_classes) of \
@@ -221,18 +231,19 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         "target_type": [StrOptions({"auto", "continuous", "binary", "multiclass"})],
         "smooth": [StrOptions({"auto"}), Interval(Real, 0, None, closed="left")],
         "cv": ["cv_object"],
-        "shuffle": ["boolean"],
-        "random_state": ["random_state"],
+        "shuffle": ["boolean", StrOptions({"deprecated"})],
+        "random_state": ["random_state", StrOptions({"deprecated"})],
     }

+    # TODO(1.11) remove `shuffle` and `random_state` params, which had been deprecated
     def __init__(
         self,
         categories="auto",
         target_type="auto",
         smooth="auto",
         cv=5,
-        shuffle=True,
-        random_state=None,
+        shuffle="deprecated",
+        random_state="deprecated",
     ):
         self.categories = categories
         self.smooth = smooth
@@ -325,12 +336,29 @@ def fit_transform(self, X, y, **params):

         X_ordinal, X_known_mask, y_encoded, n_categories = self._fit_encodings_all(X, y)

+        # TODO(1.11): remove code block
+        if self.shuffle != "deprecated" or self.random_state != "deprecated":
+            warnings.warn(
+                "`TargetEncoder.shuffle` and `TargetEncoder.random_state` are "
+                "deprecated in version 1.9 and will be removed in version 1.11. Pass a "
+                "cross-validation generator as `cv` argument to specify the shuffling "
+                "behaviour instead.",
+                FutureWarning,
+            )
+        shuffle = True if self.shuffle == "deprecated" else self.shuffle
+        cv_kwargs = {"shuffle": shuffle}
+        if self.random_state != "deprecated":
+            cv_kwargs["random_state"] = self.random_state
+
+        # TODO(1.11): pass shuffle=True to keep backwards compatibility for default
+        # inputs (will be ignored in `check_cv` if a cv object is passed);
+        # `random_state` already defaults to `None` in `check_cv` and doesn't need to
+        # be passed here
         cv = check_cv(
             self.cv,
             y,
             classifier=self.target_type_ != "continuous",
-            shuffle=self.shuffle,
-            random_state=self.random_state,
+            **cv_kwargs,
         )

         if _routing_enabled():


# Make more of the "tools" of scikit-learn Array API compatible

🚨 🚧 This issue requires a bit of patience and experience to contribute to 🚧 🚨

- Original issue introducing array API in scikit-learn: #22352
- array API official doc/spec: https://data-apis.org/array-api/
- scikit-learn doc: https://scikit-learn.org/dev/modules/array_api.html

Please mention this issue when you create a PR, but please don't write "closes #26024" or "fixes #26024".

scikit-learn contains lots of useful tools, in addition to the many estimators it has. For example [metrics](https://scikit-learn.org/stable/modules/classes.html#sklearn-metrics-metrics), [pipelines](https://scikit-learn.org/stable/modules/classes.html#module-sklearn.pipeline), [pre-processing](https://scikit-learn.org/stable/modules/classes.html#module-sklearn.preprocessing) and [mode selection](https://scikit-learn.org/stable/modules/classes.html#module-sklearn.model_selection). These are useful to and used by people who do not necessarily use an estimator from scikit-learn. This is great.

The fact that many users install scikit-learn "just" to use `train_test_split` is a testament to how useful it is to provide easy to use tools that do the right(!) thing. Instead of everyone implementing them from scratch because it is "easy" and making mistakes along the way.

In this issue I'd like to collect and track work related to making it easier to use all these "tools" from scikit-learn even if you are not using Numpy arrays for your data. In particular thanks to the Array API standard it should be "not too much work" to make things usable with data that is in an array that conforms to the Array API standard.

There is work in #25956 and #22554 which adds the basic infrastructure needed to use "array API arrays".

The goal of this issue is to make code like the following work:
```python
>>> from sklearn.preprocessing import MinMaxScaler
>>> from sklearn import config_context
>>> from sklearn.datasets import make_classification
>>> import torch
>>> X_np, y_np = make_classification(random_state=0)
>>> X_torch = torch.asarray(X_np, device="cuda", dtype=torch.float32)
>>> y_torch = torch.asarray(y_np, device="cuda", dtype=torch.float32)

>>> with config_context(array_api_dispatch=True):
...     # For example using MinMaxScaler on PyTorch tensors
...     scale = MinMaxScaler()
...     X_trans = scale.fit_transform(X_torch, y_torch)
...     assert type(X_trans) == type(X_torch)
...     assert X_trans.device == X_torch.device
```

The first step (or maybe part of the first) is to check which of them already "just work". After that is done we can start the work (one PR per class/function) making changes.

## Guidelines for testing

General comment: most of the time when we add array API support to a function in scikit-learn, we do not touch the existing (numpy-only) tests to make sure that the PR does not change the default behavior of scikit-learn on traditional inputs when array API is not enabled.

In the case of an estimator, it can be enough to add the `array_api_support=True` estimator tag in a method named `__sklearn_tags__`. For metric functions, just register it in the `array_api_metric_checkers` in `sklearn/metrics/tests/test_common.py` to include it in the common test.

For other kinds of functions not covered by existing common tests, or when the array API support depends heavily on non-default values, it might be required to add one or more new test functions to the related module-level test file. The general testing scheme is the following:

- generate some random test data with numpy or `sklearn.datasets.make_*`;
- call the function once on the numpy inputs without enabling array API dispatch;
- convert the inputs to a namespace / device combo passed as parameter to the test;
- call the function with array API dispatching enabled (under a `with sklearn.config_context(array_api_dispatch=True)` block
- check that the results are on the same namespace and device as the input
- convert back the output to a numpy array using `_convert_to_numpy`
- compare the original / reference numpy results and the `xp` computation results converted back to numpy using `assert_allclose` or similar.

Those tests should have `array_api` somewhere in their name to makes sure that we can run all the array API compliance tests with a keyword search in the pytest command line, e.g.:

```
pytest -k array_api sklearn/some/subpackage
```

In particular, for cost reasons, our CUDA GPU CI only runs `pytest -k array_api sklearn`. So it's very important to respect this naming conventions, otherwise we will not tests all what we are supposed to test on CUDA.

More generally, look at merged array API pull requests to see how testing is typically handled.

diff --git a/sklearn/_loss/tests/test_link.py b/sklearn/_loss/tests/test_link.py
index e5a665f8d48ac..f85b223c6f1fb 100644
--- a/sklearn/_loss/tests/test_link.py
+++ b/sklearn/_loss/tests/test_link.py
@@ -1,7 +1,8 @@
 import numpy as np
 import pytest
-from numpy.testing import assert_allclose, assert_array_equal
+from numpy.testing import assert_allclose

+from sklearn import config_context
 from sklearn._loss.link import (
     _LINKS,
     HalfLogitLink,
@@ -9,6 +10,12 @@
     MultinomialLogit,
     _inclusive_low_high,
 )
+from sklearn.utils._array_api import (
+    _convert_to_numpy,
+    _get_namespace_device_dtype_ids,
+    yield_namespace_device_dtype_combinations,
+)
+from sklearn.utils._testing import _array_api_for_tests

 LINK_FUNCTIONS = list(_LINKS.values())

@@ -28,10 +35,10 @@ def test_interval_raises():
         Interval(0, 1, False, True),
         Interval(0, 1, True, False),
         Interval(0, 1, True, True),
-        Interval(-np.inf, np.inf, False, False),
-        Interval(-np.inf, np.inf, False, True),
-        Interval(-np.inf, np.inf, True, False),
-        Interval(-np.inf, np.inf, True, True),
+        Interval(-float("inf"), float("inf"), False, False),
+        Interval(-float("inf"), float("inf"), False, True),
+        Interval(-float("inf"), float("inf"), True, False),
+        Interval(-float("inf"), float("inf"), True, True),
         Interval(-10, -1, False, False),
         Interval(-10, -1, False, True),
         Interval(-10, -1, True, False),
@@ -39,10 +46,10 @@ def test_interval_raises():
     ],
 )
 def test_is_in_range(interval):
-    # make sure low and high are always within the interval, used for linspace
+    """Test that low and high are always within the interval used for linspace."""
     low, high = _inclusive_low_high(interval)
-
     x = np.linspace(low, high, num=10)
+
     assert interval.includes(x)

     # x contains lower bound
@@ -59,7 +66,7 @@ def test_is_in_range(interval):

 @pytest.mark.parametrize("link", LINK_FUNCTIONS)
 def test_link_inverse_identity(link, global_random_seed):
-    # Test that link of inverse gives identity.
+    """Test that link of inverse gives identity."""
     rng = np.random.RandomState(global_random_seed)
     link = link()
     n_samples, n_classes = 100, None
@@ -81,31 +88,51 @@ def test_link_inverse_identity(link, global_random_seed):
     assert_allclose(link.inverse(link.link(y_pred)), y_pred)

+@pytest.mark.parametrize(
+    "namespace, device, dtype_name",
+    yield_namespace_device_dtype_combinations(),
+    ids=_get_namespace_device_dtype_ids,
+)
 @pytest.mark.parametrize("link", LINK_FUNCTIONS)
-def test_link_out_argument(link):
-    # Test that out argument gets assigned the result.
-    rng = np.random.RandomState(42)
+def test_link_inverse_array_api(
+    namespace, device, dtype_name, link, global_random_seed
+):
+    """Test that link and inverse link give same result for array API inputs."""
+    rng = np.random.RandomState(global_random_seed)
     link = link()
     n_samples, n_classes = 100, None
+    # The values for `raw_prediction` are limited from -20 to 20 because in the
+    # class `LogitLink` the term `expit(x)` comes very close to 1 for large
+    # positive x and therefore loses precision.
     if link.is_multiclass:
         n_classes = 10
-        raw_prediction = rng.normal(loc=0, scale=10, size=(n_samples, n_classes))
+        raw_prediction = rng.uniform(low=-20, high=20, size=(n_samples, n_classes))
         if isinstance(link, MultinomialLogit):
             raw_prediction = link.symmetrize_raw_prediction(raw_prediction)
-    else:
-        # So far, the valid interval of raw_prediction is (-inf, inf) and
-        # we do not need to distinguish.
+    elif isinstance(link, HalfLogitLink):
         raw_prediction = rng.uniform(low=-10, high=10, size=(n_samples))
+    else:
+        raw_prediction = rng.uniform(low=-20, high=20, size=(n_samples))

-    y_pred = link.inverse(raw_prediction, out=None)
-    out = np.empty_like(raw_prediction)
-    y_pred_2 = link.inverse(raw_prediction, out=out)
-    assert_allclose(y_pred, out)
-    assert_array_equal(out, y_pred_2)
-    assert np.shares_memory(out, y_pred_2)
-
-    out = np.empty_like(y_pred)
-    raw_prediction_2 = link.link(y_pred, out=out)
-    assert_allclose(raw_prediction, out)
-    assert_array_equal(out, raw_prediction_2)
-    assert np.shares_memory(out, raw_prediction_2)
+    xp = _array_api_for_tests(namespace, device)
+    if dtype_name != "float64":
+        raw_prediction *= 0.5  # avoid overflow
+        rtol = 1e-3 if n_classes else 1e-4
+    else:
+        rtol = 1e-8
+
+    with config_context(array_api_dispatch=True):
+        raw_prediction_xp = xp.asarray(raw_prediction.astype(dtype_name), device=device)
+        assert_allclose(
+            _convert_to_numpy(link.inverse(raw_prediction_xp), xp=xp),
+            link.inverse(raw_prediction),
+            rtol=rtol,
+        )
+
+        y_pred = link.inverse(raw_prediction)
+        y_pred_xp = xp.asarray(y_pred.astype(dtype_name), device=device)
+        assert_allclose(
+            _convert_to_numpy(link.link(y_pred_xp), xp=xp),
+            link.link(y_pred),
+            rtol=rtol,
+        )
diff --git a/sklearn/utils/tests/test_array_api.py b/sklearn/utils/tests/test_array_api.py
index b300bd5aeebc6..e1abb40fc7a51 100644
--- a/sklearn/utils/tests/test_array_api.py
+++ b/sklearn/utils/tests/test_array_api.py
@@ -6,6 +6,7 @@
 import scipy
 import scipy.sparse as sp
 from numpy.testing import assert_allclose
+from scipy.special import expit, logit

 from sklearn._config import config_context
 from sklearn._loss import HalfMultinomialLoss
@@ -18,11 +19,13 @@
     _convert_to_numpy,
     _count_nonzero,
     _estimator_with_converted_arrays,
+    _expit,
     _fill_diagonal,
     _get_namespace_device_dtype_ids,
     _half_multinomial_loss,
     _is_numpy_namespace,
     _isin,
+    _logit,
     _logsumexp,
     _matching_numpy_dtype,
     _max_precision_float_dtype,
@@ -824,6 +827,31 @@ def test_median(namespace, device, dtype_name, axis):
     assert_allclose(result_np, _convert_to_numpy(result_xp, xp=xp))

+@pytest.mark.parametrize(
+    "namespace, device, dtype_name", yield_namespace_device_dtype_combinations()
+)
+def test_expit_logit(namespace, device, dtype_name):
+    rtol = 1e-6 if "float32" in str(dtype_name) else 1e-12
+    xp = _array_api_for_tests(namespace, device)
+
+    with config_context(array_api_dispatch=True):
+        x_np = numpy.linspace(-20, 20, 1000).astype(dtype_name)
+        x_xp = xp.asarray(x_np, device=device)
+        assert_allclose(
+            _convert_to_numpy(_expit(x_xp), xp=xp),
+            expit(x_np),
+            rtol=rtol,
+        )
+
+        x_np = numpy.linspace(0, 1, 1000).astype(dtype_name)
+        x_xp = xp.asarray(x_np, device=device)
+        assert_allclose(
+            _convert_to_numpy(_logit(x_xp), xp=xp),
+            logit(x_np),
+            rtol=rtol,
+        )
+
+
 @pytest.mark.parametrize(
     "array_namespace, device_, dtype_name", yield_namespace_device_dtype_combinations()
 )

diff --git a/sklearn/_loss/link.py b/sklearn/_loss/link.py
index 03677c8da6139..6a42e5508fad1 100644
--- a/sklearn/_loss/link.py
+++ b/sklearn/_loss/link.py
@@ -7,11 +7,11 @@

 from abc import ABC, abstractmethod
 from dataclasses import dataclass
+from math import ulp

-import numpy as np
-from scipy.special import expit, logit
 from scipy.stats import gmean

+from sklearn.utils._array_api import _expit, _logit, get_namespace
 from sklearn.utils.extmath import softmax

@@ -41,49 +41,50 @@ def includes(self, x):
         -------
         result : bool
         """
+        xp, _ = get_namespace(x)
         if self.low_inclusive:
-            low = np.greater_equal(x, self.low)
+            low = xp.greater_equal(x, self.low)
         else:
-            low = np.greater(x, self.low)
+            low = xp.greater(x, self.low)

-        if not np.all(low):
+        if not xp.all(low):
             return False

         if self.high_inclusive:
-            high = np.less_equal(x, self.high)
+            high = xp.less_equal(x, self.high)
         else:
-            high = np.less(x, self.high)
+            high = xp.less(x, self.high)

         # Note: np.all returns numpy.bool_
-        return bool(np.all(high))
+        return bool(xp.all(high))

-def _inclusive_low_high(interval, dtype=np.float64):
+def _inclusive_low_high(interval):
     """Generate values low and high to be within the interval range.

     This is used in tests only.

     Returns
     -------
-    low, high : tuple
+    low, high : tuple of floats
         The returned values low and high lie within the interval.
     """
-    eps = 10 * np.finfo(dtype).eps
-    if interval.low == -np.inf:
+    eps = 10 * ulp(1)
+    if interval.low == -float("inf"):
         low = -1e10
     elif interval.low < 0:
         low = interval.low * (1 - eps) + eps
     else:
         low = interval.low * (1 + eps) + eps

-    if interval.high == np.inf:
+    if interval.high == float("inf"):
         high = 1e10
     elif interval.high < 0:
         high = interval.high * (1 + eps) - eps
     else:
         high = interval.high * (1 - eps) - eps

-    return low, high
+    return float(low), float(high)

 class BaseLink(ABC):
@@ -105,11 +106,11 @@ class BaseLink(ABC):

     # Usually, raw_prediction may be any real number and y_pred is an open
     # interval.
-    # interval_raw_prediction = Interval(-np.inf, np.inf, False, False)
-    interval_y_pred = Interval(-np.inf, np.inf, False, False)
+    # interval_raw_prediction = Interval(-float("inf"), float("inf"), False, False)
+    interval_y_pred = Interval(-float("inf"), float("inf"), False, False)

     @abstractmethod
-    def link(self, y_pred, out=None):
+    def link(self, y_pred):
         """Compute the link function g(y_pred).

         The link function maps (predicted) target values to raw predictions,
@@ -119,19 +120,15 @@ def link(self, y_pred, out=None):
         ----------
         y_pred : array
             Predicted target values.
-        out : array
-            A location into which the result is stored. If provided, it must
-            have a shape that the inputs broadcast to. If not provided or None,
-            a freshly-allocated array is returned.

         Returns
         -------
-        out : array
+        array
             Output array, element-wise link function.
         """

     @abstractmethod
-    def inverse(self, raw_prediction, out=None):
+    def inverse(self, raw_prediction):
         """Compute the inverse link function h(raw_prediction).

         The inverse link function maps raw predictions to predicted target
@@ -141,14 +138,10 @@ def inverse(self, raw_prediction, out=None):
         ----------
         raw_prediction : array
             Raw prediction values (in link space).
-        out : array
-            A location into which the result is stored. If provided, it must
-            have a shape that the inputs broadcast to. If not provided or None,
-            a freshly-allocated array is returned.

         Returns
         -------
-        out : array
+        array
             Output array, element-wise inverse link function.
         """

@@ -156,12 +149,8 @@ def inverse(self, raw_prediction, out=None):
 class IdentityLink(BaseLink):
     """The identity link function g(x)=x."""

-    def link(self, y_pred, out=None):
-        if out is not None:
-            np.copyto(out, y_pred)
-            return out
-        else:
-            return y_pred
+    def link(self, y_pred):
+        return y_pred  # TODO: Should we copy?

     inverse = link

@@ -169,13 +158,15 @@ def link(self, y_pred, out=None):
 class LogLink(BaseLink):
     """The log link function g(x)=log(x)."""

-    interval_y_pred = Interval(0, np.inf, False, False)
+    interval_y_pred = Interval(0, float("inf"), False, False)

-    def link(self, y_pred, out=None):
-        return np.log(y_pred, out=out)
+    def link(self, y_pred):
+        xp, _ = get_namespace(y_pred)
+        return xp.log(y_pred)

-    def inverse(self, raw_prediction, out=None):
-        return np.exp(raw_prediction, out=out)
+    def inverse(self, raw_prediction):
+        xp, _ = get_namespace(raw_prediction)
+        return xp.exp(raw_prediction)

 class LogitLink(BaseLink):
@@ -183,11 +174,11 @@ class LogitLink(BaseLink):

     interval_y_pred = Interval(0, 1, False, False)

-    def link(self, y_pred, out=None):
-        return logit(y_pred, out=out)
+    def link(self, y_pred):
+        return _logit(y_pred)

-    def inverse(self, raw_prediction, out=None):
-        return expit(raw_prediction, out=out)
+    def inverse(self, raw_prediction):
+        return _expit(raw_prediction)

 class HalfLogitLink(BaseLink):
@@ -198,13 +189,11 @@ class HalfLogitLink(BaseLink):

     interval_y_pred = Interval(0, 1, False, False)

-    def link(self, y_pred, out=None):
-        out = logit(y_pred, out=out)
-        out *= 0.5
-        return out
+    def link(self, y_pred):
+        return 0.5 * _logit(y_pred)

-    def inverse(self, raw_prediction, out=None):
-        return expit(2 * raw_prediction, out)
+    def inverse(self, raw_prediction):
+        return _expit(2 * raw_prediction)

 class MultinomialLogit(BaseLink):
@@ -257,20 +246,17 @@ class MultinomialLogit(BaseLink):
     interval_y_pred = Interval(0, 1, False, False)

     def symmetrize_raw_prediction(self, raw_prediction):
-        return raw_prediction - np.mean(raw_prediction, axis=1)[:, np.newaxis]
+        xp, _ = get_namespace(raw_prediction)
+        return raw_prediction - xp.mean(raw_prediction, axis=1)[:, None]

-    def link(self, y_pred, out=None):
+    def link(self, y_pred):
+        xp, _ = get_namespace(y_pred)
         # geometric mean as reference category
         gm = gmean(y_pred, axis=1)
-        return np.log(y_pred / gm[:, np.newaxis], out=out)
+        return xp.log(y_pred / gm[:, None])

-    def inverse(self, raw_prediction, out=None):
-        if out is None:
-            return softmax(raw_prediction, copy=True)
-        else:
-            np.copyto(out, raw_prediction)
-            softmax(out, copy=False)
-            return out
+    def inverse(self, raw_prediction):
+        return softmax(raw_prediction)

 _LINKS = {
diff --git a/sklearn/utils/_array_api.py b/sklearn/utils/_array_api.py
index 020a758589713..0bd1869695952 100644
--- a/sklearn/utils/_array_api.py
+++ b/sklearn/utils/_array_api.py
@@ -591,18 +591,33 @@ def move_to(*arrays, xp, device):
     )

-def _expit(X, out=None, xp=None):
+def _expit(x, out=None, xp=None):
     # The out argument for exp and hence expit is only supported for numpy,
     # but not in the Array API specification.
-    xp, _ = get_namespace(X, xp=xp)
+    xp, _ = get_namespace(x, xp=xp)
     if _is_numpy_namespace(xp):
-        if out is not None:
-            special.expit(X, out=out)
-        else:
-            out = special.expit(X)
-        return out
+        return special.expit(x, out=out)
+
+    return 1.0 / (1.0 + xp.exp(-x))

-    return 1.0 / (1.0 + xp.exp(-X))
+
+def _logit(x, out=None, xp=None):
+    # The out argument for log and hence logit is only supported for numpy,
+    # but not in the Array API specification.
+    xp, _ = get_namespace(x, xp=xp)
+    if _is_numpy_namespace(xp):
+        return special.logit(x, out=out)
+
+    # See https://github.com/scipy/xsf/blob/e0c4d22d6ae768b39efc69586f1e8d5560a32fc5/include/xsf/log_exp.h#L30
+    def logit_v2(x):
+        s = 2 * (x - 0.5)
+        return xp.log1p(s) - xp.log1p(-s)
+
+    return xp.where(
+        xp.logical_or(x < 0.3, x > 0.65),
+        xp.log(x / (1 - x)),
+        logit_v2(x),
+    )

 def _validate_diagonal_args(array, value, xp):