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 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/README.md b/README.md index 40f176b..8eecc9b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ 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/). Saving and loading @@ -69,20 +71,23 @@ model = tf.keras.models.load_model("model.keras") 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/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 -------- diff --git a/complexnn/bn.py b/complexnn/bn.py index 245080d..0682517 100644 --- a/complexnn/bn.py +++ b/complexnn/bn.py @@ -16,6 +16,8 @@ from .utils import _ignore_input_shape_warning +from .utils import _check_complex_axis + def sqrt_init(shape, dtype=None): value = (1 / np.sqrt(2)) * K.ones(shape) @@ -268,12 +270,16 @@ 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). + (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`. + 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 @@ -295,13 +301,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) """ @@ -359,6 +368,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 `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}) param_shape = (input_shape[self.axis] // 2,) diff --git a/complexnn/conv.py b/complexnn/conv.py index d29d70d..90d2dbf 100644 --- a/complexnn/conv.py +++ b/complexnn/conv.py @@ -15,7 +15,7 @@ from .bn import ComplexBN as complex_normalization from .bn import sqrt_init from .init import ComplexIndependentFilters, ComplexInit -from .utils import _ignore_input_shape_warning, get_initializer +from .utils import _check_complex_axis, _ignore_input_shape_warning, get_initializer # WeightNorm_Conv still subclasses the Keras 2 convolution base from TensorFlow's # private copy of Keras. The class is broken on Keras 2 and Keras 3 and needs a @@ -356,6 +356,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: @@ -658,9 +665,10 @@ 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. - # Arguments + `(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, the number of complex feature maps. It is also the effective number of feature maps for each of the real and imaginary parts. @@ -718,10 +726,15 @@ 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 - 3D tensor with shape: `(batch_size, steps, input_dim)` - # Output shape - 3D tensor with shape: `(batch_size, new_steps, 2 x filters)` + + Input shape: + 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)`, + with the real parts in the first `filters` channels. `steps` value might have changed due to padding or strides. """ @@ -790,9 +803,10 @@ 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"`. - # Arguments + 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 (i.e, the number complex feature maps in the convolution). The total effective number of filters or feature maps is 2 x filters. @@ -860,18 +874,23 @@ 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 + `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: `(samples, 2 x filters, new_rows, new_cols)` if 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__( @@ -938,10 +957,10 @@ 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"`. - # Arguments + 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 (i.e, the number complex feature maps in the convolution). The total effective number of filters or feature maps is 2 x filters. @@ -1008,14 +1027,18 @@ 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 + `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: `(samples, 2 x filters, new_conv_dim1, new_conv_dim2, new_conv_dim3)` if data_format='channels_first' @@ -1023,7 +1046,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 f84bde4..1753037 100644 --- a/complexnn/dense.py +++ b/complexnn/dense.py @@ -8,7 +8,12 @@ from tensorflow.keras.layers import InputSpec, Layer from tensorflow.keras.utils import register_keras_serializable -from .utils import _compute_fans, _ignore_input_shape_warning, get_initializer +from .utils import ( + _check_complex_axis, + _compute_fans, + _ignore_input_shape_warning, + get_initializer, +) @register_keras_serializable(package="complexnn") @@ -24,7 +29,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 @@ -50,11 +56,16 @@ class ComplexDense(Layer): (see keras.constraints). bias_constraint: Constraint function applied to the bias vector (see keras.constraints). - # Input shape - a 2D input with shape `(batch_size, input_dim)`. - # Output shape + + Input shape: + 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__( @@ -102,7 +113,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 950257d..b5d3576 100644 --- a/complexnn/norm.py +++ b/complexnn/norm.py @@ -11,7 +11,7 @@ from .bn import ComplexBN as complex_normalization from .bn import sanitizedInitGet, sanitizedInitSer, sqrt_init -from .utils import _ignore_input_shape_warning +from .utils import _check_complex_axis, _ignore_input_shape_warning def layernorm(x, axis, epsilon, gamma, beta): @@ -154,6 +154,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 `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}) gamma_shape = (input_shape[self.axis] // 2,) diff --git a/complexnn/utils.py b/complexnn/utils.py index bdabfd9..f0cd07b 100644 --- a/complexnn/utils.py +++ b/complexnn/utils.py @@ -118,35 +118,75 @@ 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"): """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: @@ -173,28 +213,53 @@ def _compute_fans(shape, data_format="channels_last"): return fan_in, fan_out -@register_keras_serializable(package="complexnn") -class GetReal(Layer): +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. + + 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): + _check_part_shape(self, input_shape) + super(_ComplexPart, self).build(input_shape) + def call(self, inputs): - return get_realpart(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) @register_keras_serializable(package="complexnn") -class GetImag(Layer): - def call(self, inputs): - return get_imagpart(inputs) +class GetReal(_ComplexPart): + def _split(self, inputs): + return get_realpart(inputs) - def compute_output_shape(self, input_shape): - return getpart_output_shape(input_shape) + +@register_keras_serializable(package="complexnn") +class GetImag(_ComplexPart): + def _split(self, inputs): + return get_imagpart(inputs) @register_keras_serializable(package="complexnn") -class GetAbs(Layer): - def call(self, inputs): +class GetAbs(_ComplexPart): + def _split(self, inputs): return get_abs(inputs) - - def compute_output_shape(self, input_shape): - return getpart_output_shape(input_shape) 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/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/conf.py b/docs/source/conf.py index da654f1..b905d1a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -30,10 +30,19 @@ extensions = [ "sphinxcontrib.apidoc", "sphinx.ext.autodoc", - "recommonmark", + "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" @@ -57,10 +66,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"] diff --git a/docs/source/index.rst b/docs/source/index.rst index 216302b..6fb07dc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -16,10 +16,12 @@ Contents Introduction Installation + Data layout API Contributing math cite + License Indices and tables ================== diff --git a/docs/source/layout.md b/docs/source/layout.md new file mode 100644 index 0000000..f898882 --- /dev/null +++ b/docs/source/layout.md @@ -0,0 +1,63 @@ +# 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) +``` + +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. + +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` | 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 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. 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()`, 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 + +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)`. 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() diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..a822b63 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,199 @@ +import unittest + +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" + + +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_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( + 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(), (8, 0), "axis -1 has size 0") + self.assertLayoutError( + 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(), (8, 0), "axis -1 has size 0") + 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): + 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_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): + 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)) + + 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()