torchnd 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: torchnd
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: ND operations for PyTorch
5
5
  Author-email: Felix Zimmermann <fzimmermann89@gmail.com>
6
6
  License: GPL-3.0-or-later
@@ -85,13 +85,7 @@ out = conv_nd(x, weight, dim=(-2, -1), padding=1)
85
85
  ```python
86
86
  x = torch.randn(2, 4, 16, 16)
87
87
  weight = torch.randn(8, 4, 3, 3)
88
- out = conv_nd(
89
- x, weight,
90
- dim=(-2, -1),
91
- stride=(2, 1),
92
- padding=(1, 2),
93
- dilation=(1, 2)
94
- )
88
+ out = conv_nd(x, weight, dim=(-2, -1), stride=(2, 1), padding=(1, 2), dilation=(1, 2))
95
89
  ```
96
90
 
97
91
  **Modules:**
@@ -146,6 +140,19 @@ N-dimensional convolution with flexible dimension specification. Supports arbitr
146
140
 
147
141
  The `dim` parameter specifies which dimensions are spatial. The `channel_dim` parameter specifies the channel dimension. This allows for arbitrary tensor layouts (e.g., channel-last).
148
142
 
143
+ `conv_nd`, `ConvNd`, and `ConvTransposeNd` accept `convolution=True` for
144
+ mathematical convolution. The default keeps PyTorch's cross-correlation
145
+ convention. Each call reverses the spatial kernel axes without changing the
146
+ stored weight. Forward and transposed calls do not conjugate complex weights;
147
+ `adjoint()` conjugates them and keeps the chosen convention. See the `conv_nd`
148
+ docstring for the exact indexing and functional adjoint arguments.
149
+
150
+ ```python
151
+ conv = ConvNd(4, 8, 3, padding=1, bias=False, convolution=True)
152
+ y = conv(x)
153
+ x_adjoint = conv.adjoint(y)
154
+ ```
155
+
149
156
  ### pad_nd
150
157
 
151
158
  N-dimensional padding and cropping with flexible dimension specification. Supports arbitrary dimension layouts, not limited to the last N dimensions. Negative padding values crop the tensor.
@@ -54,13 +54,7 @@ out = conv_nd(x, weight, dim=(-2, -1), padding=1)
54
54
  ```python
55
55
  x = torch.randn(2, 4, 16, 16)
56
56
  weight = torch.randn(8, 4, 3, 3)
57
- out = conv_nd(
58
- x, weight,
59
- dim=(-2, -1),
60
- stride=(2, 1),
61
- padding=(1, 2),
62
- dilation=(1, 2)
63
- )
57
+ out = conv_nd(x, weight, dim=(-2, -1), stride=(2, 1), padding=(1, 2), dilation=(1, 2))
64
58
  ```
65
59
 
66
60
  **Modules:**
@@ -115,6 +109,19 @@ N-dimensional convolution with flexible dimension specification. Supports arbitr
115
109
 
116
110
  The `dim` parameter specifies which dimensions are spatial. The `channel_dim` parameter specifies the channel dimension. This allows for arbitrary tensor layouts (e.g., channel-last).
117
111
 
112
+ `conv_nd`, `ConvNd`, and `ConvTransposeNd` accept `convolution=True` for
113
+ mathematical convolution. The default keeps PyTorch's cross-correlation
114
+ convention. Each call reverses the spatial kernel axes without changing the
115
+ stored weight. Forward and transposed calls do not conjugate complex weights;
116
+ `adjoint()` conjugates them and keeps the chosen convention. See the `conv_nd`
117
+ docstring for the exact indexing and functional adjoint arguments.
118
+
119
+ ```python
120
+ conv = ConvNd(4, 8, 3, padding=1, bias=False, convolution=True)
121
+ y = conv(x)
122
+ x_adjoint = conv.adjoint(y)
123
+ ```
124
+
118
125
  ### pad_nd
119
126
 
120
127
  N-dimensional padding and cropping with flexible dimension specification. Supports arbitrary dimension layouts, not limited to the last N dimensions. Negative padding values crop the tensor.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "torchnd"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "ND operations for PyTorch"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.0"
2
2
 
