Skip to content

Commit f903723

Browse files
bruAristimunhaqbarthelemylucascolley
authored
ENH: cov: expose correction and weights parameters (#690)
* ENH: expose correction and weights parameters in cov Resolves #688. Adds `axis`, `correction`, `frequency_weights`, and `weights` to `cov`, giving users control over the degrees-of-freedom correction and the observation-axis / weighted variants that `numpy.cov` and `torch.cov` already support. Naming follows array-api conventions (`axis`, `correction`) rather than numpy's (`rowvar`, `bias`, `ddof`); the docstring includes a one-to-one mapping. The delegation moves observations to the last axis via `xp.moveaxis`, collapsing `rowvar` out of the backend dispatch — only `ddof` vs `correction` differs between branches. Dask's native `cov` forces `.compute()` on a lazy scalar when any weights are given, so weighted dask inputs fall through to the generic implementation, which is fully lazy. * MNT: drop device= in cov weights * STY: formatter * TST: add bias tests from #691 * Update _funcs.py Co-authored-by: Quentin Barthélemy <q.barthelemy@gmail.com> * Update _delegation.py Co-authored-by: Quentin Barthélemy <q.barthelemy@gmail.com> * MNT: rename weights params to fweights/aweights * ENH: validate weights shape in cov * MNT: address lucascolley review * MNT: move weights validation to generic cov * Update _funcs.py Co-authored-by: Quentin Barthélemy <q.barthelemy@gmail.com> * DOC: explain non-integer correction use cases in cov Addresses review feedback (kgryte, betatim) that the motivation for allowing non-integer correction was not obvious from the docstring: weighted unbiased correction and autocorrelated data both require fractional values. * TST: cover weight validation error paths in cov Adds tests for the 1-D shape and length checks in the generic cov path. Raises the diff coverage for this PR from 93.33% to 100%. * Preserve torch autograd in the batched cov path The generic `cov` implementation called `xp.asarray(m)` on its input. For torch this detaches gradients and mutates the caller's tensor in place, so `cov` on a batched tensor (ndim > 2, which routes to the generic path) with `requires_grad=True` returned a detached result and silently zeroed the input's grad. The call is unnecessary: the delegation layer already guarantees `m` is an array (it calls `array_namespace(m)` and reads `m.ndim`). Drop it, and add a torch autograd regression test. * MNT: address cov review feedback * TYP: clarify array cast in cov warning test * lint * Apply suggestions from code review Co-authored-by: Lucas Colley <lucas.colley8@gmail.com> * MNT: address cov review comments - link numpy/torch functions with intersphinx in the cov docstring (torch added to the intersphinx mapping) - add canonical examples for correction, fweights and aweights - explain the int(correction) cast: torch.cov rejects integer-valued floats such as 1.0 at runtime, so typing.cast is not enough - simplify the integer-correction check to float(correction).is_integer() - comment that the NaN shape checks account for Dask reporting unknown dimensions as NaN instead of None --------- Co-authored-by: Quentin Barthélemy <q.barthelemy@gmail.com> Co-authored-by: Lucas Colley <lucas.colley8@gmail.com>
1 parent 76f6b0d commit f903723

4 files changed

Lines changed: 427 additions & 37 deletions

File tree

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"numpy": ("https://numpy.org/doc/stable", None),
6161
"jax": ("https://docs.jax.dev/en/latest", None),
6262
"pytest": ("https://docs.pytest.org/en/stable/", None),
63+
"torch": ("https://docs.pytorch.org/docs/stable", None),
6364
}
6465

