torchnd 0.1.0__py3-none-any.whl

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.
torchnd/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from torchnd.functional import adjoint_pad_nd, conv_nd, pad_nd, pad_or_crop_to_size
4
+ from torchnd.modules import ConvNd, ConvTransposeNd
5
+
6
+ __all__ = [
7
+ "adjoint_pad_nd",
8
+ "conv_nd",
9
+ "pad_nd",
10
+ "pad_or_crop_to_size",
11
+ "ConvNd",
12
+ "ConvTransposeNd",
13
+ "__version__",
14
+ ]
torchnd/functional.py ADDED
@@ -0,0 +1,478 @@
1
+ """N-dimensional convolution with flexible dimension specification."""
2
+
3
+ from collections.abc import Callable, Sequence
4
+
5
+ import torch
6
+ from einops import rearrange
7
+ from torch import Tensor
8
+
9
+ CONV_REGISTRY: dict[int, Callable[..., Tensor]] = {
10
+ 1: torch.nn.functional.conv1d,
11
+ 2: torch.nn.functional.conv2d,
12
+ 3: torch.nn.functional.conv3d,
13
+ }
14
+
15
+ CONV_TRANSPOSE_REGISTRY: dict[int, Callable[..., Tensor]] = {
16
+ 1: torch.nn.functional.conv_transpose1d,
17
+ 2: torch.nn.functional.conv_transpose2d,
18
+ 3: torch.nn.functional.conv_transpose3d,
19
+ }
20
+
21
+
22
+ def _normalize_tuple(value: int | tuple[int, ...], length: int) -> tuple[int, ...]:
23
+ return (value,) * length if isinstance(value, int) else tuple(value)
24
+
25
+
26
+ def conv_nd(
27
+ x: Tensor,
28
+ weight: Tensor,
29
+ dim: tuple[int, ...] = (-2, -1),
30
+ channel_dim: int = 1,
31
+ stride: int | tuple[int, ...] = 1,
32
+ padding: int | tuple[int, ...] = 0,
33
+ dilation: int | tuple[int, ...] = 1,
34
+ groups: int = 1,
35
+ transposed: bool = False,
36
+ output_padding: int | tuple[int, ...] = 0,
37
+ ) -> Tensor:
38
+ """N-dimensional convolution with flexible dimension specification.
39
+
40
+ Supports arbitrary spatial dimensions via recursive decomposition when
41
+ native PyTorch implementations (1D, 2D, 3D) are unavailable.
42
+
43
+ Note: Strictly speaking, this implements correlation, not convolution. But it matches pytorch's naming convention.
44
+
45
+ Parameters
46
+ ----------
47
+ x
48
+ Input tensor with batch, channel, and spatial dimensions.
49
+ weight
50
+ Convolution kernel.
51
+ Forward: ``(C_out, C_in // groups, *kernel_sizes)``.
52
+ Transposed: ``(C_in, C_out // groups, *kernel_sizes)``.
53
+ dim
54
+ Spatial dimensions to convolve over.
55
+ channel_dim
56
+ Channel dimension index.
57
+ stride
58
+ Stride of the convolution; controls the step size the kernel moves along each spatial dimension. Can be a single integer or a tuple giving a value per dimension.
59
+ padding
60
+ Padding added to each spatial dimension. Can be a single integer or a tuple specifying padding for each dimension.
61
+ dilation
62
+ Spacing between kernel elements along each spatial dimension. Can be a single integer or per-dimension tuple.
63
+ groups
64
+ Grouped convolution groups.
65
+ transposed
66
+ If True, perform transposed (fractionally-strided) convolution.
67
+ output_padding
68
+ Additional size for transposed conv output (disambiguates shape).
69
+
70
+ Returns
71
+ -------
72
+ Convolved output preserving input's dimension layout.
73
+ """
74
+ num_spatial = len(dim)
75
+ stride = _normalize_tuple(stride, num_spatial)
76
+ padding = _normalize_tuple(padding, num_spatial)
77
+ dilation = _normalize_tuple(dilation, num_spatial)
78
+ output_padding = _normalize_tuple(output_padding, num_spatial)
79
+
80
+ def conv(a: Tensor, b: Tensor) -> Tensor:
81
+ return _dispatch(a, b, dim, channel_dim, stride, padding, dilation, groups, transposed, output_padding)
82
+
83
+ match (x.is_complex(), weight.is_complex()):
84
+ case (True, True):
85
+ return torch.complex(
86
+ conv(x.real, weight.real) - conv(x.imag, weight.imag),
87
+ conv(x.real, weight.imag) + conv(x.imag, weight.real),
88
+ )
89
+ case (True, False):
90
+ return torch.complex(conv(x.real, weight), conv(x.imag, weight))
91
+ case (False, True):
92
+ return torch.complex(conv(x, weight.real), conv(x, weight.imag))
93
+ case _:
94
+ return conv(x, weight)
95
+
96
+
97
+ def _dispatch(
98
+ x: Tensor,
99
+ weight: Tensor,
100
+ dim: tuple[int, ...],
101
+ channel_dim: int,
102
+ stride: tuple[int, ...],
103
+ padding: tuple[int, ...],
104
+ dilation: tuple[int, ...],
105
+ groups: int,
106
+ transposed: bool,
107
+ output_padding: tuple[int, ...],
108
+ ) -> Tensor:
109
+ """Permute to standard layout, convolve, permute back."""
110
+ ndim = x.ndim
111
+ spatial_dims = tuple(d % ndim for d in dim)
112
+ channel_dim_normalized = channel_dim % ndim
113
+ batch_dims = tuple(d for d in range(ndim) if d not in set(spatial_dims) and d != channel_dim_normalized)
114
+
115
+ to_standard = batch_dims + (channel_dim_normalized,) + spatial_dims
116
+ x_perm = x.permute(to_standard)
117
+
118
+ num_batch = len(batch_dims)
119
+ batch_shape = x_perm.shape[:num_batch]
120
+ x_flat = x_perm.reshape(-1 if batch_shape else 1, *x_perm.shape[num_batch:])
121
+
122
+ out_flat = _conv_core(x_flat, weight, stride, padding, dilation, groups, transposed, output_padding)
123
+
124
+ out = out_flat.reshape(*batch_shape, *out_flat.shape[1:]) if batch_shape else out_flat.squeeze(0)
125
+ from_standard = torch.argsort(torch.tensor(to_standard)).tolist()
126
+ return out.permute(from_standard)
127
+
128
+
129
+ def _conv_core(
130
+ x: Tensor,
131
+ weight: Tensor,
132
+ stride: tuple[int, ...],
133
+ padding: tuple[int, ...],
134
+ dilation: tuple[int, ...],
135
+ groups: int,
136
+ transposed: bool,
137
+ output_padding: tuple[int, ...],
138
+ ) -> Tensor:
139
+ """Dispatch to native implementation or recursive fallback."""
140
+ num_spatial = len(stride)
141
+ registry = CONV_TRANSPOSE_REGISTRY if transposed else CONV_REGISTRY
142
+
143
+ if num_spatial == 0:
144
+ return registry[1](x.unsqueeze(-1), weight.unsqueeze(-1), groups=groups).squeeze(-1)
145
+
146
+ if num_spatial in registry:
147
+ kwargs = {
148
+ "bias": None,
149
+ "stride": stride,
150
+ "padding": padding,
151
+ "dilation": dilation,
152
+ "groups": groups,
153
+ }
154
+ if transposed:
155
+ kwargs["output_padding"] = output_padding
156
+ return registry[num_spatial](x, weight, **kwargs)
157
+
158
+ if transposed:
159
+ return _conv_transpose_recursive(x, weight, stride, padding, dilation, groups, output_padding)
160
+ return _conv_recursive(x, weight, stride, padding, dilation, groups)
161
+
162
+
163
+ def _conv_recursive(
164
+ x: Tensor,
165
+ weight: Tensor,
166
+ stride: tuple[int, ...],
167
+ padding: tuple[int, ...],
168
+ dilation: tuple[int, ...],
169
+ groups: int,
170
+ ) -> Tensor:
171
+ """Decompose N-D conv: sum over first kernel dim of (N-1)-D convs."""
172
+ if padding[0] > 0:
173
+ pad_spec = (0, 0) * (x.ndim - 3) + (padding[0], padding[0])
174
+ x = torch.nn.functional.pad(x, pad_spec)
175
+
176
+ kernel_size = weight.shape[2]
177
+ input_size = x.shape[2]
178
+ batch_size = x.shape[0]
179
+ output_size = (input_size - dilation[0] * (kernel_size - 1) - 1) // stride[0] + 1
180
+
181
+ accumulator: Tensor | None = None
182
+ for ki in range(kernel_size):
183
+ start = ki * dilation[0]
184
+ x_slice = x[:, :, start : start + (output_size - 1) * stride[0] + 1 : stride[0]]
185
+
186
+ out = _conv_core(
187
+ rearrange(x_slice, "n c d ... -> (n d) c ..."),
188
+ weight[:, :, ki],
189
+ stride[1:],
190
+ padding[1:],
191
+ dilation[1:],
192
+ groups,
193
+ False,
194
+ (),
195
+ )
196
+ out = rearrange(out, "(n d) c ... -> n c d ...", n=batch_size, d=output_size)
197
+ accumulator = out if accumulator is None else accumulator + out
198
+
199
+ assert accumulator is not None
200
+ return accumulator
201
+
202
+
203
+ def _conv_transpose_recursive(
204
+ x: Tensor,
205
+ weight: Tensor,
206
+ stride: tuple[int, ...],
207
+ padding: tuple[int, ...],
208
+ dilation: tuple[int, ...],
209
+ groups: int,
210
+ output_padding: tuple[int, ...],
211
+ ) -> Tensor:
212
+ """Decompose N-D transpose conv: scatter (N-1)-D results into output."""
213
+ kernel_size = weight.shape[2]
214
+ input_size = x.shape[2]
215
+ batch_size = x.shape[0]
216
+ out_channels = weight.shape[1] * groups
217
+ full_size = (input_size - 1) * stride[0] + dilation[0] * (kernel_size - 1) + 1
218
+
219
+ result: Tensor | None = None
220
+ for ki in range(kernel_size):
221
+ out = _conv_core(
222
+ rearrange(x, "n c d ... -> (n d) c ..."),
223
+ weight[:, :, ki],
224
+ stride[1:],
225
+ padding[1:],
226
+ dilation[1:],
227
+ groups,
228
+ True,
229
+ output_padding[1:],
230
+ )
231
+ out = rearrange(out, "(n d) c ... -> n c d ...", n=batch_size, d=input_size)
232
+
233
+ if result is None:
234
+ result = x.new_zeros(batch_size, out_channels, full_size, *out.shape[3:])
235
+
236
+ start = ki * dilation[0]
237
+ end = start + input_size * stride[0]
238
+ result[:, :, start : end : stride[0]] = result[:, :, start : end : stride[0]] + out
239
+
240
+ assert result is not None
241
+
242
+ if padding[0] > 0:
243
+ result = result[:, :, padding[0] :]
244
+
245
+ right_adjust = padding[0] - output_padding[0]
246
+ if right_adjust > 0:
247
+ result = result[:, :, :-right_adjust]
248
+ elif right_adjust < 0:
249
+ pad_spec = (0, 0) * (result.ndim - 3) + (0, -right_adjust)
250
+ result = torch.nn.functional.pad(result, pad_spec)
251
+
252
+ return result
253
+
254
+
255
+ def pad_nd(
256
+ input: Tensor,
257
+ pad: Sequence[int],
258
+ mode: str = "constant",
259
+ value: float = 0.0,
260
+ dims: Sequence[int] | None = None,
261
+ ) -> Tensor:
262
+ """
263
+ N-dimensional padding/cropping supporting arbitrary dimensions.
264
+
265
+ Parameters
266
+ ----------
267
+ input
268
+ Input tensor.
269
+ pad
270
+ Padding sizes as pairs (left, right) per dimension.
271
+ Negative values crop. Without `dims`: PyTorch convention.
272
+ mode
273
+ 'constant', 'circular', 'reflect', or 'replicate'.
274
+ value
275
+ Fill value for 'constant' mode.
276
+ dims
277
+ Dimensions to pad. If None, pads last N dimensions.
278
+
279
+ Returns
280
+ -------
281
+ Padded/cropped tensor.
282
+ """
283
+ if len(pad) % 2 != 0:
284
+ raise ValueError("pad must have even length")
285
+
286
+ n_dims = len(pad) // 2
287
+ if n_dims == 0:
288
+ return input
289
+
290
+ ndim = input.ndim
291
+
292
+ if dims is None:
293
+ target_dims = list(range(ndim - 1, ndim - n_dims - 1, -1))
294
+ pad_pairs = [(pad[2 * i], pad[2 * i + 1]) for i in range(n_dims)]
295
+ else:
296
+ if len(dims) != n_dims:
297
+ raise ValueError("len(dims) must equal len(pad) // 2")
298
+ target_dims = [d % ndim for d in dims]
299
+ pad_pairs = [(pad[2 * i], pad[2 * i + 1]) for i in range(n_dims)]
300
+ if len(set(target_dims)) != len(target_dims):
301
+ raise ValueError("duplicate dimensions not supported")
302
+
303
+ if mode == "constant":
304
+ return _pad_constant(input, target_dims, pad_pairs, value)
305
+
306
+ return _pad_non_constant(input, target_dims, pad_pairs, mode)
307
+
308
+
309
+ def pad_or_crop_to_size(
310
+ input: Tensor,
311
+ size: Sequence[int],
312
+ mode: str = "constant",
313
+ value: float = 0.0,
314
+ dims: Sequence[int] | None = None,
315
+ ) -> Tensor:
316
+ """
317
+ Pad or crop tensor to target size, centering the original content.
318
+
319
+ Parameters
320
+ ----------
321
+ input
322
+ Input tensor.
323
+ size
324
+ Target sizes for each dimension to adjust.
325
+ mode
326
+ 'constant', 'circular', 'reflect', or 'replicate'.
327
+ value
328
+ Fill value for 'constant' mode.
329
+ dims
330
+ Dimensions to adjust. If None, adjusts last len(size) dimensions.
331
+
332
+ Returns
333
+ -------
334
+ Tensor with specified dimensions resized to target.
335
+ """
336
+ ndim = input.ndim
337
+
338
+ if dims is None:
339
+ target_dims = list(range(ndim - len(size), ndim))
340
+ else:
341
+ if len(dims) != len(size):
342
+ raise ValueError("len(dims) must equal len(size)")
343
+ target_dims = [d % ndim for d in dims]
344
+
345
+ pad_list: list[int] = []
346
+ for dim, target in zip(target_dims, size):
347
+ diff = target - input.shape[dim]
348
+ pad_list.extend([diff // 2, diff - diff // 2])
349
+
350
+ return pad_nd(input, pad_list, mode=mode, value=value, dims=target_dims)
351
+
352
+
353
+ def _pad_constant(
354
+ input: Tensor,
355
+ target_dims: list[int],
356
+ pad_pairs: list[tuple[int, int]],
357
+ value: float,
358
+ ) -> Tensor:
359
+ dim_to_pad = dict(zip(target_dims, pad_pairs))
360
+
361
+ full_pad: list[int] = []
362
+ for d in range(input.ndim - 1, -1, -1):
363
+ full_pad.extend(dim_to_pad.get(d, (0, 0)))
364
+
365
+ while len(full_pad) > 2 and full_pad[-2:] == [0, 0]:
366
+ full_pad = full_pad[:-2]
367
+
368
+ return torch.nn.functional.pad(input, full_pad, mode="constant", value=value)
369
+
370
+
371
+ def _pad_non_constant(
372
+ input: Tensor,
373
+ target_dims: list[int],
374
+ pad_pairs: list[tuple[int, int]],
375
+ mode: str,
376
+ ) -> Tensor:
377
+ result = input
378
+
379
+ for i in range(0, len(target_dims), 3):
380
+ chunk_dims = target_dims[i : i + 3]
381
+ chunk_pads = pad_pairs[i : i + 3]
382
+
383
+ other_dims = [d for d in range(result.ndim) if d not in chunk_dims]
384
+ perm = other_dims + chunk_dims
385
+
386
+ inv_perm = [0] * len(perm)
387
+ for old_pos, new_pos in enumerate(perm):
388
+ inv_perm[new_pos] = old_pos
389
+
390
+ result = result.permute(perm)
391
+ batch_shape = result.shape[: len(other_dims)]
392
+
393
+ if len(batch_shape) == 0:
394
+ result = result.unsqueeze(0)
395
+ elif len(batch_shape) > 1:
396
+ result = result.flatten(0, len(batch_shape) - 1)
397
+
398
+ n_unsqueeze = max(0, len(chunk_dims) + 2 - result.ndim)
399
+ for _ in range(n_unsqueeze):
400
+ result = result.unsqueeze(1)
401
+
402
+ flat_pad = [p for lr in reversed(chunk_pads) for p in lr]
403
+ result = torch.nn.functional.pad(result, flat_pad, mode=mode)
404
+
405
+ for _ in range(n_unsqueeze):
406
+ result = result.squeeze(1)
407
+
408
+ if len(batch_shape) == 0:
409
+ result = result.squeeze(0)
410
+ elif len(batch_shape) > 1:
411
+ result = result.unflatten(0, batch_shape)
412
+
413
+ result = result.permute(inv_perm)
414
+
415
+ return result
416
+
417
+
418
+ def adjoint_pad_nd(
419
+ input: Tensor,
420
+ pad: Sequence[int],
421
+ mode: str = "constant",
422
+ value: float = 0.0,
423
+ dims: Sequence[int] | None = None,
424
+ original_sizes: Sequence[int] | None = None,
425
+ ) -> Tensor:
426
+ """
427
+ Adjoint of pad_nd via autograd - computes backward of padding as forward op.
428
+
429
+ Parameters
430
+ ----------
431
+ input
432
+ Padded tensor (output space of pad_nd).
433
+ pad
434
+ Padding sizes as pairs (left, right) per dimension (same as pad_nd).
435
+ mode
436
+ 'constant', 'circular', 'reflect', or 'replicate'.
437
+ value
438
+ Fill value for 'constant' mode.
439
+ dims
440
+ Dimensions that were padded. If None, assumes last N dimensions.
441
+ original_sizes
442
+ Original sizes of the padded dimensions before padding.
443
+ Required to reconstruct the unpadded shape.
444
+
445
+ Returns
446
+ -------
447
+ Tensor with padding adjoint applied (unpadded shape).
448
+ """
449
+ if len(pad) % 2 != 0:
450
+ raise ValueError("pad must have even length")
451
+
452
+ n_dims = len(pad) // 2
453
+ if n_dims == 0:
454
+ return input
455
+
456
+ ndim = input.ndim
457
+
458
+ if dims is None:
459
+ target_dims = list(range(ndim - 1, ndim - n_dims - 1, -1))
460
+ else:
461
+ if len(dims) != n_dims:
462
+ raise ValueError("len(dims) must equal len(pad) // 2")
463
+ target_dims = [d % ndim for d in dims]
464
+
465
+ if original_sizes is None:
466
+ original_sizes = []
467
+ for i, d in enumerate(target_dims):
468
+ left, right = pad[2 * i], pad[2 * i + 1]
469
+ original_sizes.append(input.shape[d] - left - right)
470
+
471
+ unpadded_shape = list(input.shape)
472
+ for i, d in enumerate(target_dims):
473
+ unpadded_shape[d] = original_sizes[i]
474
+
475
+ x = torch.zeros(unpadded_shape, device=input.device, dtype=input.dtype, requires_grad=True)
476
+ y = pad_nd(x, pad, mode=mode, value=value, dims=dims)
477
+ (gx,) = torch.autograd.grad(y, x, grad_outputs=input, retain_graph=False, create_graph=True)
478
+ return gx
torchnd/modules.py ADDED
@@ -0,0 +1,635 @@
1
+ """N-dimensional convolution modules wrapping conv_nd functional interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from abc import ABC, abstractmethod
7
+
8
+ import torch
9
+ from torch import Tensor
10
+ from torch.utils.checkpoint import checkpoint
11
+
12
+ from .functional import _normalize_tuple, adjoint_pad_nd, conv_nd, pad_nd
13
+
14
+
15
+ class _ConvNdBase(torch.nn.Module, ABC):
16
+ """Base class for N-dimensional convolution modules."""
17
+
18
+ transposed: bool = False
19
+ in_channels: int
20
+ out_channels: int
21
+ kernel_size: tuple[int, ...]
22
+ dim: tuple[int, ...]
23
+ channel_dim: int
24
+ stride: tuple[int, ...]
25
+ padding: tuple[int, ...]
26
+ output_padding: tuple[int, ...]
27
+ dilation: tuple[int, ...]
28
+ groups: int
29
+ padding_mode: tuple[str, ...]
30
+
31
+ def __init__(
32
+ self,
33
+ in_channels: int,
34
+ out_channels: int,
35
+ kernel_size: int | tuple[int, ...],
36
+ dim: tuple[int, ...] = (-2, -1),
37
+ channel_dim: int = 1,
38
+ stride: int | tuple[int, ...] = 1,
39
+ padding: int | tuple[int, ...] = 0,
40
+ output_padding: int | tuple[int, ...] = 0,
41
+ dilation: int | tuple[int, ...] = 1,
42
+ groups: int = 1,
43
+ bias: bool | Tensor = True,
44
+ padding_mode: str | tuple[str, ...] = "zeros",
45
+ device: torch.device | None = None,
46
+ dtype: torch.dtype | None = None,
47
+ weight: Tensor | None = None,
48
+ ) -> None:
49
+ """Initialize N-dimensional convolution.
50
+
51
+ Parameters
52
+ ----------
53
+ in_channels
54
+ Number of input channels.
55
+ out_channels
56
+ Number of output channels.
57
+ kernel_size
58
+ Size of the convolving kernel.
59
+ dim
60
+ Spatial dimensions to convolve over.
61
+ channel_dim
62
+ Channel dimension index.
63
+ stride
64
+ Stride of the convolution.
65
+ padding
66
+ Padding added to each spatial dimension.
67
+ output_padding
68
+ Additional size added to output shape (transposed conv only).
69
+ dilation
70
+ Spacing between kernel elements.
71
+ groups
72
+ Number of blocked connections from input to output channels.
73
+ bias
74
+ If True, adds learnable bias. If Tensor, uses as external bias.
75
+ padding_mode
76
+ Supported padding modes are 'zeros', 'reflect', 'circular', and 'replicate'.
77
+ Can be a single string (applied to all dims) or a tuple of strings per dim.
78
+ device
79
+ Device for parameters. External weights/bias are moved to this device.
80
+ dtype
81
+ Data type for parameters. Complex dtypes enable complex convolution.
82
+ External weights/bias are cast to this dtype.
83
+ weight
84
+ If provided, uses as external weight (enables hypernetwork usage).
85
+ Registered as buffer to be included in state_dict.
86
+ """
87
+ super().__init__()
88
+
89
+ if in_channels % groups != 0:
90
+ raise ValueError(f"in_channels ({in_channels}) must be divisible by groups ({groups})")
91
+ if out_channels % groups != 0:
92
+ raise ValueError(f"out_channels ({out_channels}) must be divisible by groups ({groups})")
93
+
94
+ num_spatial = len(dim)
95
+ self.in_channels = in_channels
96
+ self.out_channels = out_channels
97
+ self.kernel_size = _normalize_tuple(kernel_size, num_spatial)
98
+ self.dim = dim
99
+ self.channel_dim = channel_dim
100
+ self.stride = _normalize_tuple(stride, num_spatial)
101
+ self.padding = _normalize_tuple(padding, num_spatial)
102
+ self.output_padding = _normalize_tuple(output_padding, num_spatial)
103
+ self.dilation = _normalize_tuple(dilation, num_spatial)
104
+ self.groups = groups
105
+
106
+ if isinstance(padding_mode, str):
107
+ self.padding_mode = (padding_mode,) * num_spatial
108
+ else:
109
+ self.padding_mode = tuple(padding_mode)
110
+
111
+ if self.transposed:
112
+ weight_shape = (in_channels, out_channels // groups, *self.kernel_size)
113
+ else:
114
+ weight_shape = (out_channels, in_channels // groups, *self.kernel_size)
115
+
116
+ for mode in self.padding_mode:
117
+ if mode not in ("zeros", "reflect", "circular", "replicate"):
118
+ raise ValueError(f"padding_mode '{mode}' is not supported")
119
+
120
+ if weight is not None:
121
+ weight = self._apply_device_dtype(weight, device, dtype)
122
+ self.register_buffer("weight", weight)
123
+ else:
124
+ self.weight = torch.nn.Parameter(torch.empty(weight_shape, device=device, dtype=dtype))
125
+
126
+ if isinstance(bias, Tensor):
127
+ bias = self._apply_device_dtype(bias, device, dtype)
128
+ self.register_buffer("bias", bias)
129
+ elif bias:
130
+ self.bias = torch.nn.Parameter(torch.empty(out_channels, device=device, dtype=dtype))
131
+ else:
132
+ self.register_buffer("bias", None)
133
+
134
+ self.reset_parameters()
135
+
136
+ @staticmethod
137
+ def _apply_device_dtype(
138
+ tensor: Tensor,
139
+ device: torch.device | None,
140
+ dtype: torch.dtype | None,
141
+ ) -> Tensor:
142
+ if device is not None or dtype is not None:
143
+ tensor = tensor.to(device=device, dtype=dtype)
144
+ return tensor
145
+
146
+ def reset_parameters(self) -> None:
147
+ """Initialize learnable parameters using Kaiming uniform initialization."""
148
+ if not isinstance(self.weight, torch.nn.Parameter):
149
+ return
150
+
151
+ fan_in = self.weight.shape[1] * math.prod(self.weight.shape[2:])
152
+
153
+ if self.weight.is_complex():
154
+ std = 1.0 / math.sqrt(fan_in * 2)
155
+ bound = math.sqrt(3.0) * std
156
+ with torch.no_grad():
157
+ self.weight.real.uniform_(-bound, bound)
158
+ self.weight.imag.uniform_(-bound, bound)
159
+ else:
160
+ torch.nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5))
161
+
162
+ if isinstance(self.bias, torch.nn.Parameter):
163
+ bound = 1.0 / math.sqrt(fan_in) if fan_in > 0 else 0
164
+ with torch.no_grad():
165
+ if self.bias.is_complex():
166
+ self.bias.real.uniform_(-bound, bound)
167
+ self.bias.imag.uniform_(-bound, bound)
168
+ else:
169
+ self.bias.uniform_(-bound, bound)
170
+
171
+ @abstractmethod
172
+ def forward(self, x: Tensor) -> Tensor:
173
+ """Forward pass of the convolution."""
174
+ raise NotImplementedError
175
+
176
+ @abstractmethod
177
+ def adjoint(self, x: Tensor) -> Tensor: # noqa: ARG002
178
+ """Return the adjoint convolution.
179
+
180
+ The adjoint of a convolution is the transposed convolution with
181
+ conjugated weights.
182
+
183
+ Parameters
184
+ ----------
185
+ x
186
+ Input tensor.
187
+ Returns
188
+ -------
189
+ Adjoint convolution.
190
+ """
191
+ raise NotImplementedError
192
+
193
+ def extra_repr(self) -> str:
194
+ parts = [
195
+ f"{self.in_channels}",
196
+ f"{self.out_channels}",
197
+ f"kernel_size={self.kernel_size}",
198
+ f"stride={self.stride}",
199
+ ]
200
+ if self.padding != (0,) * len(self.padding):
201
+ parts.append(f"padding={self.padding}")
202
+ if self.dilation != (1,) * len(self.dilation):
203
+ parts.append(f"dilation={self.dilation}")
204
+ if self.transposed and self.output_padding != (0,) * len(self.output_padding):
205
+ parts.append(f"output_padding={self.output_padding}")
206
+ if self.groups != 1:
207
+ parts.append(f"groups={self.groups}")
208
+ if self.bias is None:
209
+ parts.append("bias=False")
210
+ if not all(m == "zeros" for m in self.padding_mode):
211
+ # Show single value if all same, otherwise show tuple
212
+ if len(set(self.padding_mode)) == 1:
213
+ parts.append(f"padding_mode={self.padding_mode[0]!r}")
214
+ else:
215
+ parts.append(f"padding_mode={self.padding_mode}")
216
+ if self.dim != (-2, -1):
217
+ parts.append(f"dim={self.dim}")
218
+ if self.channel_dim != 1:
219
+ parts.append(f"channel_dim={self.channel_dim}")
220
+ if not isinstance(self.weight, torch.nn.Parameter):
221
+ parts.append("weight=external")
222
+ if self.bias is not None and not isinstance(self.bias, torch.nn.Parameter):
223
+ parts.append("bias=external")
224
+ return ", ".join(parts)
225
+
226
+
227
+ class ConvNd(_ConvNdBase):
228
+ """N-dimensional convolution with flexible dimension specification.
229
+
230
+ Supports arbitrary spatial dimensions and dimension layouts. When native
231
+ PyTorch implementations (1D, 2D, 3D) are unavailable, uses recursive
232
+ decomposition.
233
+
234
+ Examples
235
+ --------
236
+ >>> conv = ConvNd(3, 64, kernel_size=3, dim=(-2, -1))
237
+ >>> x = torch.randn(2, 3, 32, 32)
238
+ >>> y = conv(x)
239
+
240
+ >>> # Hypernetwork usage
241
+ >>> external_weight = hyper_net(z)
242
+ >>> conv = ConvNd(3, 64, kernel_size=3, weight=external_weight, bias=False)
243
+ >>> y = conv(x) # Gradients flow through hyper_net
244
+ """
245
+
246
+ transposed = False
247
+
248
+ def __init__(
249
+ self,
250
+ in_channels: int,
251
+ out_channels: int,
252
+ kernel_size: int | tuple[int, ...],
253
+ dim: tuple[int, ...] = (-2, -1),
254
+ channel_dim: int = 1,
255
+ stride: int | tuple[int, ...] = 1,
256
+ padding: int | tuple[int, ...] = 0,
257
+ dilation: int | tuple[int, ...] = 1,
258
+ groups: int = 1,
259
+ bias: bool | Tensor = True,
260
+ padding_mode: str | tuple[str, ...] = "zeros",
261
+ device: torch.device | None = None,
262
+ dtype: torch.dtype | None = None,
263
+ weight: Tensor | None = None,
264
+ ) -> None:
265
+ """Initialize N-dimensional convolution.
266
+
267
+ Parameters
268
+ ----------
269
+ in_channels
270
+ Number of input channels.
271
+ out_channels
272
+ Number of output channels.
273
+ kernel_size
274
+ Size of the convolving kernel.
275
+ dim
276
+ Spatial dimensions to convolve over.
277
+ channel_dim
278
+ Channel dimension index.
279
+ stride
280
+ Stride of the convolution.
281
+ padding
282
+ Padding added to each spatial dimension.
283
+ dilation
284
+ Spacing between kernel elements.
285
+ groups
286
+ Number of blocked connections from input to output channels.
287
+ bias
288
+ If True, adds learnable bias. If Tensor, uses as external bias.
289
+ padding_mode
290
+ Supported padding modes are 'zeros', 'reflect', 'circular', and 'replicate'.
291
+ Can be a single string (applied to all dims) or a tuple of strings per dim.
292
+ device
293
+ Device for parameters. External weights/bias are moved to this device.
294
+ dtype
295
+ Data type for parameters. Complex dtypes enable complex convolution.
296
+ weight
297
+ If provided, uses as external weight. Registered as buffer for state_dict.
298
+ """
299
+ super().__init__(
300
+ in_channels=in_channels,
301
+ out_channels=out_channels,
302
+ kernel_size=kernel_size,
303
+ dim=dim,
304
+ channel_dim=channel_dim,
305
+ stride=stride,
306
+ padding=padding,
307
+ output_padding=0,
308
+ dilation=dilation,
309
+ groups=groups,
310
+ bias=bias,
311
+ padding_mode=padding_mode,
312
+ device=device,
313
+ dtype=dtype,
314
+ weight=weight,
315
+ )
316
+
317
+ def forward(self, x: Tensor) -> Tensor:
318
+ """Apply the forward convolution operator.
319
+
320
+ Parameters
321
+ ----------
322
+ x
323
+ Input tensor.
324
+
325
+ Returns
326
+ -------
327
+ Output tensor.
328
+ """
329
+ effective_padding = self.padding
330
+
331
+ if not all(m == "zeros" for m in self.padding_mode):
332
+ effective_padding = tuple(0 if m != "zeros" and p > 0 else p for m, p in zip(self.padding_mode, self.padding))
333
+ for mode in ("reflect", "replicate", "circular"):
334
+ dims_idx = [i for i, (m, p) in enumerate(zip(self.padding_mode, self.padding)) if m == mode and p > 0]
335
+ if dims_idx:
336
+ spatial_dims = tuple(self.dim[i] for i in dims_idx)
337
+ pad_spec = [v for i in dims_idx for v in (self.padding[i], self.padding[i])]
338
+ x = checkpoint(pad_nd, x, pad_spec, mode, 0.0, spatial_dims, use_reentrant=False)
339
+
340
+ out = conv_nd(
341
+ x,
342
+ self.weight,
343
+ dim=self.dim,
344
+ channel_dim=self.channel_dim,
345
+ stride=self.stride,
346
+ padding=effective_padding,
347
+ dilation=self.dilation,
348
+ groups=self.groups,
349
+ transposed=False,
350
+ )
351
+
352
+ if self.bias is not None:
353
+ bias_shape = [1] * out.ndim
354
+ bias_shape[self.channel_dim % out.ndim] = -1
355
+ out = out + self.bias.view(*bias_shape)
356
+
357
+ return out
358
+
359
+ def adjoint(self, x: Tensor, input_shape: tuple[int, ...] | None = None) -> Tensor:
360
+ """Apply the adjoint convolution operator.
361
+
362
+ The adjoint of a convolution is the transposed convolution with
363
+ conjugated weights (for complex) or identical weights (for real).
364
+
365
+ This method applies the adjoint using the same weights (and does not
366
+ allocate a new module). Intended for iterative algorithms, not separate
367
+ training of an adjoint operator.
368
+
369
+ Parameters
370
+ ----------
371
+ x
372
+ Input tensor in the output space of the forward operator.
373
+ input_shape
374
+ Spatial shape of the input to the forward operator. Needed for
375
+ strided ConvNd to determine output_padding of the adjoint. For
376
+ stride > 1, multiple input shapes can map to the same output shape,
377
+ so the adjoint output size is ambiguous without this information.
378
+
379
+ Returns
380
+ -------
381
+ Result of applying the adjoint operator.
382
+
383
+ Raises
384
+ ------
385
+ ValueError
386
+ If bias is present (adjoint undefined with bias).
387
+ If input_shape is missing when any stride != 1.
388
+ """
389
+ if self.bias is not None:
390
+ raise ValueError("Adjoint is not defined for convolution with bias")
391
+
392
+ effective_padding = self.padding
393
+ if not all(m == "zeros" for m in self.padding_mode):
394
+ effective_padding = tuple(0 if m != "zeros" and p > 0 else p for m, p in zip(self.padding_mode, self.padding))
395
+
396
+ num_spatial = len(self.dim)
397
+ if input_shape is None:
398
+ if any(s != 1 for s in self.stride):
399
+ raise ValueError("input_shape is required for ConvNd.adjoint when any stride != 1")
400
+ out_pad = (0,) * num_spatial
401
+ else:
402
+ if len(input_shape) != num_spatial:
403
+ raise ValueError(f"input_shape must have length {num_spatial}, got {len(input_shape)}")
404
+
405
+ pads: list[int] = []
406
+ for i, (n_in, k, s, p, d) in enumerate(zip(input_shape, self.kernel_size, self.stride, self.padding, self.dilation)):
407
+ eff_p = effective_padding[i]
408
+ n_out = (n_in + 2 * p - d * (k - 1) - 1) // s + 1
409
+ base = (n_out - 1) * s - 2 * eff_p + d * (k - 1) + 1
410
+ op = (n_in + 2 * (p - eff_p)) - base
411
+ if op < 0 or op >= s:
412
+ raise RuntimeError(
413
+ "Computed output_padding is invalid. "
414
+ f"Got output_padding={op} for stride={s} "
415
+ f"(input={n_in}, kernel={k}, pad={p}, dilation={d})."
416
+ )
417
+ pads.append(op)
418
+ out_pad = tuple(pads)
419
+
420
+ w = self.weight.conj() if self.weight.is_complex() else self.weight
421
+ out = conv_nd(
422
+ x,
423
+ w,
424
+ dim=self.dim,
425
+ channel_dim=self.channel_dim,
426
+ stride=self.stride,
427
+ padding=effective_padding,
428
+ dilation=self.dilation,
429
+ groups=self.groups,
430
+ transposed=True,
431
+ output_padding=out_pad,
432
+ )
433
+
434
+ if not all(m == "zeros" for m in self.padding_mode):
435
+ for mode in ("reflect", "replicate", "circular"):
436
+ dims_idx = [i for i, (m, p) in enumerate(zip(self.padding_mode, self.padding)) if m == mode and p > 0]
437
+ if dims_idx:
438
+ spatial_dims = tuple(self.dim[i] for i in dims_idx)
439
+ pad_spec = [v for i in dims_idx for v in (self.padding[i], self.padding[i])]
440
+ original_sizes = [input_shape[i] for i in dims_idx] if input_shape else None
441
+ out = adjoint_pad_nd(out, pad_spec, mode=mode, dims=spatial_dims, original_sizes=original_sizes)
442
+
443
+ return out
444
+
445
+
446
+ class ConvTransposeNd(_ConvNdBase):
447
+ """N-dimensional transposed convolution with flexible dimension specification.
448
+
449
+ Also known as fractionally-strided convolution or deconvolution.
450
+
451
+ Examples
452
+ --------
453
+ >>> conv_t = ConvTransposeNd(64, 3, kernel_size=3, stride=2, dim=(-2, -1))
454
+ >>> x = torch.randn(2, 64, 16, 16)
455
+ >>> y = conv_t(x)
456
+ """
457
+
458
+ transposed = True
459
+
460
+ def __init__(
461
+ self,
462
+ in_channels: int,
463
+ out_channels: int,
464
+ kernel_size: int | tuple[int, ...],
465
+ dim: tuple[int, ...] = (-2, -1),
466
+ channel_dim: int = 1,
467
+ stride: int | tuple[int, ...] = 1,
468
+ padding: int | tuple[int, ...] = 0,
469
+ output_padding: int | tuple[int, ...] = 0,
470
+ dilation: int | tuple[int, ...] = 1,
471
+ groups: int = 1,
472
+ bias: bool | Tensor = True,
473
+ padding_mode: str | tuple[str, ...] = "zeros",
474
+ device: torch.device | None = None,
475
+ dtype: torch.dtype | None = None,
476
+ weight: Tensor | None = None,
477
+ ) -> None:
478
+ """Initialize N-dimensional transposed convolution.
479
+
480
+ Parameters
481
+ ----------
482
+ in_channels
483
+ Number of input channels.
484
+ out_channels
485
+ Number of output channels.
486
+ kernel_size
487
+ Size of the convolving kernel.
488
+ dim
489
+ Spatial dimensions to convolve over.
490
+ channel_dim
491
+ Channel dimension index.
492
+ stride
493
+ Stride of the convolution.
494
+ padding
495
+ Padding added to each spatial dimension.
496
+ output_padding
497
+ Additional size added to output shape to disambiguate when stride > 1.
498
+ dilation
499
+ Spacing between kernel elements.
500
+ groups
501
+ Number of blocked connections from input to output channels.
502
+ bias
503
+ If True, adds learnable bias. If Tensor, uses as external bias.
504
+ padding_mode
505
+ Supported padding modes are 'zeros', 'reflect', 'circular', and 'replicate'.
506
+ Can be a single string (applied to all dims) or a tuple of strings per dim.
507
+ device
508
+ Device for parameters. External weights/bias are moved to this device.
509
+ dtype
510
+ Data type for parameters. Complex dtypes enable complex convolution.
511
+ weight
512
+ If provided, uses as external weight. Registered as buffer for state_dict.
513
+ """
514
+ super().__init__(
515
+ in_channels=in_channels,
516
+ out_channels=out_channels,
517
+ kernel_size=kernel_size,
518
+ dim=dim,
519
+ channel_dim=channel_dim,
520
+ stride=stride,
521
+ padding=padding,
522
+ output_padding=output_padding,
523
+ dilation=dilation,
524
+ groups=groups,
525
+ bias=bias,
526
+ padding_mode=padding_mode,
527
+ device=device,
528
+ dtype=dtype,
529
+ weight=weight,
530
+ )
531
+
532
+ def forward(self, x: Tensor) -> Tensor:
533
+ """Apply the forward convolution operator.
534
+
535
+ Parameters
536
+ ----------
537
+ x
538
+ Input tensor.
539
+
540
+ Returns
541
+ -------
542
+ Output tensor.
543
+ """
544
+
545
+ effective_padding = self.padding
546
+ if not all(m == "zeros" for m in self.padding_mode):
547
+ effective_padding = tuple(0 if m != "zeros" and p > 0 else p for m, p in zip(self.padding_mode, self.padding))
548
+
549
+ out = conv_nd(
550
+ x,
551
+ self.weight,
552
+ dim=self.dim,
553
+ channel_dim=self.channel_dim,
554
+ stride=self.stride,
555
+ padding=effective_padding,
556
+ dilation=self.dilation,
557
+ groups=self.groups,
558
+ transposed=True,
559
+ output_padding=self.output_padding,
560
+ )
561
+
562
+ if not all(m == "zeros" for m in self.padding_mode):
563
+ for mode in ("reflect", "replicate", "circular"):
564
+ dims_idx = [i for i, (m, p) in enumerate(zip(self.padding_mode, self.padding)) if m == mode and p > 0]
565
+ if dims_idx:
566
+ spatial_dims = tuple(self.dim[i] for i in dims_idx)
567
+ pad_spec = [v for i in dims_idx for v in (self.padding[i], self.padding[i])]
568
+ original_sizes = [out.shape[self.dim[i]] - 2 * self.padding[i] for i in dims_idx]
569
+ out = checkpoint(
570
+ adjoint_pad_nd,
571
+ out,
572
+ pad_spec,
573
+ mode,
574
+ 0.0,
575
+ spatial_dims,
576
+ original_sizes,
577
+ use_reentrant=False,
578
+ )
579
+
580
+ if self.bias is not None:
581
+ bias_shape = [1] * out.ndim
582
+ bias_shape[self.channel_dim % out.ndim] = -1
583
+ out = out + self.bias.view(*bias_shape)
584
+
585
+ return out
586
+
587
+ def adjoint(self, x: Tensor) -> Tensor:
588
+ """Apply the adjoint convolution operator.
589
+
590
+ The adjoint of a transposed convolution is the (forward) convolution with
591
+ conjugated weights (for complex) or identical weights (for real).
592
+
593
+ This method applies the adjoint using the same weights (and does not
594
+ allocate a new module). Intended for iterative algorithms, not separate
595
+ training of an adjoint operator.
596
+
597
+ Parameters
598
+ ----------
599
+ x
600
+ Input tensor in the output space of the forward operator.
601
+
602
+ Returns
603
+ -------
604
+ Result of applying the adjoint operator.
605
+
606
+ Raises
607
+ ------
608
+ ValueError
609
+ If bias is present (adjoint undefined with bias).
610
+ """
611
+ if self.bias is not None:
612
+ raise ValueError("Adjoint is not defined for convolution with bias")
613
+
614
+ effective_padding = self.padding
615
+ if not all(m == "zeros" for m in self.padding_mode):
616
+ effective_padding = tuple(0 if m != "zeros" and p > 0 else p for m, p in zip(self.padding_mode, self.padding))
617
+ for mode in ("reflect", "replicate", "circular"):
618
+ dims_idx = [i for i, (m, p) in enumerate(zip(self.padding_mode, self.padding)) if m == mode and p > 0]
619
+ if dims_idx:
620
+ spatial_dims = tuple(self.dim[i] for i in dims_idx)
621
+ pad_spec = [v for i in dims_idx for v in (self.padding[i], self.padding[i])]
622
+ x = pad_nd(x, pad_spec, mode=mode, dims=spatial_dims)
623
+
624
+ w = self.weight.conj() if self.weight.is_complex() else self.weight
625
+ return conv_nd(
626
+ x,
627
+ w,
628
+ dim=self.dim,
629
+ channel_dim=self.channel_dim,
630
+ stride=self.stride,
631
+ padding=effective_padding,
632
+ dilation=self.dilation,
633
+ groups=self.groups,
634
+ transposed=False,
635
+ )
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: torchnd
3
+ Version: 0.1.0
4
+ Summary: ND Convolution for PyTorch
5
+ Author-email: Felix Zimmermann <fzimmermann89@gmail.com>
6
+ License: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/fzimmermann89/torch_conv_nd
8
+ Project-URL: Repository, https://github.com/fzimmermann89/torch_conv_nd
9
+ Project-URL: Issues, https://github.com/fzimmermann89/torch_conv_nd/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: torch>=2.0.0
23
+ Requires-Dist: einops>=0.6.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
27
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
28
+ Requires-Dist: mypy>=1.5.0; extra == "dev"
29
+ Requires-Dist: pre-commit>=3.4.0; extra == "dev"
30
+ Requires-Dist: types-setuptools; extra == "dev"
31
+
32
+ # torchnd
33
+
34
+ N-dimensional convolution for PyTorch that works with any number of spatial dimensions, supporting groups, dilation, transposed convolution, complex numbers, and arbitrary dimension layouts.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install torchnd
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ **2D convolution:**
45
+ ```python
46
+ import torch
47
+ from torchnd import conv_nd
48
+
49
+ x = torch.randn(2, 4, 16, 16)
50
+ weight = torch.randn(8, 4, 3, 3)
51
+ out = conv_nd(x, weight, dim=(-2, -1), padding=1, stride=2)
52
+ ```
53
+
54
+ **4D convolution** (batch × time × height × width):
55
+ ```python
56
+ x = torch.randn(2, 4, 10, 16, 16)
57
+ weight = torch.randn(8, 4, 3, 3, 3)
58
+ out = conv_nd(x, weight, dim=(-3, -2, -1), padding=1)
59
+ ```
60
+
61
+ **Channel-last layout:**
62
+ ```python
63
+ x = torch.randn(2, 16, 16, 4)
64
+ weight = torch.randn(8, 4, 3, 3)
65
+ out = conv_nd(x, weight, dim=(1, 2), channel_dim=-1, padding=1)
66
+ ```
67
+
68
+ **Transposed convolution:**
69
+ ```python
70
+ x = torch.randn(2, 4, 8, 8)
71
+ weight = torch.randn(4, 8, 3, 3)
72
+ out = conv_nd(x, weight, dim=(-2, -1), padding=1, stride=2, transposed=True)
73
+ ```
74
+
75
+ **Complex numbers:**
76
+ ```python
77
+ x = torch.randn(2, 4, 16, 16, dtype=torch.complex64)
78
+ weight = torch.randn(8, 4, 3, 3, dtype=torch.complex64)
79
+ out = conv_nd(x, weight, dim=(-2, -1), padding=1)
80
+ ```
81
+
82
+ **Asymmetric parameters per dimension:**
83
+ ```python
84
+ x = torch.randn(2, 4, 16, 16)
85
+ weight = torch.randn(8, 4, 3, 3)
86
+ out = conv_nd(
87
+ x, weight,
88
+ dim=(-2, -1),
89
+ stride=(2, 1),
90
+ padding=(1, 2),
91
+ dilation=(1, 2)
92
+ )
93
+ ```
94
+
95
+ **Modules:**
96
+ ```python
97
+ from torchnd import ConvNd, ConvTransposeNd
98
+
99
+ conv = ConvNd(4, 8, 3, dim=(-2, -1), padding=1)
100
+ out = conv(x)
101
+ ```
102
+
103
+ **Padding:**
104
+ ```python
105
+ from torchnd import pad_nd, adjoint_pad_nd
106
+
107
+ # Pad or crop arbitrary dimensions
108
+ padded = pad_nd(x, pad=(1, 1, 2, 2), dims=(-2, -1), mode="reflect")
109
+ cropped = pad_nd(x, pad=(-1, -1), dims=(0,)) # Negative values crop
110
+
111
+ # Adjoint of padding (unpads)
112
+ unpadded = adjoint_pad_nd(padded, pad=(1, 1, 2, 2), dims=(-2, -1))
113
+ ```
114
+
115
+ **Adjoint operators:**
116
+ ```python
117
+ from torchnd import ConvNd
118
+
119
+ conv = ConvNd(4, 8, 3, dim=(-2, -1), padding=1, bias=False)
120
+ x = torch.randn(2, 4, 16, 16)
121
+ y = torch.randn_like(conv(x))
122
+
123
+ # Adjoint satisfies: <conv(x), y> = <x, conv.adjoint(y)>
124
+ # For ConvNd: adjoint is transposed convolution with conjugated weights
125
+ adj_y = conv.adjoint(y, input_shape=(16, 16))
126
+ ```
127
+
128
+ ## Functions
129
+
130
+ ### pad_nd
131
+
132
+ 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.
133
+
134
+ Modes: `constant`, `reflect`, `replicate`, `circular`. Each dimension can use a different mode. For constant mode, negative padding crops symmetrically. For non-constant modes, negative padding is not supported.
135
+
136
+ The `dims` parameter specifies which dimensions to pad. If `None`, pads the last N dimensions where N = len(pad) // 2. Padding is specified as pairs (left, right) per dimension.
137
+
138
+ ### adjoint_pad_nd
139
+
140
+ Computes the adjoint of `pad_nd` via autograd. For a padding operator P, the adjoint P* satisfies the inner product identity: `<Px, y> = <x, P*y>` for all x, y. The adjoint unpads the tensor, summing contributions from padded regions back into the original shape. For constant/zeros mode, the adjoint is equivalent to cropping. For other modes, it correctly handles the adjoint of the boundary extension.
141
+
142
+ ### Adjoint convolution
143
+
144
+ The `ConvNd` and `ConvTransposeNd` modules provide `adjoint()` methods. For `ConvNd`, the adjoint is the transposed convolution with conjugated weights (for complex) or identical weights (for real). For `ConvTransposeNd`, the adjoint is the forward convolution with conjugated weights.
145
+
146
+ The adjoint satisfies the inner product identity: `<Ax, y> = <x, A*y>` where A is the convolution operator and A* is its adjoint. This is verified via dot product tests. The adjoint is undefined when bias is present.
147
+
148
+ ## Implementation
149
+
150
+ For dimensions beyond 3D, `conv_nd` recursively decomposes the convolution into lower-dimensional operations. A 4D convolution becomes a sum of 3D convolutions applied to strided slices of the input.
151
+
152
+ Complex convolution is handled by decomposing into real operations: `(a+bi)*(c+di) = (ac-bd) + (ad+bc)i`.
153
+
154
+ The implementation uses native PyTorch operations when possible (1D, 2D, 3D) and falls back to recursion only for higher dimensions, minimizing memory overhead with strided views.
@@ -0,0 +1,7 @@
1
+ torchnd/__init__.py,sha256=H3h4vZsjgSbFKvS0w9O5kXEFVs3zqsX8eM7GdIqRmiw,308
2
+ torchnd/functional.py,sha256=9tKawtVRUa3KFyve6pcjv603yeaVB831eXg98xio4uQ,15043
3
+ torchnd/modules.py,sha256=wyqD55PJLEH4LUvlB4Jp3EBH14nvMsqIxvn19TB28KM,23324
4
+ torchnd-0.1.0.dist-info/METADATA,sha256=DoVe_hcmKauvJnRBtLXQ-PoLHHCUsQoXezpzG-1k1Ko,5694
5
+ torchnd-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ torchnd-0.1.0.dist-info/top_level.txt,sha256=ZsJixSsEHaRPdRuRlYQcnFKvii3QUbRrtEvyK_F7uIg,8
7
+ torchnd-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ torchnd