3
3
  from torchnd.functional import (
4
4
  adjoint_linear_interpolation_nd,
@@ -341,13 +341,16 @@ def conv_nd(
341
341
  groups: int = 1,
342
342
  transposed: bool = False,
343
343
  output_padding: int | tuple[int, ...] = 0,
344
+ convolution: bool = False,
344
345
  ) -> Tensor:
345
346
  """N-dimensional convolution with flexible dimension specification.
346
347
 
347
348
  Supports arbitrary spatial dimensions via recursive decomposition when
348
349
  native PyTorch implementations (1D, 2D, 3D) are unavailable.
349
350
 
350
- Note: Strictly speaking, this implements correlation, not convolution. But it matches pytorch's naming convention.
351
+ By default, uses PyTorch's cross-correlation convention without conjugating
352
+ the kernel. Set ``convolution=True`` to reverse all spatial kernel axes
353
+ before applying the same operation. The stored/passed weight is unchanged.
351
354
 
352
355
  Parameters
353
356
  ----------
@@ -373,11 +376,25 @@ def conv_nd(
373
376
  If True, perform transposed (fractionally-strided) convolution.
374
377
  output_padding
375
378
  Additional size for transposed conv output (disambiguates shape).
379
+ convolution
380
+ If True, reverse the spatial kernel axes on each call. For one spatial
381
+ axis, the forward operation is
382
+ ``y[o, n] = sum(w[o, c, K-1-k] * x[c, n*s-p+k*d])``;
383
+ otherwise it uses ``w[o, c, k]``. Indices outside the input are zero.
384
+ Sums run over kernel indices and input channels within each group.
385
+ With ``transposed=True``, scatter ``x[c, n] * w[c, o, K-1-k]``
386
+ (or ``w[c, o, k]``) to ``y[o, n*s-p+k*d]``. Neither operation
387
+ conjugates the weight. To obtain the Hermitian adjoint, toggle
388
+ ``transposed`` and conjugate the weight, keeping ``convolution``
389
+ unchanged and choosing ``output_padding`` to recover the input shape.
376
390
 
377
391
  Returns
378
392
  -------
379
393
  Convolved output preserving input's dimension layout.
380
394
  """
395
+ if convolution:
396
+ weight = weight.flip(tuple(range(2, weight.ndim)))
397
+
381
398
  num_spatial = len(dim)
382
399
  stride = _normalize_tuple(stride, num_spatial)
383
400
  padding = _normalize_tuple(padding, num_spatial)
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import math
6
- from abc import ABC, abstractmethod
6
+ from abc import ABCMeta, abstractmethod
7
7
 
8
8
  import torch
9
9
  from torch import Tensor
@@ -12,7 +12,7 @@ from torch.utils.checkpoint import checkpoint
12
12
  from .functional import _normalize_tuple, adjoint_pad_nd, conv_nd, pad_nd
13
13
 
14
14
 
15
- class _ConvNdBase(torch.nn.Module, ABC):
15
+ class _ConvNdBase(torch.nn.Module, metaclass=ABCMeta):
16
16
  """Base class for N-dimensional convolution modules."""
17
17
 
18
18
  transposed: bool = False
@@ -27,6 +27,7 @@ class _ConvNdBase(torch.nn.Module, ABC):
27
27
  dilation: tuple[int, ...]
28
28
  groups: int
29
29
  padding_mode: tuple[str, ...]
30
+ convolution: bool = False
30
31
 
31
32
  def __init__(
32
33
  self,
@@ -45,6 +46,7 @@ class _ConvNdBase(torch.nn.Module, ABC):
45
46
  device: torch.device | None = None,
46
47
  dtype: torch.dtype | None = None,
47
48
  weight: Tensor | None = None,
49
+ convolution: bool = False,
48
50
  ) -> None:
49
51
  """Initialize N-dimensional convolution.
50
52
 
@@ -83,6 +85,11 @@ class _ConvNdBase(torch.nn.Module, ABC):
83
85
  weight
84
86
  If provided, uses as external weight (enables hypernetwork usage).
85
87
  Registered as buffer to be included in state_dict.
88
+ convolution
89
+ If True, reverse all spatial kernel axes on each call to use
90
+ mathematical convolution. Defaults to PyTorch cross-correlation.
91
+ Forward never conjugates the weight; adjoint conjugates it and
92
+ keeps the same spatial reversal. See ``conv_nd`` for index formulas.
86
93
  """
87
94
  super().__init__()
88
95
 
@@ -102,6 +109,7 @@ class _ConvNdBase(torch.nn.Module, ABC):
102
109
  self.output_padding = _normalize_tuple(output_padding, num_spatial)
103
110
  self.dilation = _normalize_tuple(dilation, num_spatial)
104
111
  self.groups = groups
112
+ self.convolution = convolution
105
113
 
106
114
  if isinstance(padding_mode, str):
107
115
  self.padding_mode = (padding_mode,) * num_spatial
@@ -205,6 +213,8 @@ class _ConvNdBase(torch.nn.Module, ABC):
205
213
  parts.append(f"output_padding={self.output_padding}")
206
214
  if self.groups != 1:
207
215
  parts.append(f"groups={self.groups}")
216
+ if self.convolution:
217
+ parts.append("convolution=True")
208
218
  if self.bias is None:
209
219
  parts.append("bias=False")
210
220
  if not all(m == "zeros" for m in self.padding_mode):
@@ -261,6 +271,7 @@ class ConvNd(_ConvNdBase):
261
271
  device: torch.device | None = None,
262
272
  dtype: torch.dtype | None = None,
263
273
  weight: Tensor | None = None,
274
+ convolution: bool = False,
264
275
  ) -> None:
