From 81d37809c6e3c7bb0e9935a920df4e3f7388da7b Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 14:39:50 +0200 Subject: [PATCH 01/15] style(conv): join split error strings as ruff format does ruff 0.17 joins implicitly concatenated strings that fit on one line, so "ruff format --check" fails on complexnn/conv.py as it is on main. Apply that change alone, to the three copies of the "channel dimension should be defined" message, so the next commit only holds the channel axis validation. No behaviour change. --- complexnn/conv.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/complexnn/conv.py b/complexnn/conv.py index 1f6303d..759f8c2 100644 --- a/complexnn/conv.py +++ b/complexnn/conv.py @@ -333,8 +333,7 @@ def build(self, input_shape): channel_axis = -1 if input_shape[channel_axis] is None: raise ValueError( - "The channel dimension of the inputs " - "should be defined. Found `None`." + "The channel dimension of the inputs should be defined. Found `None`." ) # Divide by 2 for real and complex input. input_dim = input_shape[channel_axis] // 2 @@ -1106,8 +1105,7 @@ def build(self, input_shape): channel_axis = -1 if input_shape[channel_axis] is None: raise ValueError( - "The channel dimension of the inputs " - "should be defined. Found `None`." + "The channel dimension of the inputs should be defined. Found `None`." ) input_dim = input_shape[channel_axis] gamma_shape = (input_dim * self.filters,) @@ -1127,8 +1125,7 @@ def call(self, inputs): channel_axis = -1 if input_shape[channel_axis] is None: raise ValueError( - "The channel dimension of the inputs " - "should be defined. Found `None`." + "The channel dimension of the inputs should be defined. Found `None`." ) input_dim = input_shape[channel_axis] ker_shape = self.kernel_size + (input_dim, self.filters) From 2ac2634adde839f0b723ed1c402019591bda4abc Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 14:41:03 +0200 Subject: [PATCH 02/15] feat: validate the complex channel axis at build time The layers store complex values as real tensors: the first half of the channel axis holds the real parts and the second half the imaginary parts. An odd or single channel used to fail with errors that did not say so: an input spec asking for size 0 (#15), a bare AssertionError in ComplexDense, or a shape mismatch deep inside ComplexBatchNormalization and ComplexLayerNorm. GetReal, GetImag and GetAbs returned halves of different sizes without complaint. Add _check_complex_axis to utils.py and call it from the build methods of ComplexConv (1D, 2D, 3D and transposed), ComplexDense, ComplexBatchNormalization, ComplexLayerNorm, GetReal, GetImag and GetAbs. It raises a ValueError that names the layer, the axis and the received shape, and explains the layout. The convolution message also says which data_format chose the axis, which is what caught out the reporter of #15. The normalisation layers point to their axis argument. Every rejected shape already failed on Keras 2 and Keras 3, except GetReal, GetImag and GetAbs on an odd size. Those three skip the check when the size is undefined, because the dynamic split in call handles that case on Keras 2. Tests cover odd and too small sizes for each layer, the exact layer from #15, and the smallest valid layout. Refs #15 --- complexnn/bn.py | 9 +++ complexnn/conv.py | 8 +++ complexnn/dense.py | 4 +- complexnn/norm.py | 8 +++ complexnn/utils.py | 80 +++++++++++++++++++------ tests/test_validation.py | 125 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 tests/test_validation.py diff --git a/complexnn/bn.py b/complexnn/bn.py index 95f4d3f..79cd641 100644 --- a/complexnn/bn.py +++ b/complexnn/bn.py @@ -11,6 +11,8 @@ from tensorflow.keras import constraints, initializers, regularizers from tensorflow.keras.layers import InputSpec, Layer +from .utils import _check_complex_axis + def sqrt_init(shape, dtype=None): value = (1 / np.sqrt(2)) * K.ones(shape) @@ -352,6 +354,13 @@ def build(self, input_shape): "input tensor should have a defined dimension " "but the layer received an input with shape " + str(input_shape) + "." ) + _check_complex_axis( + self, + input_shape, + self.axis, + hint="Use the `axis` argument to choose the axis that holds the real " + "and imaginary parts.", + ) self.input_spec = InputSpec(ndim=len(input_shape), axes={self.axis: dim}) param_shape = (input_shape[self.axis] // 2,) diff --git a/complexnn/conv.py b/complexnn/conv.py index 759f8c2..16cce1c 100644 --- a/complexnn/conv.py +++ b/complexnn/conv.py @@ -16,6 +16,7 @@ from .bn import sqrt_init from .fft import fft, fft2, ifft, ifft2 from .init import ComplexIndependentFilters, ComplexInit +from .utils import _check_complex_axis def conv1d_transpose( @@ -335,6 +336,13 @@ def build(self, input_shape): raise ValueError( "The channel dimension of the inputs should be defined. Found `None`." ) + _check_complex_axis( + self, + input_shape, + channel_axis, + hint=f"The channel axis is axis {channel_axis} because data_format " + f"is '{self.data_format}'.", + ) # Divide by 2 for real and complex input. input_dim = input_shape[channel_axis] // 2 if False and self.transposed: diff --git a/complexnn/dense.py b/complexnn/dense.py index 274ffd5..38c9e50 100644 --- a/complexnn/dense.py +++ b/complexnn/dense.py @@ -7,7 +7,7 @@ from tensorflow.keras import backend as K from tensorflow.keras.layers import InputSpec, Layer -from .utils import _compute_fans +from .utils import _check_complex_axis, _compute_fans class ComplexDense(Layer): @@ -97,7 +97,7 @@ def __init__( def build(self, input_shape): assert len(input_shape) == 2 - assert input_shape[-1] % 2 == 0 + _check_complex_axis(self, input_shape, -1) input_dim = input_shape[-1] // 2 data_format = K.image_data_format() kernel_shape = (input_dim, self.units) diff --git a/complexnn/norm.py b/complexnn/norm.py index d66e193..7a1c473 100644 --- a/complexnn/norm.py +++ b/complexnn/norm.py @@ -11,6 +11,7 @@ from .bn import ComplexBN as complex_normalization from .bn import sqrt_init +from .utils import _check_complex_axis def layernorm(x, axis, epsilon, gamma, beta): @@ -149,6 +150,13 @@ def build(self, input_shape): "input tensor should have a defined dimension " "but the layer received an input with shape " + str(input_shape) + "." ) + _check_complex_axis( + self, + input_shape, + self.axis, + hint="Use the `axis` argument to choose the axis that holds the real " + "and imaginary parts.", + ) self.input_spec = InputSpec(ndim=len(input_shape), axes={self.axis: dim}) gamma_shape = (input_shape[self.axis] // 2,) diff --git a/complexnn/utils.py b/complexnn/utils.py index 2ce9c90..94cbc14 100644 --- a/complexnn/utils.py +++ b/complexnn/utils.py @@ -53,21 +53,59 @@ def get_abs(x): return K.sqrt(real * real + imag * imag) +def _complex_part_axis(ndim): + """Axis that `get_realpart` and `get_imagpart` split for a rank `ndim` input.""" + if (K.image_data_format() == "channels_first" and ndim != 3) or ndim == 2: + return 1 + return -1 + + def getpart_output_shape(input_shape): returned_shape = list(input_shape[:]) - image_format = K.image_data_format() - ndim = len(returned_shape) - - if (image_format == "channels_first" and ndim != 3) or ndim == 2: - axis = 1 - else: - axis = -1 + axis = _complex_part_axis(len(returned_shape)) returned_shape[axis] = returned_shape[axis] // 2 return tuple(returned_shape) +def _check_complex_axis(layer, input_shape, axis, hint=""): + """Check that an axis of the input can hold real and imaginary parts. + + The layers store complex values as real tensors: along one axis, usually + the channel axis, the first half holds the real parts and the second half + the imaginary parts. That axis must therefore have an even size of at + least 2. + + # Arguments + layer: The layer doing the check, named in the error message. + input_shape: Shape of the layer input, including the batch axis. + axis: Integer, the axis that holds the real and imaginary parts. + hint: Optional sentence added to the end of the error message. + + # Raises + ValueError: If the size of `axis` is undefined, odd or smaller than 2. + """ + shape = tuple(input_shape) + size = shape[axis] + if size is not None and size >= 2 and size % 2 == 0: + return + if size is None: + received = f"the size of axis {axis} is not defined" + else: + received = f"axis {axis} has size {size}" + message = ( + f"{type(layer).__name__} expects the real and imaginary parts of its " + f"input stacked along axis {axis}: the first half of that axis holds " + "the real parts and the second half the imaginary parts, so its size " + f"must be even and at least 2. Received an input with shape {shape}, " + f"where {received}." + ) + if hint: + message = f"{message} {hint}" + raise ValueError(message) + + # _compute_fans is different in keras-2 keras.initializers and tensorflow.python.ops.init_ops # this is the implementation copied from keras-2: def _compute_fans(shape, data_format="channels_last"): @@ -108,25 +146,31 @@ def _compute_fans(shape, data_format="channels_last"): return fan_in, fan_out -class GetReal(Layer): - def call(self, inputs): - return get_realpart(inputs) +class _ComplexPart(Layer): + """Base class for the layers that split their input into its two parts.""" + + def build(self, input_shape): + axis = _complex_part_axis(len(input_shape)) + # An undefined size is split from the dynamic shape in `call`, which + # works on Keras 2, so only check sizes that are known here. + if tuple(input_shape)[axis] is not None: + _check_complex_axis(self, input_shape, axis) + super(_ComplexPart, self).build(input_shape) def compute_output_shape(self, input_shape): return getpart_output_shape(input_shape) -class GetImag(Layer): +class GetReal(_ComplexPart): def call(self, inputs): - return get_imagpart(inputs) + return get_realpart(inputs) - def compute_output_shape(self, input_shape): - return getpart_output_shape(input_shape) + +class GetImag(_ComplexPart): + def call(self, inputs): + return get_imagpart(inputs) -class GetAbs(Layer): +class GetAbs(_ComplexPart): def call(self, inputs): return get_abs(inputs) - - def compute_output_shape(self, input_shape): - return getpart_output_shape(input_shape) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..c801278 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,125 @@ +import unittest + +import complexnn as conn +from tensorflow.keras.layers import Input + +# Part of every error message about the real and imaginary layout. +LAYOUT_ERROR = "must be even and at least 2" + + +class TestComplexAxisValidation(unittest.TestCase): + """Layers reject inputs whose complex axis cannot hold both parts (issue #15)""" + + def assertLayoutError(self, layer, shape, *fragments): + """Calling `layer` on an input of `shape` raises the layout error.""" + with self.assertRaisesRegex(ValueError, LAYOUT_ERROR) as context: + layer(Input(shape=shape)) + message = str(context.exception) + self.assertIn(type(layer).__name__, message) + for fragment in fragments: + self.assertIn(fragment, message) + + def test_issue_15_channels_first(self): + """The exact layer and input from issue #15""" + layer = conn.ComplexConv3D( + filters=2, + kernel_size=(3, 3, 2), + padding="same", + data_format="channels_first", + ) + self.assertLayoutError( + layer, + (1, 32, 32, 2), + "axis 1 has size 1", + "data_format is 'channels_first'", + ) + + def test_issue_15_valid_layout(self): + """The same layer builds once the parts are on the channel axis""" + layer = conn.ComplexConv3D( + filters=2, + kernel_size=(3, 3, 2), + padding="same", + data_format="channels_first", + ) + outputs = layer(Input(shape=(2, 32, 32, 1))) + self.assertEqual((None, 4, 32, 32, 1), tuple(outputs.shape)) + + def test_conv_odd_channels(self): + self.assertLayoutError(conn.ComplexConv1D(2, 3), (16, 3), "axis -1 has size 3") + self.assertLayoutError( + conn.ComplexConv2D(2, 3), (8, 8, 5), "axis -1 has size 5" + ) + self.assertLayoutError( + conn.ComplexConv3D(2, 3), (8, 8, 8, 3), "axis -1 has size 3" + ) + + def test_conv_too_few_channels(self): + self.assertLayoutError(conn.ComplexConv1D(2, 3), (16, 1), "axis -1 has size 1") + self.assertLayoutError( + conn.ComplexConv2D(2, 3, data_format="channels_first"), + (1, 8, 8), + "axis 1 has size 1", + ) + + def test_conv_transposed_odd_channels(self): + layer = conn.ComplexConv2D(2, 3, strides=2, padding="same", transposed=True) + self.assertLayoutError(layer, (8, 8, 3), "axis -1 has size 3") + + def test_dense(self): + self.assertLayoutError(conn.ComplexDense(4), (5,), "axis -1 has size 5") + self.assertLayoutError(conn.ComplexDense(4), (1,), "axis -1 has size 1") + + def test_dense_undefined_size(self): + self.assertLayoutError( + conn.ComplexDense(4), (None,), "the size of axis -1 is not defined" + ) + + def test_batch_normalization(self): + self.assertLayoutError(conn.ComplexBN(), (5,), "axis -1 has size 5") + self.assertLayoutError(conn.ComplexBN(), (8, 8, 1), "axis -1 has size 1") + self.assertLayoutError( + conn.ComplexBN(axis=1), (3, 8, 8), "axis 1 has size 3", "`axis` argument" + ) + + def test_layer_normalization(self): + self.assertLayoutError(conn.ComplexLayerNorm(), (3,), "axis -1 has size 3") + self.assertLayoutError(conn.ComplexLayerNorm(), (8, 1), "axis -1 has size 1") + + def test_get_parts(self): + for layer_class in (conn.GetReal, conn.GetImag, conn.GetAbs): + with self.subTest(layer=layer_class.__name__): + self.assertLayoutError(layer_class(), (3,), "axis 1 has size 3") + self.assertLayoutError(layer_class(), (8, 5), "axis -1 has size 5") + + def test_get_parts_undefined_size(self): + """An undefined size is left to the dynamic split in call""" + for layer_class in (conn.GetReal, conn.GetImag, conn.GetAbs): + with self.subTest(layer=layer_class.__name__): + layer = layer_class() + layer.build((None, None, None)) + self.assertTrue(layer.built) + + +class TestValidComplexAxis(unittest.TestCase): + """The smallest valid layout, one complex channel, still builds""" + + def test_layers_build_with_two_channels(self): + cases = [ + (conn.ComplexConv1D(2, 3), (16, 2), (None, 14, 4)), + (conn.ComplexConv2D(2, 3), (8, 8, 2), (None, 6, 6, 4)), + (conn.ComplexDense(3), (2,), (None, 6)), + (conn.ComplexBN(), (8, 2), (None, 8, 2)), + (conn.ComplexLayerNorm(), (8, 2), (None, 8, 2)), + (conn.GetReal(), (8, 2), (None, 8, 1)), + (conn.GetImag(), (8, 2), (None, 8, 1)), + (conn.GetAbs(), (8, 2), (None, 8, 1)), + ] + for layer, shape, expected in cases: + with self.subTest(layer=type(layer).__name__): + outputs = layer(Input(shape=shape)) + self.assertEqual(expected, tuple(outputs.shape)) + + +if __name__ == "__main__": + unittest.main() From a84d4cc57a49b637ad32c791fc7aa0d0859b7116 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 16:45:47 +0200 Subject: [PATCH 03/15] build(docs): build with myst-parser and install the package on RTD The latest Read the Docs build (June 2024) failed with "No module named 'recommonmark'": conf.py loads recommonmark, but docs/requirements.txt never listed it, nor sphinx-rtd-theme. Even with both installed, autodoc cannot import complexnn because the package is not installed, so the live site still serves a 2022 build with empty API pages. - docs/requirements.txt: use myst-parser, which replaces the deprecated recommonmark, add sphinx-rtd-theme, which conf.py sets as html_theme, and drop tensorflow, which now comes in as a dependency of the package itself. - conf.py: load myst_parser instead of recommonmark and drop html_static_path, which points at a _static directory that does not exist and makes Sphinx warn. - .readthedocs.yml: move from ubuntu-20.04, which is end of life, to ubuntu-24.04 with Python 3.12, and install the package before the docs requirements so autodoc can import it. - .gitignore: ignore docs/build and the API pages that sphinxcontrib-apidoc writes to docs/source/api on every build. --- .gitignore | 6 +++++- .readthedocs.yml | 9 ++++++--- docs/requirements.txt | 7 ++++--- docs/source/conf.py | 7 +------ 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index ab0947e..9974d21 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,8 @@ venv dist/* -__pycache__/* \ No newline at end of file +__pycache__/* + +# Sphinx output and the API pages sphinxcontrib-apidoc generates +docs/build/ +docs/source/api/ diff --git a/.readthedocs.yml b/.readthedocs.yml index b71a10c..e6410fd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,9 +7,9 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-20.04 + os: ubuntu-24.04 tools: - python: "3.10" + python: "3.12" # You can also specify other tool versions: # nodejs: "16" # rust: "1.55" @@ -23,6 +23,9 @@ sphinx: formats: - pdf +# Install the package so autodoc can import it, then the Sphinx extensions. python: install: - - requirements: docs/requirements.txt \ No newline at end of file + - method: pip + path: . + - requirements: docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt index 17c7bcb..189e345 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ -tensorflow -sphinx>=1.8 +myst-parser +sphinx +sphinx-rtd-theme sphinxcontrib-apidoc -sphinxcontrib-bibtex \ No newline at end of file +sphinxcontrib-bibtex diff --git a/docs/source/conf.py b/docs/source/conf.py index da654f1..a8ec889 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -30,7 +30,7 @@ extensions = [ "sphinxcontrib.apidoc", "sphinx.ext.autodoc", - "recommonmark", + "myst_parser", "sphinxcontrib.bibtex", ] @@ -57,10 +57,5 @@ # html_theme = "sphinx_rtd_theme" -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - # Bibtex configuration bibtex_bibfiles = ["bib.bib"] From 80baba5bdeac7865954d9b6e65fc5d3491549ceb Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 16:46:18 +0200 Subject: [PATCH 04/15] docs: fix the cite page heading and list the license page Two warnings that "sphinx-build -W" turns into errors: - myst-parser expects a document to start with a level 1 heading, and cite.md started at level 2. - license.rst was not in any toctree, so no page linked to it. Add it to the table of contents after the citation page. --- docs/source/cite.md | 2 +- docs/source/index.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/cite.md b/docs/source/cite.md index a4bd4d7..bc037c8 100644 --- a/docs/source/cite.md +++ b/docs/source/cite.md @@ -1,5 +1,5 @@ Citation --------- +======== Find the CITATION file called CITATION.cff on Github or cite this software version as: diff --git a/docs/source/index.rst b/docs/source/index.rst index 216302b..ed8836e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,6 +20,7 @@ Contents Contributing math cite + License Indices and tables ================== From 970aab002b1698e308c62fc8a8107dee5bf82c88 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 16:49:59 +0200 Subject: [PATCH 05/15] docs: write docstring sections in the Google style napoleon reads The docstrings used the Keras 2 convention of "# Arguments" headings followed by an indented block. docutils reads those as a paragraph and an unexpected indent, which gave 40 errors and warnings in the API pages under "sphinx-build -W". Turn each "# Section" heading into "Section:" after a blank line, and enable sphinx.ext.napoleon, which renders Arguments, Returns, Raises and References as field lists. "Input shape" and "Output shape" are registered as custom sections. Only the heading lines change; the text of each section stays as it was. Also set autodoc_inherit_docstrings to False. Methods without their own docstring otherwise show the one from the Keras base class, which differs between Keras versions, and the one WeightNorm_Conv.call inherits from TensorFlow's private Keras copy is not valid reStructuredText. --- complexnn/bn.py | 12 ++++++++---- complexnn/conv.py | 27 ++++++++++++++++++--------- complexnn/dense.py | 9 ++++++--- complexnn/utils.py | 12 +++++++----- docs/source/conf.py | 9 +++++++++ 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/complexnn/bn.py b/complexnn/bn.py index 79cd641..e16cf9b 100644 --- a/complexnn/bn.py +++ b/complexnn/bn.py @@ -264,7 +264,8 @@ class ComplexBatchNormalization(Layer): close to the null vector, the 2 by 2 covariance matrix of a complex unit close to identity and the 2 by 2 relation matrix, also called pseudo-covariance, close to the null matrix. - # Arguments + + Arguments: axis: Integer, the axis that should be normalized (typically the features axis). For instance, after a `Conv2D` layer with @@ -291,13 +292,16 @@ class ComplexBatchNormalization(Layer): gamma_regularizer: Optional regularizer for the gamma weights. beta_constraint: Optional constraint for the beta weights. gamma_constraint: Optional constraint for the gamma weights. - # Input shape + + Input shape: Arbitrary. Use the keyword argument `input_shape` (tuple of integers, does not include the samples axis) when using this layer as the first layer in a model. - # Output shape + + Output shape: Same shape as input. - # References + + References: - [Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift](https://arxiv.org/abs/1502.03167) """ diff --git a/complexnn/conv.py b/complexnn/conv.py index 16cce1c..1cc60a8 100644 --- a/complexnn/conv.py +++ b/complexnn/conv.py @@ -663,7 +663,8 @@ class ComplexConv1D(ComplexConv): (tuple of integers or `None`, e.g. `(10, 128)` for sequences of 10 vectors of 128-dimensional vectors, or `(None, 128)` for variable-length sequences of 128-dimensional vectors. - # Arguments + + Arguments: filters: Integer, the dimensionality of the output space, i.e, the number of complex feature maps. It is also the effective number of feature maps for each of the real and imaginary parts. @@ -721,9 +722,11 @@ class ComplexConv1D(ComplexConv): spectral_parametrization: Whether or not to use a spectral parametrization of the parameters. transposed: Boolean, whether or not to use transposed convolution - # Input shape + + Input shape: 3D tensor with shape: `(batch_size, steps, input_dim)` - # Output shape + + Output shape: 3D tensor with shape: `(batch_size, new_steps, 2 x filters)` `steps` value might have changed due to padding or strides. """ @@ -792,7 +795,8 @@ class ComplexConv2D(ComplexConv): (tuple of integers, does not include the sample axis), e.g. `input_shape=(128, 128, 3)` for 128x128 RGB pictures in `data_format="channels_last"`. - # Arguments + + Arguments: filters: Integer, the dimensionality of the complex output space (i.e, the number complex feature maps in the convolution). The total effective number of filters or feature maps is 2 x filters. @@ -860,12 +864,14 @@ class ComplexConv2D(ComplexConv): spectral_parametrization: Whether or not to use a spectral parametrization of the parameters. transposed: Boolean, whether or not to use transposed convolution - # Input shape + + Input shape: 4D tensor with shape: `(samples, channels, rows, cols)` if data_format='channels_first' or 4D tensor with shape: `(samples, rows, cols, channels)` if data_format='channels_last'. - # Output shape + + Output shape: 4D tensor with shape: `(samples, 2 x filters, new_rows, new_cols)` if data_format='channels_first' or 4D tensor with shape: @@ -940,7 +946,8 @@ class ComplexConv3D(ComplexConv): e.g. `input_shape=(2, 128, 128, 128, 3)` for 128x128x128 volumes with 3 channels, in `data_format="channels_last"`. - # Arguments + + Arguments: filters: Integer, the dimensionality of the complex output space (i.e, the number complex feature maps in the convolution). The total effective number of filters or feature maps is 2 x filters. @@ -1007,14 +1014,16 @@ class ComplexConv3D(ComplexConv): spectral_parametrization: Whether or not to use a spectral parametrization of the parameters. transposed: Boolean, whether or not to use transposed convolution - # Input shape + + Input shape: 5D tensor with shape: `(samples, channels, conv_dim1, conv_dim2, conv_dim3)` if data_format='channels_first' or 5D tensor with shape: `(samples, conv_dim1, conv_dim2, conv_dim3, channels)` if data_format='channels_last'. - # Output shape + + Output shape: 5D tensor with shape: `(samples, 2 x filters, new_conv_dim1, new_conv_dim2, new_conv_dim3)` if data_format='channels_first' diff --git a/complexnn/dense.py b/complexnn/dense.py index 38c9e50..4f4011d 100644 --- a/complexnn/dense.py +++ b/complexnn/dense.py @@ -22,7 +22,8 @@ class ComplexDense(Layer): (only applicable if `use_bias` is `True`). Note: if the input to the layer has a rank greater than 2, then AN ERROR MESSAGE IS PRINTED. - # Arguments + + Arguments: units: Positive integer, dimensionality of each of the real part and the imaginary part. It is actualy the number of complex units. activation: Activation function to use @@ -48,9 +49,11 @@ class ComplexDense(Layer): (see keras.constraints). bias_constraint: Constraint function applied to the bias vector (see keras.constraints). - # Input shape + + Input shape: a 2D input with shape `(batch_size, input_dim)`. - # Output shape + + Output shape: For a 2D input with shape `(batch_size, input_dim)`, the output would have shape `(batch_size, units)`. """ diff --git a/complexnn/utils.py b/complexnn/utils.py index 94cbc14..ebfc109 100644 --- a/complexnn/utils.py +++ b/complexnn/utils.py @@ -77,13 +77,13 @@ def _check_complex_axis(layer, input_shape, axis, hint=""): the imaginary parts. That axis must therefore have an even size of at least 2. - # Arguments + Arguments: layer: The layer doing the check, named in the error message. input_shape: Shape of the layer input, including the batch axis. axis: Integer, the axis that holds the real and imaginary parts. hint: Optional sentence added to the end of the error message. - # Raises + Raises: ValueError: If the size of `axis` is undefined, odd or smaller than 2. """ shape = tuple(input_shape) @@ -110,16 +110,18 @@ def _check_complex_axis(layer, input_shape, axis, hint=""): # this is the implementation copied from keras-2: def _compute_fans(shape, data_format="channels_last"): """Computes the number of input and output units for a weight shape. - # Arguments + Arguments: shape: Integer shape tuple. data_format: Image data format to use for convolution kernels. Note that all kernels in Keras are standardized on the `channels_last` ordering (even when inputs are set to `channels_first`). - # Returns + + Returns: A tuple of scalars, `(fan_in, fan_out)`. - # Raises + + Raises: ValueError: in case of invalid `data_format` argument. """ if len(shape) == 2: diff --git a/docs/source/conf.py b/docs/source/conf.py index a8ec889..b905d1a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -30,10 +30,19 @@ extensions = [ "sphinxcontrib.apidoc", "sphinx.ext.autodoc", + "sphinx.ext.napoleon", "myst_parser", "sphinxcontrib.bibtex", ] +# The docstrings use Google style sections, plus these Keras ones. +napoleon_custom_sections = ["Input shape", "Output shape"] + +# Methods without a docstring would otherwise show the docstring of the +# Keras base class. That text changes with the installed Keras version, and +# the one WeightNorm_Conv.call inherits is not valid reStructuredText. +autodoc_inherit_docstrings = False + master_doc = "index" # Needed by RTD apidoc_module_dir = "../../complexnn" From a025d1d28d6067043eb76afd2361d851a13c97f2 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 16:59:01 +0200 Subject: [PATCH 06/15] docs: document the real and imaginary channel layout The layers expect complex inputs as real tensors with the real parts in the first half of the channel axis and the imaginary parts in the second half, and they return outputs in the same layout. Little of this was written down: the README showed a 3x3 example with the parts as two stacked blocks, and the docs it linked to did not describe the layout at all. The reporter of #15 had put the parts on an axis of their own. - docs: add a "Data layout" page with an example, a NumPy recipe for both directions, the axis each layer uses and what the new ValueError means. - README: say in Usage what input_shape=(28, 28, 2) means and rewrite "Complex Format of Tensors" to match the code, linking the new page. - Docstrings: replace the odd channel counts in the ComplexConv2D and ComplexConv3D examples, which the layers now reject, note the layout in the conv and dense input and output shapes, correct the ComplexDense output shape to 2 x units, and correct the ComplexBatchNormalization hint for channels_first data from axis=2 to axis=1. Refs #15 --- README.md | 25 ++++++++++-------- complexnn/bn.py | 5 ++-- complexnn/conv.py | 30 ++++++++++++++-------- complexnn/dense.py | 7 +++-- docs/source/index.rst | 1 + docs/source/layout.md | 60 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 25 deletions(-) create mode 100644 docs/source/layout.md diff --git a/README.md b/README.md index eb32873..37c76b1 100644 --- a/README.md +++ b/README.md @@ -51,25 +51,30 @@ model.compile(optimizer=optimizers.Adam(), loss='mse') ``` +The layers take complex data as real tensors, with the real and imaginary parts stacked along the channel axis: the first half of the channels holds the real parts and the second half the imaginary parts. `input_shape=(28, 28, 2)` above is therefore a 28x28 image with one complex channel. The channel axis must have an even size of at least 2, and the layers raise a `ValueError` when it does not. [Complex Format of Tensors](#complex-format-of-tensors) below has the details. + An example working implementation of an autoencoder can be found [here](https://github.com/JesperDramsch/Complex-CNN-Seismic/). Complex Format of Tensors ------------------------- -This library assumes that complex values are split into two real-valued parts. The real-valued and complex-valued complement, also seen [in the Docs](https://keras-complex.readthedocs.io/math.html). +This library stores each complex value as two real values, its real part and its imaginary part. The two parts are stacked along the channel axis, all real parts first: -The tensors for a 2D complex tensor of 3x3, the look like: +- along the last axis with `data_format="channels_last"`, the default; +- along axis 1 with `data_format="channels_first"`. -``` -[[[r r r], - [r r r], - [r r r]], - [i,i,i], - [i,i,i], - [i,i,i]]] +For a batch of 3x3 images with two complex channels `z0` and `z1`, the input has the shape `(batch, 3, 3, 4)`, and its channels hold `[Re z0, Re z1, Im z0, Im z1]` in this order. The parts are not interleaved. + +To turn a complex NumPy array into this format: + +```python +import numpy as np + +z = np.random.randn(8, 3, 3, 2) + 1j * np.random.randn(8, 3, 3, 2) # two complex channels +x = np.concatenate([z.real, z.imag], axis=-1) # shape (8, 3, 3, 4) ``` -So multiple samples should then be arranged into `[r,r,r,i,i,i]`, which is also documented [in the Docs](https://keras-complex.readthedocs.io/math.html#implementation). +The complex layers return their outputs in the same format: `ComplexConv2D(filters=f)` returns `2 * f` channels, the real parts first. `complexnn.GetReal`, `complexnn.GetImag` and `complexnn.GetAbs` split such a tensor into its real parts, imaginary parts and magnitudes. The [data layout page in the docs](https://keras-complex.readthedocs.io/en/latest/layout.html) lists the axis each layer uses, and the [math page](https://keras-complex.readthedocs.io/en/latest/math.html) shows how the complex convolution is computed. Citation -------- diff --git a/complexnn/bn.py b/complexnn/bn.py index e16cf9b..30620d0 100644 --- a/complexnn/bn.py +++ b/complexnn/bn.py @@ -267,10 +267,11 @@ class ComplexBatchNormalization(Layer): Arguments: axis: Integer, the axis that should be normalized - (typically the features axis). + (typically the features axis). It holds the real parts + followed by the imaginary parts, so its size must be even. For instance, after a `Conv2D` layer with `data_format="channels_first"`, - set `axis=2` in `ComplexBatchNormalization`. + set `axis=1` in `ComplexBatchNormalization`. momentum: Momentum for the moving statistics related to the real and imaginary parts. epsilon: Small float added to each of the variances related to the diff --git a/complexnn/conv.py b/complexnn/conv.py index 1cc60a8..ab07205 100644 --- a/complexnn/conv.py +++ b/complexnn/conv.py @@ -661,8 +661,8 @@ class ComplexConv1D(ComplexConv): When using this layer as the first layer in a model, provide an `input_shape` argument (tuple of integers or `None`, e.g. - `(10, 128)` for sequences of 10 vectors of 128-dimensional vectors, - or `(None, 128)` for variable-length sequences of 128-dimensional vectors. + `(10, 128)` for sequences of 10 vectors of 64 complex values each, + or `(None, 128)` for variable-length sequences of such vectors. Arguments: filters: Integer, the dimensionality of the output space, i.e, @@ -724,10 +724,13 @@ class ComplexConv1D(ComplexConv): transposed: Boolean, whether or not to use transposed convolution Input shape: - 3D tensor with shape: `(batch_size, steps, input_dim)` + 3D tensor with shape: `(batch_size, steps, input_dim)`. The first + half of `input_dim` holds the real parts and the second half the + imaginary parts, so `input_dim` must be even. Output shape: - 3D tensor with shape: `(batch_size, new_steps, 2 x filters)` + 3D tensor with shape: `(batch_size, new_steps, 2 x filters)`, + with the real parts in the first `filters` channels. `steps` value might have changed due to padding or strides. """ @@ -793,8 +796,8 @@ class ComplexConv2D(ComplexConv): When using this layer as the first layer in a model, provide the keyword argument `input_shape` (tuple of integers, does not include the sample axis), - e.g. `input_shape=(128, 128, 3)` for 128x128 RGB pictures - in `data_format="channels_last"`. + e.g. `input_shape=(128, 128, 6)` for 128x128 images with 3 complex + channels in `data_format="channels_last"`. Arguments: filters: Integer, the dimensionality of the complex output space @@ -870,6 +873,8 @@ class ComplexConv2D(ComplexConv): `(samples, channels, rows, cols)` if data_format='channels_first' or 4D tensor with shape: `(samples, rows, cols, channels)` if data_format='channels_last'. + `channels` holds the real parts of the complex channels followed by + their imaginary parts, so it must be even. Output shape: 4D tensor with shape: @@ -877,7 +882,8 @@ class ComplexConv2D(ComplexConv): data_format='channels_first' or 4D tensor with shape: `(samples, new_rows, new_cols, 2 x filters)` if data_format='channels_last'. `rows` and `cols` values might have - changed due to padding. + changed due to padding. The first `filters` channels hold the real + parts and the last `filters` channels the imaginary parts. """ def __init__( @@ -943,9 +949,8 @@ class ComplexConv3D(ComplexConv): When using this layer as the first layer in a model, provide the keyword argument `input_shape` (tuple of integers, does not include the sample axis), - e.g. `input_shape=(2, 128, 128, 128, 3)` for 128x128x128 volumes - with 3 channels, - in `data_format="channels_last"`. + e.g. `input_shape=(128, 128, 128, 2)` for 128x128x128 volumes + with one complex channel, in `data_format="channels_last"`. Arguments: filters: Integer, the dimensionality of the complex output space @@ -1022,6 +1027,8 @@ class ComplexConv3D(ComplexConv): or 5D tensor with shape: `(samples, conv_dim1, conv_dim2, conv_dim3, channels)` if data_format='channels_last'. + `channels` holds the real parts of the complex channels followed by + their imaginary parts, so it must be even. Output shape: 5D tensor with shape: @@ -1031,7 +1038,8 @@ class ComplexConv3D(ComplexConv): `(samples, new_conv_dim1, new_conv_dim2, new_conv_dim3, 2 x filters)` if data_format='channels_last'. `new_conv_dim1`, `new_conv_dim2` and `new_conv_dim3` values might have - changed due to padding. + changed due to padding. The first `filters` channels hold the real + parts and the last `filters` channels the imaginary parts. """ def __init__( diff --git a/complexnn/dense.py b/complexnn/dense.py index 4f4011d..a631b85 100644 --- a/complexnn/dense.py +++ b/complexnn/dense.py @@ -51,11 +51,14 @@ class ComplexDense(Layer): (see keras.constraints). Input shape: - a 2D input with shape `(batch_size, input_dim)`. + a 2D input with shape `(batch_size, input_dim)`. The first half of + `input_dim` holds the real parts and the second half the imaginary + parts, so `input_dim` must be even. Output shape: For a 2D input with shape `(batch_size, input_dim)`, - the output would have shape `(batch_size, units)`. + the output would have shape `(batch_size, 2 x units)`, + with the real parts in the first `units` features. """ def __init__( diff --git a/docs/source/index.rst b/docs/source/index.rst index ed8836e..6fb07dc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -16,6 +16,7 @@ Contents Introduction Installation + Data layout API Contributing math diff --git a/docs/source/layout.md b/docs/source/layout.md new file mode 100644 index 0000000..2329f61 --- /dev/null +++ b/docs/source/layout.md @@ -0,0 +1,60 @@ +# Data layout + +The layers in keras-complex work on real-valued tensors. Each complex number is stored as two real numbers, its real part and its imaginary part, and the two parts are stacked along one axis, usually the channel axis: + +- the first half of that axis holds the real parts, +- the second half holds the imaginary parts. + +An input with `n` complex channels therefore has `2n` channels, and that axis must have an even size of at least 2. + +## Example + +A batch of 3x3 images with two complex channels, `z0` and `z1`, has the shape `(batch, 3, 3, 4)` with `data_format="channels_last"`. Its four channels hold, in this order: + +| Channel | 0 | 1 | 2 | 3 | +|---|---|---|---|---| +| Content | real part of `z0` | real part of `z1` | imaginary part of `z0` | imaginary part of `z1` | + +The parts are not interleaved. Channels ordered as `[Re z0, Im z0, Re z1, Im z1]` would be read as two other complex channels, `Re z0 + i Re z1` and `Im z0 + i Im z1`. + +## Converting complex data + +Concatenate the real and imaginary parts of a complex NumPy array along the channel axis: + +```python +import numpy as np + +# 8 images of 28x28 pixels with 2 complex channels each. +z = np.random.randn(8, 28, 28, 2) + 1j * np.random.randn(8, 28, 28, 2) + +x = np.concatenate([z.real, z.imag], axis=-1) # shape (8, 28, 28, 4) +``` + +With `data_format="channels_first"`, concatenate along axis 1 instead. + +The complex layers return their outputs in the same layout. A `ComplexConv2D` with `filters=f` returns `2 * f` channels, the real parts of its `f` complex feature maps followed by their imaginary parts, and a `ComplexDense` with `units=u` returns `2 * u` features. The layers can therefore be stacked directly. Keras layers that treat each channel on its own, such as activations and pooling, act on the real and imaginary parts separately. + +To turn an output back into complex numbers, split its channel axis in half: + +```python +y = model.predict(x) +half = y.shape[-1] // 2 +z_out = y[..., :half] + 1j * y[..., half:] +``` + +Inside a model, `GetReal`, `GetImag` and `GetAbs` return the real parts, the imaginary parts and the magnitudes. + +## The axis each layer uses + +| Layer | Axis that holds the real and imaginary parts | +|---|---| +| `ComplexConv1D`, `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use the Keras image data format, which is `channels_last` unless configured otherwise. | +| `ComplexDense` | The last axis of its 2D input `(batch_size, features)`. | +| `ComplexBatchNormalization`, `ComplexLayerNorm` | The axis given by their `axis` argument, the last axis by default. | +| `GetReal`, `GetImag`, `GetAbs` | The last axis, except for 4D and 5D inputs when the Keras image data format is `channels_first`, where they use axis 1. | + +## Errors + +A layer that is built on an input whose complex axis has an odd size, or a size smaller than 2, raises a `ValueError` naming the axis, its size and this layout. + +A common cause is keeping the real and imaginary parts on an axis of their own. For example, a `ComplexConv3D` with `data_format="channels_first"` cannot take inputs of shape `(1, 32, 32, 2)` that hold one channel with the two parts on the last axis. Move the parts to the channel axis, so that the inputs have the shape `(2, 32, 32, 1)`. From dc6dcc070738fdc48bbd5e96be3a388dc34c29fe Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 16:59:02 +0200 Subject: [PATCH 07/15] test: check the layers against the documented data layout Build inputs with the NumPy recipe from the README, real parts first, and check that: - GetReal, GetImag and GetAbs return the real parts, the imaginary parts and the magnitudes; - a 1x1 ComplexConv2D multiplies each pixel by the complex matrix held in its kernel; - ComplexConv1D, ComplexConv2D, ComplexConv3D, transposed ComplexConv2D and ComplexDense are complex linear: multiplying the inputs by i multiplies the outputs by i, which only holds when inputs and outputs both use the documented layout. With an interleaved layout instead, every one of these checks fails. --- tests/test_layout.py | 70 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/test_layout.py diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..75b1d16 --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,70 @@ +import unittest + +import numpy as np + +import complexnn as conn + + +def to_layout(z): + """Stack the real parts, then the imaginary parts, as the README describes.""" + return np.concatenate([z.real, z.imag], axis=-1).astype("float32") + + +def from_layout(x): + """Turn a tensor in that layout back into complex numbers.""" + real, imag = np.split(np.asarray(x), 2, axis=-1) + return real + 1j * imag + + +class TestDataLayout(unittest.TestCase): + """The layers read and write the layout that the README and the docs describe""" + + def setUp(self): + self.rng = np.random.default_rng(15) + + def random_complex(self, shape): + return self.rng.normal(size=shape) + 1j * self.rng.normal(size=shape) + + def test_get_parts(self): + z = self.random_complex((4, 6, 6, 3)) + x = to_layout(z) + np.testing.assert_allclose(np.asarray(conn.GetReal()(x)), z.real, rtol=1e-6) + np.testing.assert_allclose(np.asarray(conn.GetImag()(x)), z.imag, rtol=1e-6) + np.testing.assert_allclose(np.asarray(conn.GetAbs()(x)), np.abs(z), rtol=1e-5) + + def test_conv_multiplies_by_its_complex_kernel(self): + """A 1x1 complex convolution multiplies each pixel by a complex matrix""" + z = self.random_complex((4, 6, 6, 3)) + layer = conn.ComplexConv2D(filters=2, kernel_size=1, use_bias=False) + outputs = from_layout(layer(to_layout(z))) + # The kernel holds the real parts of the complex kernel in its first + # `filters` output columns and the imaginary parts in the rest. + kernel = np.asarray(layer.kernel)[0, 0] + weights = kernel[:, :2] + 1j * kernel[:, 2:] + np.testing.assert_allclose(outputs, z @ weights, rtol=1e-4, atol=1e-5) + + def test_layers_are_complex_linear(self): + """Multiplying the inputs by i multiplies the outputs by i""" + cases = [ + ("Conv1D", conn.ComplexConv1D(2, 3, use_bias=False), (4, 8, 3)), + ("Conv2D", conn.ComplexConv2D(2, 3, use_bias=False), (4, 6, 6, 3)), + ("Conv3D", conn.ComplexConv3D(2, 3, use_bias=False), (2, 5, 5, 5, 1)), + ( + "Conv2D transposed", + conn.ComplexConv2D( + 2, 3, strides=2, padding="same", use_bias=False, transposed=True + ), + (4, 4, 4, 3), + ), + ("Dense", conn.ComplexDense(5, use_bias=False), (4, 3)), + ] + for name, layer, shape in cases: + with self.subTest(layer=name): + z = self.random_complex(shape) + outputs = from_layout(layer(to_layout(z))) + rotated = from_layout(layer(to_layout(1j * z))) + np.testing.assert_allclose(rotated, 1j * outputs, rtol=1e-4, atol=1e-5) + + +if __name__ == "__main__": + unittest.main() From a4bb5ecc71fad5fdbac21d22eb773e21cacdb8d2 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 17:02:07 +0200 Subject: [PATCH 08/15] ci: build the documentation with warnings as errors The Read the Docs build has failed since June 2024, and no check on GitHub showed it, because nothing else built the documentation. Add a docs job that installs the package and docs/requirements.txt, as .readthedocs.yml does, and runs sphinx-build with -W, so a change that breaks the docs or adds a Sphinx warning fails on the pull request. --keep-going reports every warning in one run. fetch-depth 0 gives setuptools-scm the tags it needs to compute the version. --- .github/workflows/test_python.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/test_python.yml b/.github/workflows/test_python.yml index 03fafcd..bc793c3 100644 --- a/.github/workflows/test_python.yml +++ b/.github/workflows/test_python.yml @@ -37,6 +37,25 @@ jobs: python -m pip install dist/*.whl python -c "import complexnn" + # Build the documentation the way Read the Docs does: install the package + # and the docs requirements. -W turns every Sphinx warning into an error. + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v10.3.0 + with: + python-version: "3.12" + - name: Build the documentation + env: + TF_CPP_MIN_LOG_LEVEL: "3" + run: >- + uv run --no-project + --with-requirements docs/requirements.txt --with-editable . + sphinx-build -W --keep-going -b html docs/source docs/build/html + test: name: ${{ matrix.name }} runs-on: ubuntu-latest From cc13ae2fb3f798b888e317fa2f7bae331b017902 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 17:32:26 +0200 Subject: [PATCH 09/15] docs(layout): give ComplexConv1D its own row in the axis table The table grouped ComplexConv1D with ComplexConv2D and ComplexConv3D and said it uses axis 1 with data_format="channels_first" and follows the Keras image data format otherwise. Neither holds: ComplexConv1D has no data_format argument (passing one raises a TypeError) and always passes data_format="channels_last" to ComplexConv, so its complex axis is always the last axis. Give ComplexConv1D a row of its own, and say that the channels_first recipe in "Converting complex data" applies to ComplexConv2D and ComplexConv3D only. --- docs/source/layout.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/layout.md b/docs/source/layout.md index 2329f61..50ea099 100644 --- a/docs/source/layout.md +++ b/docs/source/layout.md @@ -30,7 +30,7 @@ z = np.random.randn(8, 28, 28, 2) + 1j * np.random.randn(8, 28, 28, 2) x = np.concatenate([z.real, z.imag], axis=-1) # shape (8, 28, 28, 4) ``` -With `data_format="channels_first"`, concatenate along axis 1 instead. +For `ComplexConv2D` and `ComplexConv3D` with `data_format="channels_first"`, concatenate along axis 1 instead. The complex layers return their outputs in the same layout. A `ComplexConv2D` with `filters=f` returns `2 * f` channels, the real parts of its `f` complex feature maps followed by their imaginary parts, and a `ComplexDense` with `units=u` returns `2 * u` features. The layers can therefore be stacked directly. Keras layers that treat each channel on its own, such as activations and pooling, act on the real and imaginary parts separately. @@ -48,7 +48,8 @@ Inside a model, `GetReal`, `GetImag` and `GetAbs` return the real parts, the ima | Layer | Axis that holds the real and imaginary parts | |---|---| -| `ComplexConv1D`, `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use the Keras image data format, which is `channels_last` unless configured otherwise. | +| `ComplexConv1D` | Always the last axis. It has no `data_format` argument and ignores the Keras image data format. | +| `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use the Keras image data format, which is `channels_last` unless configured otherwise. | | `ComplexDense` | The last axis of its 2D input `(batch_size, features)`. | | `ComplexBatchNormalization`, `ComplexLayerNorm` | The axis given by their `axis` argument, the last axis by default. | | `GetReal`, `GetImag`, `GetAbs` | The last axis, except for 4D and 5D inputs when the Keras image data format is `channels_first`, where they use axis 1. | From 68fa1815b97f7936b68e5a3515038ded228924e7 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 17:33:16 +0200 Subject: [PATCH 10/15] docs(layout): say the convolutions read keras.json, not the runtime format The axis table said that ComplexConv2D and ComplexConv3D use the Keras image data format when data_format is not given, the same phrase it used for GetReal, GetImag and GetAbs. The two do not read the same setting. ComplexConv resolves data_format=None with conv_utils from TensorFlow's internal Keras copy, which reads keras.json at import but not a later keras.backend.set_image_data_format() call. GetReal, GetImag and GetAbs call tensorflow.keras.backend.image_data_format() and do follow that call. On Keras 3, Keras 2 and tf-keras alike, after set_image_data_format("channels_first") a default ComplexConv2D still uses channels_last. Say so in the table, and advise passing data_format to the convolutions explicitly when the format is changed at runtime. The code is unchanged: making the convolutions follow the runtime setting is a behaviour change for a follow-up. --- docs/source/layout.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/source/layout.md b/docs/source/layout.md index 50ea099..ab23067 100644 --- a/docs/source/layout.md +++ b/docs/source/layout.md @@ -49,10 +49,12 @@ Inside a model, `GetReal`, `GetImag` and `GetAbs` return the real parts, the ima | Layer | Axis that holds the real and imaginary parts | |---|---| | `ComplexConv1D` | Always the last axis. It has no `data_format` argument and ignores the Keras image data format. | -| `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use the Keras image data format, which is `channels_last` unless configured otherwise. | +| `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use `image_data_format` from the Keras config file, `~/.keras/keras.json`, which is `channels_last` if it is not set. Changing the image data format at runtime does not change them. | | `ComplexDense` | The last axis of its 2D input `(batch_size, features)`. | | `ComplexBatchNormalization`, `ComplexLayerNorm` | The axis given by their `axis` argument, the last axis by default. | -| `GetReal`, `GetImag`, `GetAbs` | The last axis, except for 4D and 5D inputs when the Keras image data format is `channels_first`, where they use axis 1. | +| `GetReal`, `GetImag`, `GetAbs` | The last axis, except for 4D and 5D inputs when `keras.backend.image_data_format()` returns `channels_first`, where they use axis 1. Unlike the convolutions, they follow a change of the image data format at runtime. | + +If your code changes the image data format at runtime, with `keras.backend.set_image_data_format()` or, in Keras 3, `keras.config.set_image_data_format()`, pass `data_format` to `ComplexConv2D` and `ComplexConv3D` explicitly, so that they use the same channel axis as `GetReal`, `GetImag` and `GetAbs`. ## Errors From 39bc6fc81365b2b2a0a8e159f9976fd49f8d6ac8 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 17:39:32 +0200 Subject: [PATCH 11/15] fix(bn,norm): name the axis settings the normalisation layers support The new layout error for ComplexBatchNormalization and ComplexLayerNorm told users to "use the `axis` argument to choose the axis", and the axis table said they use whatever axis that argument names. call() only handles two settings: axis=-1, and axis=1 for inputs of rank 2, 4 or 5. Any other value either raises "axis should be either 1 or -1" or splits the last axis anyway. With axis=1 on a rank 3 input, the build check passes and call() then fails with an opaque InvalidArgumentError. Name the two supported settings in the error hint, in the axis table and in the ComplexBatchNormalization docstring. Test the hint for both layers, and test that every documented setting runs on inputs of rank 2 to 5. Rejecting the unsupported combinations at build time would change behaviour, so it is left for a follow-up. --- complexnn/bn.py | 6 ++++-- complexnn/norm.py | 4 ++-- docs/source/layout.md | 2 +- tests/test_validation.py | 35 +++++++++++++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/complexnn/bn.py b/complexnn/bn.py index 30620d0..8e19ad9 100644 --- a/complexnn/bn.py +++ b/complexnn/bn.py @@ -272,6 +272,8 @@ class ComplexBatchNormalization(Layer): For instance, after a `Conv2D` layer with `data_format="channels_first"`, set `axis=1` in `ComplexBatchNormalization`. + Only `axis=-1` and, for inputs of rank 2, 4 or 5, + `axis=1` are supported. momentum: Momentum for the moving statistics related to the real and imaginary parts. epsilon: Small float added to each of the variances related to the @@ -363,8 +365,8 @@ def build(self, input_shape): self, input_shape, self.axis, - hint="Use the `axis` argument to choose the axis that holds the real " - "and imaginary parts.", + hint="Use `axis=-1` when the parts are on the last axis, or `axis=1` " + "when they are on axis 1 of an input of rank 2, 4 or 5.", ) self.input_spec = InputSpec(ndim=len(input_shape), axes={self.axis: dim}) diff --git a/complexnn/norm.py b/complexnn/norm.py index 7a1c473..83315d0 100644 --- a/complexnn/norm.py +++ b/complexnn/norm.py @@ -154,8 +154,8 @@ def build(self, input_shape): self, input_shape, self.axis, - hint="Use the `axis` argument to choose the axis that holds the real " - "and imaginary parts.", + hint="Use `axis=-1` when the parts are on the last axis, or `axis=1` " + "when they are on axis 1 of an input of rank 2, 4 or 5.", ) self.input_spec = InputSpec(ndim=len(input_shape), axes={self.axis: dim}) diff --git a/docs/source/layout.md b/docs/source/layout.md index ab23067..0f613af 100644 --- a/docs/source/layout.md +++ b/docs/source/layout.md @@ -51,7 +51,7 @@ Inside a model, `GetReal`, `GetImag` and `GetAbs` return the real parts, the ima | `ComplexConv1D` | Always the last axis. It has no `data_format` argument and ignores the Keras image data format. | | `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use `image_data_format` from the Keras config file, `~/.keras/keras.json`, which is `channels_last` if it is not set. Changing the image data format at runtime does not change them. | | `ComplexDense` | The last axis of its 2D input `(batch_size, features)`. | -| `ComplexBatchNormalization`, `ComplexLayerNorm` | The axis given by their `axis` argument, the last axis by default. | +| `ComplexBatchNormalization`, `ComplexLayerNorm` | The axis given by their `axis` argument. Two settings are supported: `axis=-1`, the default, for the last axis, and `axis=1` for inputs of rank 2, 4 or 5, such as `channels_first` images of shape `(batch, channels, rows, cols)`. | | `GetReal`, `GetImag`, `GetAbs` | The last axis, except for 4D and 5D inputs when `keras.backend.image_data_format()` returns `channels_first`, where they use axis 1. Unlike the convolutions, they follow a change of the image data format at runtime. | If your code changes the image data format at runtime, with `keras.backend.set_image_data_format()` or, in Keras 3, `keras.config.set_image_data_format()`, pass `data_format` to `ComplexConv2D` and `ComplexConv3D` explicitly, so that they use the same channel axis as `GetReal`, `GetImag` and `GetAbs`. diff --git a/tests/test_validation.py b/tests/test_validation.py index c801278..3461359 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,8 +1,10 @@ import unittest -import complexnn as conn +import numpy as np from tensorflow.keras.layers import Input +import complexnn as conn + # Part of every error message about the real and imaginary layout. LAYOUT_ERROR = "must be even and at least 2" @@ -79,12 +81,21 @@ def test_batch_normalization(self): self.assertLayoutError(conn.ComplexBN(), (5,), "axis -1 has size 5") self.assertLayoutError(conn.ComplexBN(), (8, 8, 1), "axis -1 has size 1") self.assertLayoutError( - conn.ComplexBN(axis=1), (3, 8, 8), "axis 1 has size 3", "`axis` argument" + conn.ComplexBN(axis=1), + (3, 8, 8), + "axis 1 has size 3", + "`axis=1` when they are on axis 1 of an input of rank 2, 4 or 5", ) def test_layer_normalization(self): self.assertLayoutError(conn.ComplexLayerNorm(), (3,), "axis -1 has size 3") self.assertLayoutError(conn.ComplexLayerNorm(), (8, 1), "axis -1 has size 1") + self.assertLayoutError( + conn.ComplexLayerNorm(axis=1), + (3, 8, 8), + "axis 1 has size 3", + "`axis=1` when they are on axis 1 of an input of rank 2, 4 or 5", + ) def test_get_parts(self): for layer_class in (conn.GetReal, conn.GetImag, conn.GetAbs): @@ -120,6 +131,26 @@ def test_layers_build_with_two_channels(self): outputs = layer(Input(shape=shape)) self.assertEqual(expected, tuple(outputs.shape)) + def test_normalisation_supported_axes(self): + """The `axis` settings that the docs list for the normalisation layers run""" + rng = np.random.default_rng(15) + cases = [ + (-1, (4, 6)), + (-1, (4, 5, 6)), + (-1, (4, 3, 3, 6)), + (-1, (4, 3, 3, 3, 6)), + (1, (4, 6)), + (1, (4, 6, 3, 3)), + (1, (4, 6, 3, 3, 3)), + ] + for layer_class in (conn.ComplexBN, conn.ComplexLayerNorm): + for axis, shape in cases: + with self.subTest(layer=layer_class.__name__, axis=axis, shape=shape): + x = rng.normal(size=shape).astype("float32") + outputs = np.asarray(layer_class(axis=axis)(x, training=True)) + self.assertEqual(shape, outputs.shape) + self.assertTrue(np.all(np.isfinite(outputs))) + if __name__ == "__main__": unittest.main() From 6289c1c9623d129e09d8bb56ff432ebbe1b48778 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 17:39:56 +0200 Subject: [PATCH 12/15] docs(readme): link to the docs pages at the root of Read the Docs The rewritten "Complex Format of Tensors" section linked to /en/latest/layout.html and /en/latest/math.html. keras-complex on Read the Docs is a single-version project that serves its pages at the root, so both links return 404, while /math.html, the link the README used before, returns 200. The layout page will also be served at the root once Read the Docs builds it. Use the root URLs, as the badge does, and keep the #implementation anchor the old math link had. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 37c76b1..7600721 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ z = np.random.randn(8, 3, 3, 2) + 1j * np.random.randn(8, 3, 3, 2) # two comple x = np.concatenate([z.real, z.imag], axis=-1) # shape (8, 3, 3, 4) ``` -The complex layers return their outputs in the same format: `ComplexConv2D(filters=f)` returns `2 * f` channels, the real parts first. `complexnn.GetReal`, `complexnn.GetImag` and `complexnn.GetAbs` split such a tensor into its real parts, imaginary parts and magnitudes. The [data layout page in the docs](https://keras-complex.readthedocs.io/en/latest/layout.html) lists the axis each layer uses, and the [math page](https://keras-complex.readthedocs.io/en/latest/math.html) shows how the complex convolution is computed. +The complex layers return their outputs in the same format: `ComplexConv2D(filters=f)` returns `2 * f` channels, the real parts first. `complexnn.GetReal`, `complexnn.GetImag` and `complexnn.GetAbs` split such a tensor into its real parts, imaginary parts and magnitudes. The [data layout page in the docs](https://keras-complex.readthedocs.io/layout.html) lists the axis each layer uses, and the [math page](https://keras-complex.readthedocs.io/math.html#implementation) shows how the complex convolution is computed. Citation -------- From 571d5b3b7155261c026194734415a182c3f7e3fd Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 17:42:14 +0200 Subject: [PATCH 13/15] test: cover zero-size complex axes in the validation tests Every "too small" case used size 1, which the odd-size condition already rejects, so no test reached the separate "at least 2" condition: without it, tests/test_validation.py still passed on Keras 3 and Keras 2. The condition matters. On main, a complex axis of size 0 built without an error for: - ComplexConv1D, ComplexConv2D and ComplexConv3D on Keras 3; - transposed ComplexConv2D on Keras 3, Keras 2 and tf-keras; - ComplexBatchNormalization and ComplexLayerNorm; - GetReal, GetImag and GetAbs. ComplexDense raised a ZeroDivisionError. Add size 0 cases for every validated layer, and size 1 cases for transposed convolution and GetReal, GetImag and GetAbs, which only had odd sizes of 3 and 5. With the "at least 2" condition removed, six test methods now fail on both Keras versions, eight failures counting subtests. --- tests/test_validation.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index 3461359..da8a5ab 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -64,13 +64,33 @@ def test_conv_too_few_channels(self): "axis 1 has size 1", ) - def test_conv_transposed_odd_channels(self): - layer = conn.ComplexConv2D(2, 3, strides=2, padding="same", transposed=True) - self.assertLayoutError(layer, (8, 8, 3), "axis -1 has size 3") + def test_conv_zero_channels(self): + """A size of 0 is even, so only the "at least 2" condition rejects it""" + self.assertLayoutError(conn.ComplexConv1D(2, 3), (16, 0), "axis -1 has size 0") + self.assertLayoutError( + conn.ComplexConv2D(2, 3), (8, 8, 0), "axis -1 has size 0" + ) + self.assertLayoutError( + conn.ComplexConv3D(2, 3), (8, 8, 8, 0), "axis -1 has size 0" + ) + self.assertLayoutError( + conn.ComplexConv2D(2, 3, data_format="channels_first"), + (0, 8, 8), + "axis 1 has size 0", + ) + + def test_conv_transposed(self): + for size in (3, 1, 0): + with self.subTest(size=size): + layer = conn.ComplexConv2D( + 2, 3, strides=2, padding="same", transposed=True + ) + self.assertLayoutError(layer, (8, 8, size), f"axis -1 has size {size}") def test_dense(self): self.assertLayoutError(conn.ComplexDense(4), (5,), "axis -1 has size 5") self.assertLayoutError(conn.ComplexDense(4), (1,), "axis -1 has size 1") + self.assertLayoutError(conn.ComplexDense(4), (0,), "axis -1 has size 0") def test_dense_undefined_size(self): self.assertLayoutError( @@ -80,6 +100,7 @@ def test_dense_undefined_size(self): def test_batch_normalization(self): self.assertLayoutError(conn.ComplexBN(), (5,), "axis -1 has size 5") self.assertLayoutError(conn.ComplexBN(), (8, 8, 1), "axis -1 has size 1") + self.assertLayoutError(conn.ComplexBN(), (8, 0), "axis -1 has size 0") self.assertLayoutError( conn.ComplexBN(axis=1), (3, 8, 8), @@ -90,6 +111,7 @@ def test_batch_normalization(self): def test_layer_normalization(self): self.assertLayoutError(conn.ComplexLayerNorm(), (3,), "axis -1 has size 3") self.assertLayoutError(conn.ComplexLayerNorm(), (8, 1), "axis -1 has size 1") + self.assertLayoutError(conn.ComplexLayerNorm(), (8, 0), "axis -1 has size 0") self.assertLayoutError( conn.ComplexLayerNorm(axis=1), (3, 8, 8), @@ -102,6 +124,8 @@ def test_get_parts(self): with self.subTest(layer=layer_class.__name__): self.assertLayoutError(layer_class(), (3,), "axis 1 has size 3") self.assertLayoutError(layer_class(), (8, 5), "axis -1 has size 5") + self.assertLayoutError(layer_class(), (1,), "axis 1 has size 1") + self.assertLayoutError(layer_class(), (8, 0), "axis -1 has size 0") def test_get_parts_undefined_size(self): """An undefined size is left to the dynamic split in call""" From 25e1e9d9aedc8b16c1460b1bf3baeff311441791 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 19:51:06 +0200 Subject: [PATCH 14/15] fix(utils): check every input of a reused GetReal, GetImag or GetAbs These layers have no weights, so one instance is often reused, but Keras builds a layer once and the layout check only ran in build. It now also runs in call, which covers eager tensors and symbolic tensors on Keras 2, and in compute_output_shape, which Keras 3 uses for symbolic tensors. --- complexnn/utils.py | 37 ++++++++++++++++++++++++++++--------- tests/test_validation.py | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/complexnn/utils.py b/complexnn/utils.py index ebfc109..3ce8f65 100644 --- a/complexnn/utils.py +++ b/complexnn/utils.py @@ -148,31 +148,50 @@ def _compute_fans(shape, data_format="channels_last"): return fan_in, fan_out +def _check_part_shape(layer, input_shape): + """Check the axis that `get_realpart` and `get_imagpart` split, if known.""" + shape = tuple(input_shape) + axis = _complex_part_axis(len(shape)) + # An undefined size is split from the dynamic shape in `call`, which + # works on Keras 2, so only check sizes that are known here. + if shape[axis] is not None: + _check_complex_axis(layer, shape, axis) + + class _ComplexPart(Layer): - """Base class for the layers that split their input into its two parts.""" + """Base class for the layers that split their input into its two parts. + + These layers have no weights and accept inputs of any width and rank, so + one instance can be called on several tensors. Keras only builds a layer + on its first call, so the layout is also checked in `call`, which covers + eager tensors and symbolic tensors on Keras 2, and in + `compute_output_shape`, which Keras 3 uses for symbolic tensors instead + of tracing `call`. + """ def build(self, input_shape): - axis = _complex_part_axis(len(input_shape)) - # An undefined size is split from the dynamic shape in `call`, which - # works on Keras 2, so only check sizes that are known here. - if tuple(input_shape)[axis] is not None: - _check_complex_axis(self, input_shape, axis) + _check_part_shape(self, input_shape) super(_ComplexPart, self).build(input_shape) + def call(self, inputs): + _check_part_shape(self, inputs.shape) + return self._split(inputs) + def compute_output_shape(self, input_shape): + _check_part_shape(self, input_shape) return getpart_output_shape(input_shape) class GetReal(_ComplexPart): - def call(self, inputs): + def _split(self, inputs): return get_realpart(inputs) class GetImag(_ComplexPart): - def call(self, inputs): + def _split(self, inputs): return get_imagpart(inputs) class GetAbs(_ComplexPart): - def call(self, inputs): + def _split(self, inputs): return get_abs(inputs) diff --git a/tests/test_validation.py b/tests/test_validation.py index da8a5ab..a822b63 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -127,6 +127,25 @@ def test_get_parts(self): self.assertLayoutError(layer_class(), (1,), "axis 1 has size 1") self.assertLayoutError(layer_class(), (8, 0), "axis -1 has size 0") + def test_get_parts_reused_instance(self): + """A part layer checks every input, not only the one it was built on + + These layers have no weights, so one instance is often reused. Keras + builds a layer once, so the check also runs in call and in + compute_output_shape. + """ + for layer_class in (conn.GetReal, conn.GetImag, conn.GetAbs): + with self.subTest(layer=layer_class.__name__): + layer = layer_class() + layer(Input(shape=(8, 2))) + with self.assertRaisesRegex(ValueError, LAYOUT_ERROR): + layer(Input(shape=(8, 3))) + with self.assertRaisesRegex(ValueError, LAYOUT_ERROR): + layer(np.zeros((2, 8, 5), dtype="float32")) + # A different valid width still works with the same instance. + output = layer(Input(shape=(8, 4))) + self.assertEqual(tuple(output.shape), (None, 8, 2)) + def test_get_parts_undefined_size(self): """An undefined size is left to the dynamic split in call""" for layer_class in (conn.GetReal, conn.GetImag, conn.GetAbs): From 5d21469757a5577810cac68a5168931364693987 Mon Sep 17 00:00:00 2001 From: Jesper Dramsch Date: Mon, 12 Oct 2026 20:38:41 +0200 Subject: [PATCH 15/15] docs(layout): the convolutions read the image data format when they are created Since the move to complexnn's own conv_utils, ComplexConv2D and ComplexConv3D take data_format=None from keras.backend.image_data_format() when they are created, instead of reading only keras.json. GetReal, GetImag and GetAbs read it on every call. --- docs/source/layout.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/layout.md b/docs/source/layout.md index 0f613af..f898882 100644 --- a/docs/source/layout.md +++ b/docs/source/layout.md @@ -49,12 +49,12 @@ Inside a model, `GetReal`, `GetImag` and `GetAbs` return the real parts, the ima | Layer | Axis that holds the real and imaginary parts | |---|---| | `ComplexConv1D` | Always the last axis. It has no `data_format` argument and ignores the Keras image data format. | -| `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use `image_data_format` from the Keras config file, `~/.keras/keras.json`, which is `channels_last` if it is not set. Changing the image data format at runtime does not change them. | +| `ComplexConv2D`, `ComplexConv3D` | The channel axis: the last axis, or axis 1 with `data_format="channels_first"`. When `data_format` is not given, the layers use the image data format Keras reports when the layer is created, `keras.backend.image_data_format()`. Its default comes from the Keras config file, `~/.keras/keras.json`, and is `channels_last` if that is not set. Changing the format later does not affect layers that already exist. | | `ComplexDense` | The last axis of its 2D input `(batch_size, features)`. | | `ComplexBatchNormalization`, `ComplexLayerNorm` | The axis given by their `axis` argument. Two settings are supported: `axis=-1`, the default, for the last axis, and `axis=1` for inputs of rank 2, 4 or 5, such as `channels_first` images of shape `(batch, channels, rows, cols)`. | -| `GetReal`, `GetImag`, `GetAbs` | The last axis, except for 4D and 5D inputs when `keras.backend.image_data_format()` returns `channels_first`, where they use axis 1. Unlike the convolutions, they follow a change of the image data format at runtime. | +| `GetReal`, `GetImag`, `GetAbs` | The last axis, except for 4D and 5D inputs when `keras.backend.image_data_format()` returns `channels_first`, where they use axis 1. They read the image data format each time they are called, not once when they are created. | -If your code changes the image data format at runtime, with `keras.backend.set_image_data_format()` or, in Keras 3, `keras.config.set_image_data_format()`, pass `data_format` to `ComplexConv2D` and `ComplexConv3D` explicitly, so that they use the same channel axis as `GetReal`, `GetImag` and `GetAbs`. +If your code changes the image data format at runtime, with `keras.backend.set_image_data_format()` or, in Keras 3, `keras.config.set_image_data_format()`, change it before creating the layers, or pass `data_format` to `ComplexConv2D` and `ComplexConv3D` explicitly, so that they use the same channel axis as `GetReal`, `GetImag` and `GetAbs`. ## Errors