6566
nitpick_ignore = [

src/array_api_extra/_delegation.py

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -169,36 +169,91 @@ def broadcast_shapes(
169169
return _funcs.broadcast_shapes(*shapes)
170170

171171

172-
def cov(m: Array, /, *, xp: ArrayNamespace | None = None) -> Array:
172+
def cov(
173+
m: Array,
174+
/,
175+
*,
176+
axis: int = -1,
177+
correction: float = 1,
178+
fweights: Array | None = None,
179+
aweights: Array | None = None,
180+
xp: ArrayNamespace | None = None,
181+
) -> Array:
173182
"""
174183
Estimate a covariance matrix (or a stack of covariance matrices).
175184
176185
Covariance indicates the level to which two variables vary together.
177186
If we examine *N*-dimensional samples, :math:`X = [x_1, x_2, ... x_N]^T`,
178187
each with *M* observations, then element :math:`C_{ij}` of the
179-
:math:`N \times N` covariance matrix is the covariance of
188+
:math:`N \\times N` covariance matrix is the covariance of
180189
:math:`x_i` and :math:`x_j`. The element :math:`C_{ii}` is the variance
181190
of :math:`x_i`.
182191
183-
With the exception of supporting batch input, this provides a subset of
184-
the functionality of ``numpy.cov``.
192+
Extends :func:`numpy.cov` with support for batch input.
193+
Naming follows the array API conventions used elsewhere in
194+
this library (``axis``, ``correction``) rather than the NumPy spellings
195+
(``rowvar``, ``bias``, ``ddof``); see Notes for the mapping.
185196
186197
Parameters
187198
----------
188199
m : array
189200
An array of shape ``(..., N, M)`` whose innermost two dimensions
190-
contain *M* observations of *N* variables. That is,
191-
each row of `m` represents a variable, and each column a single
192-
observation of all those variables.
201+
contain *M* observations of *N* variables by default. The axis of
202+
observations is controlled by `axis`.
203+
axis : int, optional
204+
Axis of `m` containing the observations. Default: ``-1`` (the last
205+
axis), matching the array API convention. Use ``axis=-2`` (or ``0``
206+
for 2-D input) to treat each column as a variable, which
207+
corresponds to ``rowvar=False`` in :func:`numpy.cov`.
208+
correction : int or float, optional
209+
Degrees of freedom correction: normalization divides by
210+
``N - correction`` (for unweighted input). Default: ``1``, which
211+
gives the unbiased estimate (matches :func:`numpy.cov` default of
212+
``bias=False``). Set to ``0`` for the biased estimate (``N``
213+
normalization). Corresponds to ``ddof`` in :func:`numpy.cov` and to
214+
``correction`` in :func:`numpy.var`/:func:`numpy.std` and
215+
:func:`torch.cov`.
216+
Non-integer values are allowed for advanced use cases: the
217+
unbiased correction for weighted observations depends on the
218+
sum and dispersion of the weights and is generally not an
219+
integer, and autocorrelated data may also require a fractional
220+
correction. Non-integer ``correction`` routes through the
221+
generic implementation because :func:`numpy.cov`'s ``ddof`` and
222+
:func:`torch.cov`'s ``correction`` both require integers.
223+
fweights : array, optional
224+
1-D array of integer frequency weights: the number of times each
225+
observation is repeated. Same as ``fweights`` in
226+
:func:`numpy.cov`/:func:`torch.cov`.
227+
aweights : array, optional
228+
1-D array of observation-vector weights (analytic weights). Larger
229+
values mark more important observations. Same as ``aweights`` in
230+
:func:`numpy.cov`/:func:`torch.cov`.
193231
xp : array_namespace, optional
194232
The standard-compatible namespace for `m`. Default: infer.
195233
196234
Returns
197235
-------
198236
array
199-
An array having shape (..., N, N) whose innermost two dimensions represent
237+
An array having shape ``(..., N, N)`` whose innermost two dimensions represent
200238
the covariance matrix of the variables.
201239
240+
Notes
241+
-----
242+
Mapping from :func:`numpy.cov` to this function::
243+
244+
numpy.cov(m, rowvar=True) -> cov(m, axis=-1) # default
245+
numpy.cov(m, rowvar=False) -> cov(m, axis=-2)
246+
numpy.cov(m, bias=True) -> cov(m, correction=0)
247+
numpy.cov(m, ddof=k) -> cov(m, correction=k)
248+
numpy.cov(m, fweights=f) -> cov(m, fweights=f)
249+
numpy.cov(m, aweights=a) -> cov(m, aweights=a)
250+
251+
A ``RuntimeWarning`` is emitted for non-positive effective degrees of
252+
freedom when the effective normalizer can be checked without materializing
253+
a lazy array. When the normalizer itself is lazy (e.g. for weighted Dask
254+
inputs), this check is skipped; choose ``correction`` and weights such that
255+
it is positive.
256+
202257
Examples
203258
--------
204259
>>> import array_api_strict as xp
@@ -246,21 +301,84 @@ def cov(m: Array, /, *, xp: ArrayNamespace | None = None) -> Array:
246301
[ -4.286 , 2.14413333]],
247302
[[ 46.84 , -17.144 ],
248303
[-17.144 , 8.57653333]]], dtype=array_api_strict.float64)
249-
"""
250304
251-
if xp is None:
252-
xp = array_namespace(m)
305+
The normalization can be adjusted with `correction`, and observations
306+
can be weighted with integer frequencies `fweights` or importance
307+
weights `aweights`:
253308
254-
if (
255-
is_numpy_namespace(xp)
256-
or is_cupy_namespace(xp)
257-
or is_torch_namespace(xp)
258-
or is_dask_namespace(xp)
259-
or is_jax_namespace(xp)
260-
) and m.ndim <= 2:
261-
return xp.cov(m)
309+
>>> x = xp.asarray([0., 1., 2., 3., 4.])
310+
>>> xpx.cov(x, xp=xp) # unbiased variance: divide by N - 1
311+
Array(2.5, dtype=array_api_strict.float64)
312+
>>> xpx.cov(x, correction=0, xp=xp) # biased variance: divide by N
313+
Array(2., dtype=array_api_strict.float64)
314+
315+
Giving the two extreme observations frequency 2 via `fweights` is
316+
equivalent to repeating them in `x`:
317+
318+
>>> xpx.cov(x, fweights=xp.asarray([2, 1, 1, 1, 2]), xp=xp)
319+
Array(3., dtype=array_api_strict.float64)
320+
>>> xpx.cov(xp.asarray([0., 0., 1., 2., 3., 4., 4.]), xp=xp)
321+
Array(3., dtype=array_api_strict.float64)
322+
323+
`aweights` instead adjusts the relative importance of observations,
324+
here down-weighting the two extremes:
262325
263-
return _funcs.cov(m, xp=xp)
326+
>>> xpx.cov(x, aweights=xp.asarray([0.5, 1., 1., 1., 0.5]), xp=xp)
327+
Array(1.92, dtype=array_api_strict.float64)
328+
"""
329+
330+
if xp is None:
331+
xp = array_namespace(m, fweights, aweights)
332+
333+
# Validate axis against m.ndim.
334+
ndim = max(m.ndim, 1)
335+
if not -ndim <= axis < ndim:
336+
msg = f"axis {axis} is out of bounds for array of dimension {m.ndim}"
337+
raise IndexError(msg)
338+
339+
# Normalize: observations on the last axis. After this, every backend
340+
# sees the same convention and we never need to deal with `rowvar`.
341+
if m.ndim >= 2 and axis not in (-1, m.ndim - 1):
342+
m = xp.moveaxis(m, axis, -1)
343+
344+
# `numpy.cov` (and cupy/dask/jax) require integer `ddof`; `torch.cov`
345+
# requires integer `correction`. For non-integer-valued `correction`,
346+
# fall through to the generic implementation.
347+
integer_correction = float(correction).is_integer()
348+
has_weights = fweights is not None or aweights is not None
349+
350+
if m.ndim <= 2 and integer_correction:
351+
# Not just for static typing: `correction` may be an integer-valued
352+
# float such as 1.0, which `torch.cov` rejects at runtime.
353+
int_correction = int(correction)
354+
if is_torch_namespace(xp):
355+
fw = None if fweights is None else xp.asarray(fweights)
356+
aw = None if aweights is None else xp.asarray(aweights)
357+
return xp.cov(m, correction=int_correction, fweights=fw, aweights=aw)
358+
# `dask.array.cov` forces `.compute()` whenever weights are given:
359+
# its internal `if fact <= 0` check on a lazy 0-D scalar triggers
360+
# materialization. Route to the generic impl, which is fully lazy
361+
# because it only does sum/matmul and skips that scalar check.
362+
if (
363+
is_numpy_namespace(xp)
364+
or is_cupy_namespace(xp)
365+
or is_jax_namespace(xp)
366+
or (is_dask_namespace(xp) and not has_weights)
367+
):
368+
return xp.cov(
369+
m,
370+
ddof=int_correction,
371+
fweights=fweights,
372+
aweights=aweights,
373+
)
374+
375+
return _funcs.cov(
376+
m,
377+
correction=correction,
378+
fweights=fweights,
379+
aweights=aweights,
380+
xp=xp,
381+
)
264382

265383

266384
def create_diagonal(

src/array_api_extra/_lib/_funcs.py

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -263,31 +263,101 @@ def broadcast_shapes( # numpydoc ignore=PR01,RT01
263263
return tuple(out)
264264

265265

266-
def cov(m: Array, /, *, xp: ArrayNamespace) -> Array: # numpydoc ignore=PR01,RT01
266+
def cov(
267+
m: Array,
268+
/,
269+
*,
270+
correction: float = 1,
271+
fweights: Array | None = None,
272+
aweights: Array | None = None,
273+
xp: ArrayNamespace,
274+
) -> Array: # numpydoc ignore=PR01,RT01
267275
"""See docstring in array_api_extra._delegation."""
268-
m = xp.asarray(m, copy=True)
276+
# NB: no `xp.asarray(m)` here. The delegation layer already guarantees `m`
277+
# is an array (it calls `array_namespace(m)` and reads `m.ndim`), and on
278+
# torch `xp.asarray` detaches gradients and mutates the caller's tensor.
269279
dtype = (
270280
xp.float64 if xp.isdtype(m.dtype, "integral") else xp.result_type(m, xp.float64)
271281
)
272282

273283
m = atleast_nd(m, ndim=2, xp=xp)
274-
m = xp.astype(m, dtype)
275-
276-
avg = xp.mean(m, axis=-1, keepdims=True)
277-
278-
m_shape = eager_shape(m)
279-
fact = m_shape[-1] - 1
284+
# Preserve the historical no-alias guarantee even when the dtype already matches.
285+
m = xp.astype(m, dtype, copy=True)
286+
287+
# Validate weight shapes (eager metadata, lazy-safe).
288+
n_obs = m.shape[-1]
289+
for name, w_in in (("fweights", fweights), ("aweights", aweights)):
290+
if w_in is None:
291+
continue
292+
if w_in.ndim != 1:
293+
msg = f"`{name}` must be 1-D, got ndim={w_in.ndim}"
294+
raise ValueError(msg)
295+
weight_length = w_in.shape[0]
296+
# Unknown dims are `None` per the standard; Dask non-standardly
297+
# reports them as NaN, hence the `isnan` checks below.
298+
if (
299+
weight_length is not None
300+
and n_obs is not None
301+
and not math.isnan(weight_length)
302+
and not math.isnan(n_obs)
303+
and weight_length != n_obs
304+
):
305+
msg = (
306+
f"`{name}` has length {weight_length} but `m` has {n_obs} observations"
307+
)
308+
raise ValueError(msg)
280309

281-
if fact <= 0:
282-
warnings.warn("Degrees of freedom <= 0 for slice", RuntimeWarning, stacklevel=2)
283-
fact = 0
310+
fw = None
311+
if fweights is not None:
312+
fw = xp.astype(xp.asarray(fweights), dtype)
313+
aw = None
314+
if aweights is not None:
315+
aw = xp.astype(xp.asarray(aweights), dtype)
316+
if fw is None and aw is None:
317+
w = None
318+
elif fw is None:
319+
w = aw
320+
elif aw is None:
321+
w = fw
322+
else:
323+
w = fw * aw
284324

285-
m -= avg
286-
m_transpose = xp.matrix_transpose(m)
287-
if xp.isdtype(m_transpose.dtype, "complex floating"):
288-
m_transpose = xp.conj(m_transpose)
289-
c = xp.matmul(m, m_transpose)
290-
c /= fact
325+
if w is None:
326+
avg = xp.mean(m, axis=-1, keepdims=True)
327+
fact = eager_shape(m, axis=-1)[0] - correction
328+
else:
329+
v1 = xp.sum(w, axis=-1)
330+
avg = xp.sum(m * w, axis=-1, keepdims=True) / v1
331+
if aw is None:
332+
fact = v1 - correction
333+
else:
334+
fact = v1 - correction * xp.sum(w * aw, axis=-1) / v1
335+
336+
if not _compat.is_lazy_array(fact):
337+
# Weights are cast to `dtype`, so a complex input produces a complex
338+
# normalizer with a zero imaginary part. Complex ordering is undefined;
339+
# compare its real component instead.
340+
if w is not None:
341+
fact_array = cast(Array, fact)
342+
fact_to_check = (
343+
xp.real(fact_array)
344+
if xp.isdtype(fact_array.dtype, "complex floating")
345+
else fact_array
346+
)
347+
else:
348+
fact_to_check = fact
349+
if fact_to_check <= 0:
350+
warnings.warn(
351+
"Degrees of freedom <= 0 for slice", RuntimeWarning, stacklevel=2
352+
)
353+
fact = 0
354+
355+
m_c = m - avg
356+
m_w = m_c if w is None else m_c * w
357+
m_cT = xp.matrix_transpose(m_c)
358+
if xp.isdtype(m_cT.dtype, "complex floating"):
359+
m_cT = xp.conj(m_cT)
360+
c = m_w @ m_cT / fact
291361
axes = tuple(axis for axis, length in enumerate(c.shape) if length == 1)
292362
return xp.squeeze(c, axis=axes)
293363

0 commit comments

Comments
 (0)