265
276
  """Initialize N-dimensional convolution.
266
277
 
@@ -295,6 +306,11 @@ class ConvNd(_ConvNdBase):
295
306
  Data type for parameters. Complex dtypes enable complex convolution.
296
307
  weight
297
308
  If provided, uses as external weight. Registered as buffer for state_dict.
309
+ convolution
310
+ If True, reverse all spatial kernel axes on each call to use
311
+ mathematical convolution. Defaults to PyTorch cross-correlation.
312
+ Forward never conjugates the weight; adjoint conjugates it and
313
+ keeps the same spatial reversal. See ``conv_nd`` for index formulas.
298
314
  """
299
315
  super().__init__(
300
316
  in_channels=in_channels,
@@ -312,6 +328,7 @@ class ConvNd(_ConvNdBase):
312
328
  device=device,
313
329
  dtype=dtype,
314
330
  weight=weight,
331
+ convolution=convolution,
315
332
  )
316
333
 
317
334
  def forward(self, x: Tensor) -> Tensor:
@@ -351,6 +368,7 @@ class ConvNd(_ConvNdBase):
351
368
  dilation=self.dilation,
352
369
  groups=self.groups,
353
370
  transposed=False,
371
+ convolution=self.convolution,
354
372
  )
355
373
 
356
374
  if self.bias is not None:
@@ -364,7 +382,8 @@ class ConvNd(_ConvNdBase):
364
382
  """Apply the adjoint convolution operator.
365
383
 
366
384
  The adjoint of a convolution is the transposed convolution with
367
- conjugated weights (for complex) or identical weights (for real).
385
+ conjugated weights (for complex) or identical weights (for real),
386
+ retaining the same ``convolution`` setting.
368
387
 
369
388
  This method applies the adjoint using the same weights (and does not
370
389
  allocate a new module). Intended for iterative algorithms, not separate
@@ -436,6 +455,7 @@ class ConvNd(_ConvNdBase):
436
455
  dilation=self.dilation,
437
456
  groups=self.groups,
438
457
  transposed=True,
458
+ convolution=self.convolution,
439
459
  output_padding=out_pad,
440
460
  )
441
461
 
@@ -484,6 +504,7 @@ class ConvTransposeNd(_ConvNdBase):
484
504
  device: torch.device | None = None,
485
505
  dtype: torch.dtype | None = None,
486
506
  weight: Tensor | None = None,
507
+ convolution: bool = False,
487
508
  ) -> None:
488
509
  """Initialize N-dimensional transposed convolution.
489
510
 
@@ -520,6 +541,11 @@ class ConvTransposeNd(_ConvNdBase):
520
541
  Data type for parameters. Complex dtypes enable complex convolution.
521
542
  weight
522
543
  If provided, uses as external weight. Registered as buffer for state_dict.
544
+ convolution
545
+ If True, reverse all spatial kernel axes on each call to use
546
+ mathematical convolution. Defaults to PyTorch cross-correlation.
547
+ Forward never conjugates the weight; adjoint conjugates it and
548
+ keeps the same spatial reversal. See ``conv_nd`` for index formulas.
523
549
  """
524
550
  super().__init__(
525
551
  in_channels=in_channels,
@@ -537,6 +563,7 @@ class ConvTransposeNd(_ConvNdBase):
537
563
  device=device,
538
564
  dtype=dtype,
539
565
  weight=weight,
566
+ convolution=convolution,
540
567
  )
541
568
 
542
569
  def forward(self, x: Tensor) -> Tensor:
@@ -568,6 +595,7 @@ class ConvTransposeNd(_ConvNdBase):
568
595
  dilation=self.dilation,
569
596
  groups=self.groups,
570
597
  transposed=True,
598
+ convolution=self.convolution,
571
599
  output_padding=self.output_padding,
572
600
  )
573
601
 
@@ -602,7 +630,8 @@ class ConvTransposeNd(_ConvNdBase):
602
630
  """Apply the adjoint convolution operator.
603
631
 
604
632
  The adjoint of a transposed convolution is the (forward) convolution with
605
- conjugated weights (for complex) or identical weights (for real).
633
+ conjugated weights (for complex) or identical weights (for real),
634
+ retaining the same ``convolution`` setting.
606
635
 
607
636
  This method applies the adjoint using the same weights (and does not
608
637
  allocate a new module). Intended for iterative algorithms, not separate
@@ -650,4 +679,5 @@ class ConvTransposeNd(_ConvNdBase):
650
679
  dilation=self.dilation,
651
680
  groups=self.groups,
652
681
  transposed=False,
682
+ convolution=self.convolution,
653
683
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: torchnd
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: ND operations for PyTorch
5
5
  Author-email: Felix Zimmermann <fzimmermann89@gmail.com>
6
6
  License: GPL-3.0-or-later
@@ -85,13 +85,7 @@ out = conv_nd(x, weight, dim=(-2, -1), padding=1)
85
85
  ```python
86
86
  x = torch.randn(2, 4, 16, 16)
87
87
  weight = torch.randn(8, 4, 3, 3)
88
- out = conv_nd(
89
- x, weight,
90
- dim=(-2, -1),
91
- stride=(2, 1),
92
- padding=(1, 2),
93
- dilation=(1, 2)
94
- )
88
+ out = conv_nd(x, weight, dim=(-2, -1), stride=(2, 1), padding=(1, 2), dilation=(1, 2))
95
89
  ```
96
90
 
97
91
  **Modules:**
@@ -146,6 +140,19 @@ N-dimensional convolution with flexible dimension specification. Supports arbitr
146
140
 
147
141
  The `dim` parameter specifies which dimensions are spatial. The `channel_dim` parameter specifies the channel dimension. This allows for arbitrary tensor layouts (e.g., channel-last).
148
142
 
143
+ `conv_nd`, `ConvNd`, and `ConvTransposeNd` accept `convolution=True` for
144
+ mathematical convolution. The default keeps PyTorch's cross-correlation
145
+ convention. Each call reverses the spatial kernel axes without changing the
146
+ stored weight. Forward and transposed calls do not conjugate complex weights;
147
+ `adjoint()` conjugates them and keeps the chosen convention. See the `conv_nd`
148
+ docstring for the exact indexing and functional adjoint arguments.
149
+
150
+ ```python
151
+ conv = ConvNd(4, 8, 3, padding=1, bias=False, convolution=True)
152
+ y = conv(x)
153
+ x_adjoint = conv.adjoint(y)
154
+ ```
155
+
149
156
  ### pad_nd
150
157
 
151
158
  N-dimensional padding and cropping with flexible dimension specification. Supports arbitrary dimension layouts, not limited to the last N dimensions. Negative padding values crop the tensor.
@@ -9,5 +9,7 @@ src/torchnd.egg-info/SOURCES.txt
9
9
  src/torchnd.egg-info/dependency_links.txt
10
10
  src/torchnd.egg-info/requires.txt
11
11
  src/torchnd.egg-info/top_level.txt
12
+ tests/test_convolution_mode.py
12
13
  tests/test_functional.py
14
+ tests/test_module_inheritance.py
13
15
  tests/test_modules.py
@@ -0,0 +1,227 @@
1
+ """Independent values and adjoints for both kernel conventions."""
2
+
3
+ from itertools import product
4
+
5
+ import pytest
6
+ import torch
7
+
8
+ from torchnd import ConvNd, ConvTransposeNd, conv_nd
9
+
10
+
11
+ def reference_conv(
12
+ x: torch.Tensor,
13
+ weight: torch.Tensor,
14
+ *,
15
+ convolution: bool,
16
+ transposed: bool,
17
+ stride: int = 1,
18
+ padding: int = 0,
19
+ dilation: int = 1,
20
+ groups: int = 1,
21
+ output_padding: int = 0,
22
+ ) -> torch.Tensor:
23
+ """Evaluate the defining sums directly, without any convolution backend."""
24
+ spatial = x.shape[2:]
25
+ kernel = weight.shape[2:]
26
+ if transposed:
27
+ out_shape = tuple(
28
+ (n - 1) * stride - 2 * padding + dilation * (k - 1) + 1 + output_padding for n, k in zip(spatial, kernel, strict=True)
29
+ )
30
+ out_channels = weight.shape[1] * groups
31
+ else:
32
+ out_shape = tuple((n + 2 * padding - dilation * (k - 1) - 1) // stride + 1 for n, k in zip(spatial, kernel, strict=True))
33
+ out_channels = weight.shape[0]
34
+ out = x.new_zeros(x.shape[0], out_channels, *out_shape)
35
+ in_per_group = x.shape[1] // groups
36
+ out_per_group = out_channels // groups
37
+ positions = spatial if transposed else out_shape
38
+ for batch, group, ci, co in product(range(x.shape[0]), range(groups), range(in_per_group), range(out_per_group)):
39
+ ic, oc = group * in_per_group + ci, group * out_per_group + co
40
+ for pos, offset in product(product(*(range(n) for n in positions)), product(*(range(k) for k in kernel))):
41
+ target = tuple(n * stride - padding + k * dilation for n, k in zip(pos, offset, strict=True))
42
+ limits = out_shape if transposed else spatial
43
+ if any(i < 0 or i >= n for i, n in zip(target, limits, strict=True)):
44
+ continue
45
+ wi = tuple(k - 1 - i for k, i in zip(kernel, offset, strict=True)) if convolution else offset
46
+ weight_index = (ic, co, *wi) if transposed else (oc, ci, *wi)
47
+ out_index = (batch, oc, *target) if transposed else (batch, oc, *pos)
48
+ in_index = (batch, ic, *pos) if transposed else (batch, ic, *target)
49
+ out[out_index] += x[in_index] * weight[weight_index]
50
+ return out
51
+
52
+
53
+ @pytest.mark.parametrize("convolution", [False, True])
54
+ @pytest.mark.parametrize("transposed", [False, True])
55
+ @pytest.mark.parametrize("ndim", [0, 1, 2, 4])
56
+ @pytest.mark.parametrize("dtype", [torch.float64, torch.complex128])
57
+ def test_functional_reference(convolution: bool, transposed: bool, ndim: int, dtype: torch.dtype) -> None:
58
+ """Check grouped, strided, dilated kernels and channel-last layouts."""
59
+ generator = torch.Generator().manual_seed(3)
60
+ x = torch.randn(1, 4, *((3,) * ndim), dtype=dtype, generator=generator)
61
+ weight = torch.randn(4, 2, *((2,) * ndim), dtype=dtype, generator=generator)
62
+ expected = reference_conv(
63
+ x,
64
+ weight,
65
+ convolution=convolution,
66
+ transposed=transposed,
67
+ stride=2,
68
+ padding=1,
69
+ dilation=2,
70
+ groups=2,
71
+ output_padding=int(transposed),
72
+ )
73
+ actual = conv_nd(
74
+ x.moveaxis(1, -1),
75
+ weight,
76
+ dim=tuple(range(1, ndim + 1)),
77
+ channel_dim=-1,
78
+ stride=2,
79
+ padding=1,
80
+ dilation=2,
81
+ groups=2,
82
+ transposed=transposed,
83
+ output_padding=int(transposed),
84
+ convolution=convolution,
85
+ )
86
+ torch.testing.assert_close(actual.moveaxis(-1, 1), expected)
87
+ if not convolution:
88
+ default = conv_nd(
89
+ x.moveaxis(1, -1),
90
+ weight,
91
+ dim=tuple(range(1, ndim + 1)),
92
+ channel_dim=-1,
93
+ stride=2,
94
+ padding=1,
95
+ dilation=2,
96
+ groups=2,
97
+ transposed=transposed,
98
+ output_padding=int(transposed),
99
+ )
100
+ assert torch.equal(actual, default)
101
+
102
+
103
+ @pytest.mark.parametrize("convolution", [False, True])
104
+ @pytest.mark.parametrize("conv_type", [ConvNd, ConvTransposeNd])
105
+ @pytest.mark.parametrize("ndim", [1, 4])
106
+ @pytest.mark.parametrize("dtype", [torch.float64, torch.complex128])
107
+ def test_module_reference_and_adjoint(
108
+ convolution: bool,
109
+ conv_type: type[ConvNd] | type[ConvTransposeNd],
110
+ ndim: int,
111
+ dtype: torch.dtype,
112
+ ) -> None:
113
+ """Modules and their adjoints agree with the independent matrix entries."""
114
+ generator = torch.Generator().manual_seed(5)
115
+ x = torch.randn(1, 4, *((3,) * ndim), dtype=dtype, generator=generator, requires_grad=True)
116
+ weight = torch.randn(4, 2, *((2,) * ndim), dtype=dtype, generator=generator)
117
+ module = conv_type(
118
+ 4,
119
+ 4,
120
+ 2,
121
+ dim=tuple(range(2, ndim + 2)),
122
+ stride=2,
123
+ padding=1,
124
+ groups=2,
125
+ bias=False,
126
+ weight=weight,
127
+ convolution=convolution,
128
+ )
129
+ out = module(x)
130
+ expected = reference_conv(x, weight, convolution=convolution, transposed=module.transposed, stride=2, padding=1, groups=2)
131
+ torch.testing.assert_close(out, expected)
132
+ y = torch.randn(out.shape, dtype=dtype, generator=generator)
133
+ adjoint = module.adjoint(y, input_shape=(3,) * ndim) if isinstance(module, ConvNd) else module.adjoint(y)
134
+ (expected_adjoint,) = torch.autograd.grad(expected, x, y)
135
+ torch.testing.assert_close(adjoint, expected_adjoint)
136
+ torch.testing.assert_close(torch.vdot(out.flatten(), y.flatten()), torch.vdot(x.flatten(), adjoint.flatten()))
137
+
138
+
139
+ @pytest.mark.parametrize("convolution", [False, True])
140
+ @pytest.mark.parametrize("conv_type", [ConvNd, ConvTransposeNd])
141
+ @pytest.mark.parametrize("padding_mode", ["zeros", "reflect", "replicate", "circular"])
142
+ def test_padded_adjoint(
143
+ convolution: bool,
144
+ conv_type: type[ConvNd] | type[ConvTransposeNd],
145
+ padding_mode: str,
146
+ ) -> None:
147
+ """Boundary extension and its adjoint retain the chosen kernel convention."""
148
+ generator = torch.Generator().manual_seed(8)
149
+ module = conv_type(
150
+ 2, 2, 3, dim=(-1,), padding=1, padding_mode=padding_mode, bias=False, dtype=torch.complex128, convolution=convolution
151
+ )
152
+ x = torch.randn(1, 2, 5, dtype=torch.complex128, generator=generator, requires_grad=True)
153
+ out = module(x)
154
+ y = torch.randn(out.shape, dtype=out.dtype, generator=generator)
155
+ (expected,) = torch.autograd.grad(out, x, y)
156
+ torch.testing.assert_close(module.adjoint(y), expected)
157
+
158
+
159
+ @pytest.mark.parametrize("conv_type", [ConvNd, ConvTransposeNd])
160
+ @pytest.mark.parametrize("external", ["internal", "parameter", "buffer"])
161
+ @pytest.mark.parametrize("dtype", [torch.float64, torch.complex128])
162
+ @pytest.mark.parametrize(
163
+ "device", ["cpu", pytest.param("cuda", marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA unavailable"))]
164
+ )
165
+ def test_weight_updates(conv_type: type[ConvNd] | type[ConvTransposeNd], external: str, dtype: torch.dtype, device: str) -> None:
166
+ """Each call uses the current weight and builds a fresh differentiable flip."""
167
+ weight = torch.arange(1.0, 4.0, device=device).to(dtype).unflatten(0, (1, 1, 3))
168
+ if dtype.is_complex:
169
+ weight = weight + 0.5j * weight.square()
170
+ if external == "parameter":
171
+ weight = torch.nn.Parameter(weight)
172
+ else:
173
+ weight.requires_grad_()
174
+ module = conv_type(
175
+ 1,
176
+ 1,
177
+ 3,
178
+ dim=(-1,),
179
+ bias=False,
180
+ device=torch.device(device),
181
+ dtype=dtype,
182
+ weight=None if external == "internal" else weight,
183
+ convolution=True,
184
+ )
185
+ if external == "internal":
186
+ weight = module.weight
187
+ assert module.weight is weight
188
+ if external != "internal":
189
+ assert dict(module.named_buffers())["weight"] is weight
190
+ else:
191
+ assert dict(module.named_parameters())["weight"] is weight
192
+ x = torch.tensor([[[1.0, 2.0, 4.0, 8.0]]], dtype=dtype, device=device)
193
+ for step in range(2):
194
+ out = module(x)
195
+ reference_weight = weight.detach().clone().requires_grad_()
196
+ expected = reference_conv(x, reference_weight, convolution=True, transposed=module.transposed)
197
+ torch.testing.assert_close(out, expected)
198
+ out.abs().square().sum().backward()
199
+ (expected_grad,) = torch.autograd.grad(expected.abs().square().sum(), reference_weight)
200
+ torch.testing.assert_close(weight.grad, expected_grad)
201
+ y = torch.ones_like(out)
202
+ weight.grad = None
203
+ module.adjoint(y).abs().square().sum().backward()
204
+ expected_adjoint = reference_conv(y, reference_weight.conj(), convolution=True, transposed=not module.transposed)
205
+ (expected_grad,) = torch.autograd.grad(expected_adjoint.abs().square().sum(), reference_weight)
206
+ torch.testing.assert_close(weight.grad, expected_grad)
207
+ assert weight.grad is not None
208
+ assert torch.isfinite(weight.grad).all()
209
+ torch.testing.assert_close(module.state_dict()["weight"], weight)
210
+ with torch.no_grad():
211
+ weight.add_(step + 1)
212
+ weight.grad = None
213
+ assert "convolution=True" in repr(module)
214
+
215
+
216
+ @pytest.mark.parametrize("conv_type", [ConvNd, ConvTransposeNd])
217
+ def test_legacy_module_default(conv_type: type[ConvNd] | type[ConvTransposeNd]) -> None:
218
+ """Modules pickled before the setting existed keep cross-correlation."""
219
+ module = conv_type(1, 1, 3, dim=(-1,), bias=False, dtype=torch.float64)
220
+ x = torch.arange(5.0, dtype=torch.float64).unflatten(0, (1, 1, 5))
221
+ expected = module(x)
222
+ del module.convolution
223
+ assert module.convolution is False
224
+ torch.testing.assert_close(module(x), expected)
225
+ torch.testing.assert_close(
226
+ module.adjoint(expected), reference_conv(expected, module.weight, convolution=False, transposed=not module.transposed)
227
+ )
@@ -0,0 +1,50 @@
1
+ """Convolution modules can share an abstract torch module base."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ import pytest
6
+ import torch
7
+
8
+ from torchnd import ConvNd, ConvTransposeNd
9
+ from torchnd.modules import _ConvNdBase
10
+
11
+
12
+ class AbstractModule(ABC, torch.nn.Module):
13
+ """An abstract interface that places ABC before Module, as mr2 does."""
14
+
15
+ @abstractmethod
16
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
17
+ """Apply the module."""
18
+ raise NotImplementedError
19
+
20
+
21
+ @pytest.mark.parametrize("conv_type", [ConvNd, ConvTransposeNd])
22
+ @pytest.mark.parametrize("abstract_first", [False, True])
23
+ def test_multiple_inheritance(conv_type: type[ConvNd] | type[ConvTransposeNd], abstract_first: bool) -> None:
24
+ """Either base order preserves construction, parameters, gradients, and the adjoint."""
25
+ bases = (AbstractModule, conv_type) if abstract_first else (conv_type, AbstractModule)
26
+ combined_type = type("CombinedConvolution", bases, {"forward": conv_type.forward})
27
+ module = combined_type(2, 3, 3, dim=(-1,), padding=1, bias=False, dtype=torch.float64)
28
+ assert isinstance(module, AbstractModule)
29
+ assert isinstance(module, conv_type)
30
+ assert dict(module.named_parameters())["weight"] is module.weight
31
+ torch.testing.assert_close(module.state_dict()["weight"], module.weight)
32
+
33
+ generator = torch.Generator().manual_seed(0)
34
+ x = torch.randn(1, 2, 5, generator=generator, dtype=torch.float64, requires_grad=True)
35
+ y = module(x)
36
+ assert y.shape == (1, 3, 5)
37
+ v = torch.randn(y.shape, generator=generator, dtype=y.dtype)
38
+ expected_adjoint = module.adjoint(v)
39
+ (gradient,) = torch.autograd.grad(y, x, grad_outputs=v, retain_graph=True)
40
+ torch.testing.assert_close(gradient, expected_adjoint)
41
+ y.square().sum().backward()
42
+ assert module.weight.grad is not None
43
+ assert module.float() is module
44
+ assert module.weight.dtype == torch.float32
45
+
46
+
47
+ def test_base_remains_abstract() -> None:
48
+ """Using ABCMeta directly still prevents constructing the incomplete base class."""
49
+ with pytest.raises(TypeError, match="abstract"):
50
+ _ConvNdBase(1, 1, 3) # type: ignore[abstract]
@@ -122,7 +122,7 @@ class TestAdjoint:
122
122
  """Test adjoint operator correctness."""
123
123
 
124
124
  @pytest.mark.parametrize("ndim", [1, 2, 3])
125
- @pytest.mark.parametrize("dtype", [torch.float32, torch.complex64])
125
+ @pytest.mark.parametrize("dtype", [torch.float64, torch.complex128])
126
126
  @pytest.mark.parametrize(
127
127
  "config",
128
128
  [
@@ -153,11 +153,10 @@ class TestAdjoint:
153
153
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
154
154
  rhs = torch.vdot(x.flatten(), conv.adjoint(y, input_shape=input_shape[2:]).flatten())
155
155
 
156
- rtol = 1e-4 if dtype == torch.float32 else 1e-5
157
- torch.testing.assert_close(lhs, rhs, rtol=rtol, atol=rtol)
156
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
158
157
 
159
158
  @pytest.mark.parametrize("ndim", [1, 2, 3])
160
- @pytest.mark.parametrize("dtype", [torch.float32, torch.complex64])
159
+ @pytest.mark.parametrize("dtype", [torch.float64, torch.complex128])
161
160
  @pytest.mark.parametrize(
162
161
  "config",
163
162
  [
@@ -190,8 +189,7 @@ class TestAdjoint:
190
189
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
191
190
  rhs = torch.vdot(x.flatten(), conv.adjoint(y).flatten())
192
191
 
193
- rtol = 1e-4 if dtype == torch.float32 else 1e-5
194
- torch.testing.assert_close(lhs, rhs, rtol=rtol, atol=rtol)
192
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
195
193
 
196
194
  def test_raises_with_bias(self) -> None:
197
195
  """Adjoint raises ValueError if bias is present."""
@@ -460,16 +458,17 @@ class TestPaddingModeAdjoint:
460
458
  padding=2,
461
459
  padding_mode=mode,
462
460
  bias=False,
461
+ dtype=torch.float64,
463
462
  )
464
463
 
465
464
  input_shape = (x_shape[0], in_ch, *x_shape[2:])
466
- x = torch.randn(input_shape)
465
+ x = torch.randn(input_shape, dtype=torch.float64)
467
466
  y = torch.randn_like(conv(x))
468
467
 
469
468
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
470
469
  rhs = torch.vdot(x.flatten(), conv.adjoint(y, input_shape=input_shape[2:]).flatten())
471
470
 
472
- torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
471
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
473
472
 
474
473
  @pytest.mark.parametrize("mode", ["zeros", "reflect", "replicate", "circular"])
475
474
  @pytest.mark.parametrize("ndim", [1, 2])
@@ -486,18 +485,19 @@ class TestPaddingModeAdjoint:
486
485
  padding=2,
487
486
  padding_mode=mode,
488
487
  bias=False,
488
+ dtype=torch.float64,
489
489
  )
490
490
 
491
491
  spatial_in = tuple(s // 2 for s in x_shape[2:])
492
492
  input_shape = (x_shape[0], in_ch, *spatial_in)
493
493
 
494
- x = torch.randn(input_shape)
494
+ x = torch.randn(input_shape, dtype=torch.float64)
495
495
  y = torch.randn_like(conv(x))
496
496
 
497
497
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
498
498
  rhs = torch.vdot(x.flatten(), conv.adjoint(y).flatten())
499
499
 
500
- torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
500
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
501
501
 
502
502
  @pytest.mark.parametrize(
503
503
  "modes",
@@ -518,15 +518,16 @@ class TestPaddingModeAdjoint:
518
518
  padding=2,
519
519
  padding_mode=modes,
520
520
  bias=False,
521
+ dtype=torch.float64,
521
522
  )
522
523
 
523
- x = torch.randn(2, 4, 16, 16)
524
+ x = torch.randn(2, 4, 16, 16, dtype=torch.float64)
524
525
  y = torch.randn_like(conv(x))
525
526
 
526
527
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
527
528
  rhs = torch.vdot(x.flatten(), conv.adjoint(y, input_shape=(16, 16)).flatten())
528
529
 
529
- torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
530
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
530
531
 
531
532
  @pytest.mark.parametrize(
532
533
  "modes",
@@ -547,15 +548,16 @@ class TestPaddingModeAdjoint:
547
548
  padding=2,
548
549
  padding_mode=modes,
549
550
  bias=False,
551
+ dtype=torch.float64,
550
552
  )
551
553
 
552
- x = torch.randn(2, 8, 8, 8)
554
+ x = torch.randn(2, 8, 8, 8, dtype=torch.float64)
553
555
  y = torch.randn_like(conv(x))
554
556
 
555
557
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
556
558
  rhs = torch.vdot(x.flatten(), conv.adjoint(y).flatten())
557
559
 
558
- torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
560
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
559
561
 
560
562
  def test_3d_mixed_modes_adjoint(self) -> None:
561
563
  """Verify adjoint identity with 3 different padding modes."""
@@ -567,15 +569,16 @@ class TestPaddingModeAdjoint:
567
569
  padding=1,
568
570
  padding_mode=("reflect", "replicate", "circular"),
569
571
  bias=False,
572
+ dtype=torch.float64,
570
573
  )
571
574
 
572
- x = torch.randn(2, 4, 8, 8, 8)
575
+ x = torch.randn(2, 4, 8, 8, 8, dtype=torch.float64)
573
576
  y = torch.randn_like(conv(x))
574
577
 
575
578
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
576
579
  rhs = torch.vdot(x.flatten(), conv.adjoint(y, input_shape=(8, 8, 8)).flatten())
577
580
 
578
- torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
581
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
579
582
 
580
583
 
581
584
  class TestAdjointWithOutputPadding:
@@ -598,13 +601,14 @@ class TestAdjointWithOutputPadding:
598
601
  padding=1,
599
602
  output_padding=output_padding,
600
603
  bias=False,
604
+ dtype=torch.float64,
601
605
  )
602
606
 
603
607
  input_shape = (2, in_ch, *spatial_in)
604
- x = torch.randn(input_shape)
608
+ x = torch.randn(input_shape, dtype=torch.float64)
605
609
  y = torch.randn_like(conv(x))
606
610
 
607
611
  lhs = torch.vdot(conv(x).flatten(), y.flatten())
608
612
  rhs = torch.vdot(x.flatten(), conv.adjoint(y).flatten())
609
613
 
610
- torch.testing.assert_close(lhs, rhs, rtol=1e-4, atol=1e-5)
614
+ torch.testing.assert_close(lhs, rhs, rtol=1e-10, atol=1e-10)
File without changes
File without changes