diff --git a/.github/workflows/build-doc.yml b/.github/workflows/build-doc.yml index 050f8148..5aa1fcf1 100644 --- a/.github/workflows/build-doc.yml +++ b/.github/workflows/build-doc.yml @@ -76,11 +76,9 @@ jobs: echo "AUTODOC_NOTEBOOKS_BRANCH=${AUTODOC_NOTEBOOKS_BRANCH}" >> $GITHUB_ENV # check computed variables echo "Notebooks source: ${AUTODOC_NOTEBOOKS_REPO_URL}/tree/${AUTODOC_NOTEBOOKS_BRANCH}" - - uses: actions/checkout@v3 - with: - submodules: true + - uses: actions/checkout@v4 - name: Setup python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ env.python-version }} cache: "pip" @@ -92,9 +90,6 @@ jobs: python -m pip install -U pip setuptools pip install . pip install -r docs/requirements.txt - # ensure having keras 3 instead of keras 2.15 installed by tensorflow - pip uninstall -y keras - pip install "keras>=3" - name: generate documentation id: sphinx-build run: | @@ -119,7 +114,7 @@ jobs: ') echo doc_version=${doc_version} >> $GITHUB_OUTPUT - name: upload as artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ inputs.doc-artifact-name }} path: ${{ inputs.doc-path }} diff --git a/.github/workflows/deploy-doc.yml b/.github/workflows/deploy-doc.yml index ef6b6ba8..159e0bc0 100644 --- a/.github/workflows/deploy-doc.yml +++ b/.github/workflows/deploy-doc.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest if: inputs.binder-env-fullref != '' steps: - - uses: actions/checkout@v3 # checkout triggering branch to get scripts/trigger_binder.sh + - uses: actions/checkout@v4 # checkout triggering branch to get scripts/trigger_binder.sh - name: Trigger a build for default binder env ref on each BinderHub deployments in the mybinder.org federation run: | binder_env_full_ref=${{ inputs.binder-env-fullref }} @@ -39,9 +39,9 @@ jobs: deploy-doc: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: ${{ inputs.doc-artifact-name }} path: ${{ inputs.doc-path }} @@ -59,10 +59,10 @@ jobs: needs: [deploy-doc] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: gh-pages - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" - name: Generate versions.json diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index f1be731e..af5bae1e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -14,8 +14,8 @@ jobs: outputs: package_version: ${{ steps.get_package_version.outputs.version }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: '3.9' - name: Install dependencies @@ -33,7 +33,7 @@ jobs: echo "version=$version" echo "version=$version" >> $GITHUB_OUTPUT - name: Upload as build artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist @@ -59,7 +59,7 @@ jobs: outputs: tuto-tag-name: ${{ steps.push-tuto-release-tag.outputs.new_tag_name }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: replace decomon version to install in colab notebooks run: | version=${{ needs.deploy.outputs.package_version }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3f419d1d..974dd92a 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -15,18 +15,18 @@ jobs: linters: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: create requirements.txt so that pip cache with setup-python works run: echo "pre-commit" > requirements_precommit.txt - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 cache: pip cache-dependency-path: requirements_precommit.txt - name: install pre-commit run: python -m pip install pre-commit - name: get cached pre-commit hooks - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} @@ -36,8 +36,8 @@ jobs: type-checking: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: 3.9 cache: pip @@ -48,7 +48,7 @@ jobs: python -m pip install --upgrade tox setuptools - name: Restore cached .tox id: cache-tox - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: .tox key: @@ -70,8 +70,8 @@ jobs: run: shell: bash steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip @@ -82,7 +82,7 @@ jobs: python -m pip install --upgrade tox setuptools - name: Restore cached .tox id: cache-tox - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: .tox key: @@ -123,7 +123,7 @@ jobs: echo "coverage_exists=${coverage_exists}" >> $GITHUB_OUTPUT - name: Export coverage report (if existing) if: steps.check-coverage-report.outputs.coverage_exists == 'true' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage path: | diff --git a/binder/environment.yml b/binder/environment.yml index c56e1cf6..86ef75ed 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -8,7 +8,7 @@ dependencies: - kaggle - pandas - scipy - - tensorboard>=2.13 -# - tensorflow>=2.15 # conflicting dependencies with decomon (keras 2) + - tensorboard>=2.16 + - tensorflow>=2.16 - jupyter-server-proxy - - .. # decomon + - .. # decomon diff --git a/binder/postBuild b/binder/postBuild index 9bbeccbe..388e5f47 100644 --- a/binder/postBuild +++ b/binder/postBuild @@ -1,9 +1,3 @@ -# install tensorflow backend separately from decomon (dependencies conflict as tf 2.15 requires keras 2) -pip install tensorflow>=2.15 -# use keras 3 instead of keras 2 installed by tensorflow -pip uninstall -y keras -pip install "keras>=3" - # tensorboard launches at startup mv binder/tensorboardserverextension.py ${NB_PYTHON_PREFIX}/lib/python*/site-packages/ # enable tensorboard extension diff --git a/docs/generate_nb_index.py b/docs/generate_nb_index.py index c69f6565..80befd97 100644 --- a/docs/generate_nb_index.py +++ b/docs/generate_nb_index.py @@ -4,7 +4,6 @@ import os import re import urllib.parse -from typing import List, Tuple NOTEBOOKS_LIST_PLACEHOLDER = "[[notebooks-list]]" NOTEBOOKS_PAGE_TEMPLATE_RELATIVE_PATH = "tutorials.template.md" @@ -25,7 +24,7 @@ def extract_notebook_title_n_description( notebook_filepath: str, -) -> Tuple[str, List[str]]: +) -> tuple[str, list[str]]: # load notebook with open(notebook_filepath, "rt", encoding="utf-8") as f: notebook = json.load(f) @@ -33,7 +32,7 @@ def extract_notebook_title_n_description( # find title + description: from first cell, h1 title + remaining text. # or title from filename else title = "" - description_lines: List[str] = [] + description_lines: list[str] = [] cell = notebook["cells"][0] if cell["cell_type"] == "markdown": firstline = cell["source"][0].strip() @@ -51,7 +50,7 @@ def extract_notebook_title_n_description( return title, description_lines -def filter_tags_from_description(description_lines: List[str], html_tag_to_remove: str) -> List[str]: +def filter_tags_from_description(description_lines: list[str], html_tag_to_remove: str) -> list[str]: description = "".join(description_lines) # opening/closing tags opening_tag = html_tag_to_remove @@ -147,7 +146,7 @@ def get_binder_link( return link -def get_repo_n_branches_for_binder_n_github_links() -> Tuple[bool, str, str, str, str, str, bool]: +def get_repo_n_branches_for_binder_n_github_links() -> tuple[bool, str, str, str, str, str, bool]: # repos + branches to use for binder environment and notebooks content. creating_links = True use_nbgitpuller = False diff --git a/docs/requirements.txt b/docs/requirements.txt index 65da0bc8..4196e804 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ sphinx myst_parser sphinx_rtd_theme -tensorflow>=2.15 # install a backend for keras 3 (and tensorflow is the one needed by default) +tensorflow>=2.16 # install a backend for keras 3 (and tensorflow is the one needed by default) diff --git a/docs/source/getting_started.md b/docs/source/getting_started.md index e71905c2..285e7d9f 100644 --- a/docs/source/getting_started.md +++ b/docs/source/getting_started.md @@ -8,29 +8,34 @@ within a box domain. ````python # Imports +import keras import numpy as np -from keras.layers import Dense +from keras.layers import Dense, Input from keras.models import Sequential -from decomon import get_lower_box, get_upper_box -from decomon.models import clone +# from decomon import get_lower_box, get_upper_box +from decomon import clone # Toy example with a Keras model: -model = Sequential([Dense(10, activation="relu", input_dim=2)]) +model = Sequential([Input((2,)), Dense(10, activation="relu")]) + +# Convert into a decomon neural network +decomon_model = clone( + model, + final_ibp=True, # keep constant bounds + final_affine=False # drop affine bounds +) # Create a fake box with the right shape x_min = np.zeros((1, 2)) x_max = np.ones((1, 2)) - -# Convert into a decomon neural network -decomon_model = clone(model) +x_box = np.concatenate([x_min[:, None], x_max[:, None]], axis=1) # Get lower and upper bounds -lower_bound = get_lower_box(decomon_model, x_min, x_max) -upper_bound = get_upper_box(decomon_model, x_min, x_max) +lower_bound, upper_bound = decomon_model.predict_on_single_batch_np(x_box) # more efficient than predict on very small batch -print(lower_bound) -print(upper_bound) +print(f"lower bound: {lower_bound}") +print(f"upper bound: {upper_bound}") ```` Other types of domains are possible and illustrated diff --git a/pyproject.toml b/pyproject.toml index b7e8ef89..5e4329c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,3 +79,7 @@ module = [ "torch.*" ] ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "decomon.visualization.model_visualization" +ignore_errors = true diff --git a/src/decomon/__init__.py b/src/decomon/__init__.py index 87cd56bf..225e82d2 100644 --- a/src/decomon/__init__.py +++ b/src/decomon/__init__.py @@ -8,12 +8,12 @@ import keras -from . import layers, models -from .metrics.loss import get_adv_loss, get_lower_loss, get_model, get_upper_loss -from .models.models import get_AB as get_grid_params -from .models.models import get_AB_finetune as get_grid_slope -from .wrapper import ( - check_adv_box, +from .models import clone + +# from .metrics.loss import get_adv_loss, get_lower_loss, get_model, get_upper_loss +# from .models.models import get_AB as get_grid_params +# from .models.models import get_AB_finetune as get_grid_slope +from .wrapper import ( # check_adv_box,; refine_box, get_adv_box, get_adv_noise, get_lower_box, @@ -22,9 +22,9 @@ get_range_noise, get_upper_box, get_upper_noise, - refine_box, ) -from .wrapper_with_tuning import get_lower_box_tuning, get_upper_box_tuning + +# from .wrapper_with_tuning import get_lower_box_tuning, get_upper_box_tuning try: __version__ = version("decomon") diff --git a/src/decomon/backward_layers/activations.py b/src/decomon/backward_layers/activations.py deleted file mode 100644 index b53ade7d..00000000 --- a/src/decomon/backward_layers/activations.py +++ /dev/null @@ -1,484 +0,0 @@ -import warnings -from typing import Any, Callable, Dict, List, Optional, Union - -import keras.ops as K -import numpy as np -from keras.layers import Layer - -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - Slope, -) -from decomon.types import Tensor -from decomon.utils import ( - get_linear_hull_relu, - get_linear_hull_s_shape, - get_linear_hull_sigmoid, - get_linear_hull_tanh, - get_linear_softplus_hull, - sigmoid_prime, - softsign_prime, - tanh_prime, -) - -ELU = "elu" -SELU = "selu" -SOFTPLUS = "softplus" -SOFTSIGN = "softsign" -SOFTMAX = "softmax" -RELU_ = "relu_" -RELU = "relu" -SIGMOID = "sigmoid" -TANH = "tanh" -EXPONENTIAL = "exponential" -HARD_SIGMOID = "hard_sigmoid" -LINEAR = "linear" - - -def backward_relu( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - alpha: float = 0.0, - max_value: Optional[float] = None, - threshold: float = 0.0, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of relu - - Args: - inputs - dc_decomp - perturbation_domain - alpha - max_value - threshold - slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - if dc_decomp: - raise NotImplementedError() - - if threshold != 0: - raise NotImplementedError() - - if not alpha and max_value is None: - # default values: return relu_(x) = max(x, 0) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - input_shape = inputs_outputs_spec.get_kerasinputshape(inputs) - bounds = get_linear_hull_relu(upper=u_c, lower=l_c, slope=slope, **kwargs) - dim = int(np.prod(input_shape[1:])) # type: ignore - return [K.reshape(elem, (-1, dim)) for elem in bounds] - - raise NotImplementedError() - - -def backward_sigmoid( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of sigmoid - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - return get_linear_hull_sigmoid(u_c, l_c, slope=slope, **kwargs) - - -def backward_tanh( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of tanh - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - return get_linear_hull_tanh(u_c, l_c, slope=slope, **kwargs) - - -def backward_hard_sigmoid( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of hard sigmoid - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - raise NotImplementedError() - - -def backward_elu( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of Exponential Linear Unit - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - slope = Slope(slope) - raise NotImplementedError() - - -def backward_selu( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of Scaled Exponential Linear Unit (SELU) - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - slope = Slope(slope) - raise NotImplementedError() - - -def backward_linear( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of linear - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - slope = Slope(slope) - raise NotImplementedError() - - -def backward_exponential( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPAof exponential - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - slope = Slope(slope) - raise NotImplementedError() - - -def backward_softplus( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of softplus - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - return get_linear_softplus_hull(u_c, l_c, slope=slope, **kwargs) - - -def backward_softsign( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of softsign - - Args: - inputs - w_u_out - b_u_out - w_l_out - b_l_out - perturbation_domain - slope: backward slope - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - bounds = get_linear_hull_s_shape( - inputs, func=K.softsign, f_prime=softsign_prime, perturbation_domain=perturbation_domain, mode=mode - ) - shape = int(np.prod(inputs[-1].shape[1:])) - return [K.reshape(elem, (-1, shape)) for elem in bounds] - - -def backward_softsign_( - inputs: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - w_u_0, b_u_0, w_l_0, b_l_0 = get_linear_hull_s_shape( - inputs, func=K.softsign, f_prime=softsign_prime, perturbation_domain=perturbation_domain, mode=mode, slope=slope - ) - - w_u_0 = K.expand_dims(w_u_0, -1) - w_l_0 = K.expand_dims(w_l_0, -1) - - b_u_0 = K.expand_dims(b_u_0, -1) - b_l_0 = K.expand_dims(b_l_0, -1) - - z_value = K.cast(0.0, dtype=inputs[0].dtype) - b_u_out = K.sum(K.maximum(z_value, w_u_out) * b_u_0 + K.minimum(z_value, w_u_out) * b_l_0, 2) + b_u_out - b_l_out = K.sum(K.maximum(z_value, w_l_out) * b_l_0 + K.minimum(z_value, w_l_out) * b_u_0, 2) + b_l_out - w_u_out = K.maximum(z_value, w_u_out) * w_u_0 + K.minimum(z_value, w_u_out) * w_l_0 - w_l_out = K.maximum(z_value, w_l_out) * w_l_0 + K.minimum(z_value, w_l_out) * w_u_0 - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - -def backward_softmax( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - axis: int = -1, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of softmax - - Args: - inputs - dc_decomp - perturbation_domain - slope - mode - axis - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - slope = Slope(slope) - raise NotImplementedError() - - -def deserialize(name: str) -> Callable[..., List[Tensor]]: - """Get the activation from name. - - Args: - name: name of the method. - among the implemented Keras activation function. - - Returns: - - """ - name = name.lower() - - if name == SOFTMAX: - return backward_softmax - if name == ELU: - return backward_elu - if name == SELU: - return backward_selu - if name == SOFTPLUS: - return backward_softplus - if name == SOFTSIGN: - return backward_softsign - if name == SIGMOID: - return backward_sigmoid - if name == TANH: - return backward_tanh - if name in [RELU, RELU_]: - return backward_relu - if name == EXPONENTIAL: - return backward_exponential - if name == LINEAR: - return backward_linear - raise ValueError("Could not interpret " "activation function identifier:", name) - - -def get(identifier: Any) -> Callable[..., List[Tensor]]: - """Get the `identifier` activation function. - - Args: - identifier: None or str, name of the function. - - Returns: - The activation function, `linear` if `identifier` is None. - - """ - if identifier is None: - return backward_linear - if isinstance(identifier, str): - return deserialize(identifier) - elif callable(identifier): - if isinstance(identifier, Layer): - warnings.warn( - "Do not pass a layer instance (such as {identifier}) as the " - "activation argument of another layer. Instead, advanced " - "activation layers should be used just like any other " - "layer in a model.".format(identifier=identifier.__class__.__name__) - ) - return identifier - else: - raise ValueError("Could not interpret " "activation function identifier:", identifier) diff --git a/src/decomon/backward_layers/backward_layers.py b/src/decomon/backward_layers/backward_layers.py deleted file mode 100644 index 7b3cbf7f..00000000 --- a/src/decomon/backward_layers/backward_layers.py +++ /dev/null @@ -1,629 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras -import keras.ops as K -import numpy as np -from keras.layers import Flatten, Layer - -from decomon.backward_layers.activations import get -from decomon.backward_layers.core import BackwardLayer -from decomon.backward_layers.utils import get_identity_lirpa -from decomon.backward_layers.utils_conv import get_toeplitz -from decomon.core import ( - ForwardMode, - GridDomain, - Option, - PerturbationDomain, - Slope, - get_affine, - get_ibp, -) -from decomon.keras_utils import BatchedDiagLike, BatchedIdentityLike -from decomon.layers.convert import to_decomon -from decomon.layers.core import DecomonLayer -from decomon.layers.decomon_layers import DecomonBatchNormalization -from decomon.layers.utils import ClipAlpha, NonNeg, NonPos -from decomon.models.utils import get_input_dim -from decomon.types import BackendTensor - - -class BackwardDense(BackwardLayer): - """Backward LiRPA of Dense""" - - def __init__( - self, - layer: Layer, - input_dim: int = -1, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - self.use_bias = self.layer.use_bias - if not isinstance(self.layer, DecomonLayer): - if input_dim < 0: - input_dim = get_input_dim(self.layer) - self.layer = to_decomon( - layer=layer, - input_dim=input_dim, - dc_decomp=False, - perturbation_domain=self.perturbation_domain, - finetune=False, - ibp=get_ibp(self.mode), - affine=get_affine(self.mode), - shared=True, - fast=False, - ) - self.frozen_weights = False - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if len(inputs) == 0: - inputs = self.layer.input - - y = inputs[-1] - flatten_inputdim_wo_last_dim = int(np.prod(y.shape[1:-1])) - flatten_outputdim = flatten_inputdim_wo_last_dim * self.layer.units - batchsize = y.shape[0] - - # kernel reshaped: diagonal by blocks, with original kernel on the diagonal, `flatten_inputdim_wo_last_dim` times - # repeated batchsize times - zero_block = K.zeros_like(self.kernel) - kernel_diag_by_block = K.concatenate( - [ - K.concatenate( - [self.kernel if i == j else zero_block for i in range(flatten_inputdim_wo_last_dim)], axis=-1 - ) - for j in range(flatten_inputdim_wo_last_dim) - ], - axis=-2, - ) - w = K.repeat(kernel_diag_by_block[None], batchsize, axis=0) - if self.use_bias: - b = K.repeat( - K.reshape(K.repeat(self.bias[None], flatten_inputdim_wo_last_dim, axis=0), (1, -1)), batchsize, axis=0 - ) - else: - b = K.zeros((batchsize, flatten_outputdim), dtype=self.dtype) - - return [w, b, w, b] - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - input_shape: list of input shape - - Returns: - - """ - if self.layer.kernel is None: - raise RuntimeError("self.layer.kernel cannot be None when calling self.build()") - self.kernel = self.layer.kernel - self._trainable_weights = [self.kernel] - if self.use_bias: - if self.layer.bias is None: - raise RuntimeError("self.layer.bias cannot be None when calling self.build()") - self.bias = self.layer.bias - self._trainable_weights.append(self.bias) - self.built = True - - def freeze_weights(self) -> None: - if not self.frozen_weights: - self._trainable_weights = [] - if getattr(self.layer, "freeze_weights"): - self.layer.freeze_weights() - self.frozen_weights = True - - def unfreeze_weights(self) -> None: - if self.frozen_weights: - if getattr(self.layer, "unfreeze_weights"): - self.layer.unfreeze_weights() - self.frozen_weights = False - - -class BackwardConv2D(BackwardLayer): - """Backward LiRPA of Conv2D""" - - def __init__( - self, - layer: Layer, - input_dim: int = -1, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - if not isinstance(self.layer, DecomonLayer): - if input_dim < 0: - input_dim = get_input_dim(self.layer) - self.layer = to_decomon( - layer=layer, - input_dim=input_dim, - dc_decomp=False, - perturbation_domain=self.perturbation_domain, - finetune=False, - ibp=get_ibp(self.mode), - affine=get_affine(self.mode), - shared=True, - fast=False, - ) - self.frozen_weights = False - - def get_affine_components(self, inputs: List[BackendTensor]) -> Tuple[BackendTensor, BackendTensor]: - """Express the implicit affine matrix of the convolution layer. - - Conv is a linear operator but its affine component is implicit - we use im2col and extract_patches to express the affine matrix - Note that this matrix is Toeplitz - - Args: - inputs: list of input tensors - Returns: - the affine operators W, b : conv(inputs)= W.inputs + b - """ - - w_out_u_ = get_toeplitz(self.layer, True) - output = self.layer.output - if isinstance(output, keras.KerasTensor): - output_shape = output.shape - else: # list of outputs - output_shape = output[-1].shape - output_shape = output_shape[1:] - if self.layer.data_format == "channels_last": - b_out_u_ = K.reshape(K.zeros(output_shape, dtype=self.layer.dtype), (-1, output_shape[-1])) - else: - b_out_u_ = K.transpose( - K.reshape(K.zeros(output_shape, dtype=self.layer.dtype), (-1, output_shape[0])), (1, 0) - ) - - if self.layer.use_bias: - bias_ = K.cast(self.layer.bias, self.layer.dtype) - if self.layer.data_format == "channels_last": - b_out_u_ = b_out_u_ + bias_[None] - else: - b_out_u_ = b_out_u_ + bias_[:, None] - b_out_u_ = K.ravel(b_out_u_) - - z_value = K.cast(0.0, self.dtype) - y_ = inputs[-1] - shape = int(np.prod(y_.shape[1:])) - y_flatten = K.reshape(z_value * y_, (-1, int(np.prod(shape)))) # (None, n_in) - w_out_ = K.sum(y_flatten, -1)[:, None, None] + w_out_u_ - b_out_ = K.sum(y_flatten, -1)[:, None] + b_out_u_ - - return w_out_, b_out_ - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - weight_, bias_ = self.get_affine_components(inputs) - return [weight_, bias_] * 2 - - def freeze_weights(self) -> None: - if not self.frozen_weights: - self._trainable_weights = [] - if getattr(self.layer, "freeze_weights"): - self.layer.freeze_weights() - self.frozen_weights = True - - def unfreeze_weights(self) -> None: - if self.frozen_weights: - if getattr(self.layer, "unfreeze_weights"): - self.layer.unfreeze_weights() - self.frozen_weights = False - - -class BackwardActivation(BackwardLayer): - def __init__( - self, - layer: Layer, - slope: Union[str, Slope] = Slope.V_SLOPE, - finetune: bool = False, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - self.activation = get(layer.get_config()["activation"]) - self.activation_name = layer.get_config()["activation"] - self.slope = Slope(slope) - self.finetune = finetune - self.finetune_param: List[keras.Variable] = [] - if self.finetune: - self.frozen_alpha = False - self.grid_finetune: List[keras.Variable] = [] - self.frozen_grid = False - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "slope": self.slope, - "finetune": self.finetune, - } - ) - return config - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - input_shape: list of input shape - - Returns: - - """ - input_dim = int(np.prod(input_shape[-1][1:])) # type: ignore - - if self.finetune and self.activation_name != "linear": - if isinstance(self.perturbation_domain, GridDomain): - if self.activation_name[:4] == "relu": - self.alpha_b_l = self.add_weight( - shape=( - 3, - input_dim, - ), - initializer="ones", - name="alpha_l_b_0", - regularizer=None, - constraint=ClipAlpha(), - ) - alpha_b_l = np.zeros((3, input_dim)) - alpha_b_l[0] = 1 - self.alpha_b_l.assign(alpha_b_l) - self.finetune_param.append(self.alpha_b_l) - - else: - self.alpha_b_l = self.add_weight( - shape=(input_dim,), - initializer="ones", - name="alpha_l_b", - regularizer=None, - constraint=ClipAlpha(), - ) - alpha_b_l = np.ones((input_dim,)) - # alpha_b_l[0] = 1 - self.alpha_b_l.assign(alpha_b_l) - - if self.activation_name[:4] != "relu": - self.alpha_b_u = self.add_weight( - shape=(input_dim,), - initializer="ones", - name="alpha_u_b", - regularizer=None, - constraint=ClipAlpha(), - ) - self.finetune_param.append(self.alpha_b_u) - - self.finetune_param.append(self.alpha_b_l) - if len(self.finetune_param) == 1: - self.finetune_param = self.finetune_param[0] - - # grid domain - if self.activation_name[:4] == "relu": - if ( - isinstance(self.perturbation_domain, GridDomain) - and self.perturbation_domain.opt_option == Option.lagrangian - and self.mode != ForwardMode.IBP - ): - finetune_grid_pos = self.add_weight( - shape=(input_dim,), - initializer="zeros", - name="lambda_grid_neg", - regularizer=None, - constraint=NonNeg(), - ) - - finetune_grid_neg = self.add_weight( - shape=(input_dim,), - initializer="zeros", - name="lambda_grid_pos", - regularizer=None, - constraint=NonPos(), - ) - - self.grid_finetune = [finetune_grid_neg, finetune_grid_pos] - - if ( - isinstance(self.perturbation_domain, GridDomain) - and self.perturbation_domain.opt_option == Option.milp - and self.mode != ForwardMode.IBP - ): - finetune_grid_A = self.add_weight( - shape=(input_dim,), - initializer="zeros", - name=f"A_{self.layer.name}_{self.rec}", - regularizer=None, - trainable=False, - ) # constraint=NonPos() - finetune_grid_B = self.add_weight( - shape=(input_dim,), - initializer="zeros", - name=f"B_{self.layer.name}_{self.rec}", - regularizer=None, - trainable=False, - ) # constraint=NonNeg() - - self.grid_finetune = [finetune_grid_A, finetune_grid_B] - - self.built = True - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - # infer the output dimension - if self.activation_name != "linear": - if self.finetune: - w_u_out, b_u_out, w_l_out, b_l_out = self.activation( - inputs, - perturbation_domain=self.perturbation_domain, - slope=self.slope, - mode=self.mode, - finetune=self.finetune_param, - finetune_grid=self.grid_finetune, - ) - else: - w_u_out, b_u_out, w_l_out, b_l_out = self.activation( - inputs, - perturbation_domain=self.perturbation_domain, - slope=self.slope, - mode=self.mode, - finetune_grid=self.grid_finetune, - ) - else: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - if len(w_u_out.shape) == 2: - w_u_out = BatchedDiagLike()(w_u_out) - if len(w_l_out.shape) == 2: - w_l_out = BatchedDiagLike()(w_l_out) - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - def freeze_alpha(self) -> None: - if not self.frozen_alpha: - if self.finetune and self.mode in [ForwardMode.AFFINE, ForwardMode.HYBRID]: - if len(self.grid_finetune): - self._trainable_weights = self._trainable_weights[:2] - else: - self._trainable_weights = [] - self.frozen_alpha = True - - def unfreeze_alpha(self) -> None: - if self.frozen_alpha: - if self.finetune and self.mode in [ForwardMode.AFFINE, ForwardMode.HYBRID]: - if self.activation_name != "linear": - if self.activation_name[:4] != "relu": - self._trainable_weights += [self.alpha_b_u, self.alpha_b_l] - else: - self._trainable_weights += [self.alpha_b_l] - self.frozen_alpha = False - - def freeze_grid(self) -> None: - if len(self.grid_finetune) and not self.frozen_grid: - self._trainable_weights = self._trainable_weights[2:] - self.frozen_grid = True - - def unfreeze_grid(self) -> None: - if len(self.grid_finetune) and self.frozen_grid: - self._trainable_weights = self.grid_finetune + self._trainable_weights - self.frozen_grid = False - - -class BackwardFlatten(BackwardLayer): - """Backward LiRPA of Flatten""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - return get_identity_lirpa(inputs) - - -class BackwardReshape(BackwardLayer): - """Backward LiRPA of Reshape""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - return get_identity_lirpa(inputs) - - -class BackwardPermute(BackwardLayer): - """Backward LiRPA of Permute""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - self.dims = layer.dims - self.op = layer.call - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - # w_u_out (None, n_in, n_out) - y = inputs[-1] - n_dim = w_u_out.shape[1] - n_out = w_u_out.shape[-1] - shape = list(y.shape[1:]) - - w_u_out = K.reshape(w_u_out, [-1] + shape + [n_out]) - w_l_out = K.reshape(w_l_out, [-1] + shape + [n_out]) - - dims = [0] + list(self.dims) + [len(y.shape)] - dims = list(np.argsort(dims)) - w_u_out = K.reshape(K.transpose(w_u_out, dims), (-1, n_dim, n_out)) - w_l_out = K.reshape(K.transpose(w_l_out, dims), (-1, n_dim, n_out)) - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - -class BackwardDropout(BackwardLayer): - """Backward LiRPA of Dropout""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - return get_identity_lirpa(inputs) - - -class BackwardBatchNormalization(BackwardLayer): - """Backward LiRPA of Batch Normalization""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - - self.axis = self.layer.axis - self.op_flat = Flatten() - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - y = inputs[-1] - n_out = int(np.prod(y.shape[1:])) - - n_dim = len(y.shape) - shape = [1] * n_dim - shape[self.axis] = self.layer.moving_mean.shape[0] - - if not hasattr(self.layer, "gamma") or self.layer.gamma is None: # scale = False - gamma = K.ones_like(self.layer.moving_variance) - else: # scale = True - gamma = self.layer.gamma - if not hasattr(self.layer, "beta") or self.layer.beta is None: # center = False - beta = K.zeros_like(self.layer.moving_mean) - else: # center = True - beta = self.layer.beta - - w = gamma / K.sqrt(self.layer.moving_variance + self.layer.epsilon) - b = beta - w * self.layer.moving_mean - - # reshape w - w_b = K.reshape( - K.reshape(BatchedIdentityLike()(K.reshape(y, (-1, n_out))), tuple(y.shape) + (-1,)) - * K.reshape(w, shape + [1]), - (-1, n_out, n_out), - ) - - # reshape b - b_b = K.reshape(K.ones_like(y) * K.reshape(b, shape), (-1, n_out)) - - return [w_b, b_b, w_b, b_b] - - -class BackwardInputLayer(BackwardLayer): - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - return get_identity_lirpa(inputs) diff --git a/src/decomon/backward_layers/backward_maxpooling.py b/src/decomon/backward_layers/backward_maxpooling.py deleted file mode 100644 index 823d8526..00000000 --- a/src/decomon/backward_layers/backward_maxpooling.py +++ /dev/null @@ -1,204 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras.ops as K -import numpy as np -from keras.layers import Flatten, Layer - -from decomon.backward_layers.core import BackwardLayer -from decomon.backward_layers.utils import backward_max_, get_identity_lirpa -from decomon.core import ForwardMode, PerturbationDomain -from decomon.keras_utils import BatchedIdentityLike -from decomon.types import BackendTensor - - -class BackwardMaxPooling2D(BackwardLayer): - """Backward LiRPA of MaxPooling2D""" - - pool_size: Tuple[int, int] - strides: Tuple[int, int] - padding: str - data_format: str - fast: bool - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - raise NotImplementedError() - - def _pooling_function_fast( - self, - inputs: List[BackendTensor], - w_u_out: BackendTensor, - b_u_out: BackendTensor, - w_l_out: BackendTensor, - b_l_out: BackendTensor, - ) -> List[BackendTensor]: - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - - op_flat = Flatten() - - b_u_pooled = K.max_pool(u_c, self.pool_size, self.strides, self.padding, self.data_format) - b_l_pooled = K.max_pool(l_c, self.pool_size, self.strides, self.padding, self.data_format) - - b_u_pooled = K.expand_dims(K.expand_dims(op_flat(b_u_pooled), 1), -1) - b_l_pooled = K.expand_dims(K.expand_dims(op_flat(b_l_pooled), 1), -1) - - y = inputs[-1] - n_out = w_u_out.shape[-1] - - w_u_out_new = K.concatenate([K.expand_dims(K.expand_dims(0 * (op_flat(y)), 1), -1)] * n_out, -1) - w_l_out_new = w_u_out_new - - z_value = K.cast(0.0, dtype=w_u_out.dtype) - b_u_out_new = ( - K.sum(K.maximum(w_u_out, z_value) * b_u_pooled, 2) - + K.sum(K.minimum(w_u_out, z_value) * b_l_pooled, 2) - + b_u_out - ) - b_l_out_new = ( - K.sum(K.maximum(w_l_out, z_value) * b_l_pooled, 2) - + K.sum(K.minimum(w_l_out, z_value) * b_u_pooled, 2) - + b_l_out - ) - - return [w_u_out_new, b_u_out_new, w_l_out_new, b_l_out_new] - - def _pooling_function_not_fast( - self, - inputs: List[BackendTensor], - w_u_out: BackendTensor, - b_u_out: BackendTensor, - w_l_out: BackendTensor, - b_l_out: BackendTensor, - ) -> List[BackendTensor]: - """ - Args: - inputs - pool_size - strides - padding - data_format - - Returns: - - """ - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - y = inputs[-1] - input_shape = y.shape - - if self.data_format in [None, "channels_last"]: - axis = -1 - else: - axis = 1 - - # initialize vars - u_c_tmp, w_u_tmp, b_u_tmp, l_c_tmp, w_l_tmp, b_l_tmp = ( - empty_tensor, - empty_tensor, - empty_tensor, - empty_tensor, - empty_tensor, - empty_tensor, - ) - - if self.ibp: - u_c_tmp = K.concatenate([self.internal_op(elem) for elem in K.split(u_c, input_shape[-1], -1)], -2) - l_c_tmp = K.concatenate([self.internal_op(elem) for elem in K.split(l_c, input_shape[-1], -1)], -2) - - if self.affine: - b_u_tmp = K.concatenate([self.internal_op(elem) for elem in K.split(b_u, input_shape[-1], -1)], -2) - b_l_tmp = K.concatenate([self.internal_op(elem) for elem in K.split(b_l, input_shape[-1], -1)], -2) - w_u_tmp = K.concatenate([self.internal_op(elem) for elem in K.split(w_u, input_shape[-1], -1)], -2) - w_l_tmp = K.concatenate([self.internal_op(elem) for elem in K.split(w_l, input_shape[-1], -1)], -2) - - if self.dc_decomp: - raise NotImplementedError() - else: - h_tmp, g_tmp = empty_tensor, empty_tensor - - outputs_tmp = self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_tmp, w_u_tmp, b_u_tmp, l_c_tmp, w_l_tmp, b_l_tmp, h_tmp, g_tmp] - ) - - w_u_out, b_u_out, w_l_out, b_l_out = backward_max_( - outputs_tmp, - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - axis=-1, - ) - - # invert the convolution - op_flat = Flatten() - - # do not do the activation so far - # get input shape - input_shape_channelreduced = list(inputs[0].shape[1:]) - n_axis = input_shape_channelreduced[axis] - input_shape_channelreduced[axis] = 1 - n_dim = int(np.prod(input_shape_channelreduced)) - - # create diagonal matrix - id_list = [BatchedIdentityLike()(op_flat(elem[0][None])) for elem in K.split(y, input_shape[axis], axis)] - - id_list = [K.reshape(identity_mat, [-1] + input_shape_channelreduced) for identity_mat in id_list] - w_list = [self.internal_op(identity_mat) for identity_mat in id_list] - - # flatten - weights = [K.reshape(op_flat(weights), (n_dim, -1, int(np.prod(self.pool_size)))) for weights in w_list] - - n_0 = weights[0].shape[1] - n_1 = weights[0].shape[2] - - w_u_out = K.reshape(w_u_out, (-1, 1, n_0, input_shape[axis], w_u_out.shape[-2], n_1)) - w_l_out = K.reshape(w_l_out, (-1, 1, n_0, input_shape[axis], w_l_out.shape[-2], n_1)) - - weights = K.expand_dims(K.concatenate([K.expand_dims(K.expand_dims(w, -2), -2) for w in weights], 2), 0) - - w_u_out = K.reshape( - K.sum(K.expand_dims(w_u_out, 1) * weights, (3, -1)), (-1, 1, n_dim * n_axis, w_u_out.shape[-2]) - ) - w_l_out = K.reshape( - K.sum(K.expand_dims(w_l_out, 1) * weights, (3, -1)), (-1, 1, n_dim * n_axis, w_l_out.shape[-2]) - ) - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - if self.fast: - return self._pooling_function_fast( - inputs=inputs, - w_u_out=w_u_out, - b_u_out=b_u_out, - w_l_out=w_l_out, - b_l_out=b_l_out, - ) - else: - return self._pooling_function_not_fast( - inputs=inputs, - w_u_out=w_u_out, - b_u_out=b_u_out, - w_l_out=w_l_out, - b_l_out=b_l_out, - ) diff --git a/src/decomon/backward_layers/backward_merge.py b/src/decomon/backward_layers/backward_merge.py deleted file mode 100644 index acfdcd3e..00000000 --- a/src/decomon/backward_layers/backward_merge.py +++ /dev/null @@ -1,609 +0,0 @@ -from abc import ABC, abstractmethod -from itertools import chain -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras -import keras.ops as K -import numpy as np -from keras.layers import Layer, Wrapper - -from decomon.backward_layers.utils import ( - backward_add, - backward_maximum, - backward_minimum, - backward_multiply, - backward_subtract, - get_identity_lirpa, -) -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - get_affine, - get_ibp, -) -from decomon.layers.core import DecomonLayer -from decomon.layers.decomon_merge_layers import ( - DecomonAdd, - DecomonConcatenate, - DecomonDot, - DecomonMaximum, - DecomonMinimum, - DecomonMultiply, - DecomonSubtract, -) -from decomon.layers.utils import broadcast, multiply, permute_dimensions, split -from decomon.types import BackendTensor - - -class BackwardMerge(ABC, Wrapper): - layer: Layer - _trainable_weights: List[keras.Variable] - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - kwargs.pop("slope", None) - kwargs.pop("finetune", None) - super().__init__(layer, **kwargs) - self.rec = rec - if isinstance(self.layer, DecomonLayer): - self.mode = self.layer.mode - self.perturbation_domain = self.layer.perturbation_domain - self.dc_decomp = self.layer.dc_decomp - else: - self.mode = ForwardMode(mode) - if perturbation_domain is None: - self.perturbation_domain = BoxDomain() - else: - self.perturbation_domain = perturbation_domain - self.dc_decomp = dc_decomp - self.inputs_outputs_spec = InputsOutputsSpec( - dc_decomp=self.dc_decomp, mode=self.mode, perturbation_domain=self.perturbation_domain - ) - - @property - def ibp(self) -> bool: - return get_ibp(self.mode) - - @property - def affine(self) -> bool: - return get_affine(self.mode) - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "rec": self.rec, - "mode": self.mode, - "perturbation_domain": self.perturbation_domain, - "dc_decomp": self.dc_decomp, - } - ) - return config - - @abstractmethod - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - """ - Args: - inputs - - Returns: - - """ - pass - - def compute_output_shape(self, input_shape: List[Tuple[Optional[int], ...]]) -> List[Tuple[Optional[int], ...]]: - """Compute expected output shape according to input shape - - Will be called by symbolic calls on Keras Tensors. - - Args: - input_shape - - Returns: - - """ - raise NotImplementedError() - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - input_shape - - Returns: - - """ - # generic case: nothing to do before call - pass - - def freeze_weights(self) -> None: - pass - - def unfreeze_weights(self) -> None: - pass - - def freeze_alpha(self) -> None: - pass - - def unfreeze_alpha(self) -> None: - pass - - def reset_finetuning(self) -> None: - pass - - -class BackwardAdd(BackwardMerge): - """Backward LiRPA of Add""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - self.op = DecomonAdd( - mode=self.mode, perturbation_domain=self.perturbation_domain, dc_decomp=self.dc_decomp - ).call - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - #  check number of inputs - if len(inputs_list) == 1: # nothing to merge - return [[w_u_out, b_u_out, w_l_out, b_l_out]] - elif len(inputs_list) == 2: - bounds_0, bounds_1 = backward_add( - inputs_list[0], - inputs_list[1], - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - ) - return [bounds_0, bounds_1] - - else: - raise NotImplementedError("This layer is intended to merge only 2 layers.") - - -class BackwardAverage(BackwardMerge): - """Backward LiRPA of Average""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - self.op = DecomonAdd(mode=self.mode, perturbation_domain=self.perturbation_domain, dc_decomp=False).call - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - n_elem = len(inputs_list) - #  check number of inputs - if n_elem == 1: # nothing to merge - return [[w_u_out, b_u_out, w_l_out, b_l_out]] - else: - bounds: List[List[BackendTensor]] = [] - input_bounds: List[List[BackendTensor]] = [] - - for j in range(n_elem - 1, 0, -1): - inputs_1 = inputs_list[j] - if j == 1: - inputs_0 = inputs_list[0] - else: - inputs_0 = self.op(list(chain(*inputs_list[: (j - 1)]))) # merge (j-1) first inputs - bounds_0, bounds_1 = backward_add( - inputs_0, - inputs_1, - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - ) - input_bounds.append(bounds_1) - bounds.append(bounds_0) - if j == 1: - input_bounds.append(bounds_0) - # update bounds to use for next iteration - w_u_out, b_u_out, w_l_out, b_l_out = bounds_0 - - input_bounds = input_bounds[::-1] - return [[1.0 / n_elem * elem_i for elem_i in elem] for elem in input_bounds] - - -class BackwardSubtract(BackwardMerge): - """Backward LiRPA of Subtract""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - if not isinstance(layer, DecomonSubtract): - raise KeyError() - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - n_elem = len(inputs_list) - - if n_elem != 2: - raise NotImplementedError("This layer is intended to merge only 2 layers.") - - return backward_subtract( - inputs_list[0], - inputs_list[1], - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=self.layer.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - ) - - -class BackwardMaximum(BackwardMerge): - """Backward LiRPA of Maximum""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - if not isinstance(layer, DecomonMaximum): - raise KeyError() - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - n_elem = len(inputs_list) - - if n_elem != 2: - raise NotImplementedError("This layer is intended to merge only 2 layers.") - - return backward_maximum( - inputs_list[0], - inputs_list[1], - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=self.layer.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - ) - - -class BackwardMinimum(BackwardMerge): - """Backward LiRPA of Minimum""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - if not isinstance(layer, DecomonMinimum): - raise KeyError() - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - n_elem = len(inputs_list) - - if n_elem != 2: - raise NotImplementedError("This layer is intended to merge only 2 layers.") - - return backward_minimum( - inputs_list[0], - inputs_list[1], - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=self.layer.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - ) - - -class BackwardConcatenate(BackwardMerge): - """Backward LiRPA of Concatenate""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - if not isinstance(layer, DecomonConcatenate): - raise KeyError() - - self.axis = self.layer.axis - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - n_elem = len(inputs_list) - n_list = [self.inputs_outputs_spec.get_kerasinputshape(subinputs)[self.axis] for subinputs in inputs_list] - indices_split = np.cumsum(n_list[:-1]) - axis_w = self.axis - if axis_w != -1: - axis_w += 1 - w_u_out_list = K.split(w_u_out, indices_split, axis_w) - w_l_out_list = K.split(w_l_out, indices_split, axis_w) - b_u_out_list = K.split(b_u_out, indices_split, self.axis) - b_l_out_list = K.split(b_l_out, indices_split, self.axis) - - bounds = [[w_u_out_list[i], b_u_out_list[i], w_l_out_list[i], b_l_out_list[i]] for i in range(n_elem)] - - return bounds - - -class BackwardMultiply(BackwardMerge): - """Backward LiRPA of Multiply""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - if not isinstance(layer, DecomonMultiply): - raise KeyError() - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - n_elem = len(inputs_list) - - if n_elem != 2: - raise NotImplementedError("This layer is intended to merge only 2 layers.") - - return backward_multiply( - inputs_list[0], - inputs_list[1], - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=self.layer.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - ) - - -class BackwardDot(BackwardMerge): - """Backward LiRPA of Dot""" - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - super().__init__( - layer=layer, - rec=rec, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - **kwargs, - ) - if not isinstance(layer, DecomonDot): - raise KeyError() - - self.axes = [i for i in self.layer.axes] - self.op = BackwardAdd(self.layer) - - raise NotImplementedError() - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[List[BackendTensor]]: - w_u_out, b_u_out, w_l_out, b_l_out = get_identity_lirpa(inputs) - - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - n_elem = len(inputs_list) - - if n_elem != 2: - raise NotImplementedError("This layer is intended to merge only 2 layers.") - - # permute dimensions and reshape - inputs_0 = inputs_list[0] - inputs_1 = inputs_list[1] - - n_0 = len(inputs_0[0].shape) - 2 - n_1 = len(inputs_1[0].shape) - 2 - - input_0_permuted = permute_dimensions(inputs_0, self.axes[0], mode=self.mode, dc_decomp=self.dc_decomp) - input_1_permuted = permute_dimensions(inputs_1, self.axes[1], mode=self.mode, dc_decomp=self.dc_decomp) - - inputs_0_broadcasted = broadcast(input_0_permuted, n_1, -1, mode=self.mode, dc_decomp=self.dc_decomp) - inputs_1_broadcasted = broadcast(input_1_permuted, n_0, 2, mode=self.mode, dc_decomp=self.dc_decomp) - - inputs_multiplied = multiply( - inputs_0_broadcasted, - inputs_1_broadcasted, - dc_decomp=self.layer.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - - inputs_add = [] - for elem in split(inputs_multiplied, axis=self.axes[0], mode=self.mode): - inputs_add += elem - - bounds = self.op.call(inputs_add) - n = len(inputs_add) - bounds = [[1.0 / n * elem for elem in bounds_i] for bounds_i in bounds] - - # concatenate - shape = [-1, 1] + list(inputs_multiplied[0].shape[1:]) + list(w_u_out.shape[3:]) - - bounds_reshaped = [[K.reshape(elem[0], shape), elem[1], K.reshape(elem[2], shape), elem[3]] for elem in bounds] - w_u = K.concatenate([elem[0] for elem in bounds_reshaped], 1) - b_u = sum([elem[1] for elem in bounds_reshaped], 1) - w_l = K.concatenate([elem[2] for elem in bounds_reshaped], 1) - b_l = sum([elem[3] for elem in bounds_reshaped]) - - bounds_m_0, bounds_m_1 = backward_multiply( - inputs_0_broadcasted, - inputs_1_broadcasted, - w_u, - b_u, - w_l, - b_l, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - dc_decomp=self.dc_decomp, - ) - - shape_0 = [-1, 1] + list(input_0_permuted[0].shape[1:]) + list(w_u.shape[3:]) - shape_1 = [-1, 1] + list(input_1_permuted[0].shape[1:]) + list(w_u.shape[3:]) - - bounds_m_0 = [ - K.reshape(bounds_m_0[0], shape_0), - bounds_m_0[1], - K.reshape(bounds_m_0[2], shape_0), - bounds_m_0[3], - ] - bounds_m_1 = [ - K.reshape(bounds_m_1[0], shape_1), - bounds_m_1[1], - K.reshape(bounds_m_1[2], shape_1), - bounds_m_1[3], - ] - - axes = [i for i in self.axes] - if axes[0] == -1: - axes[0] = len(inputs_0[0].shape) - if axes[1] == -1: - axes[1] = len(inputs_1[0].shape) - - index_0 = np.arange(len(shape_0)) - index_0[2] = axes[0] + 1 - index_0[axes[0] + 1] = 2 - - index_1 = np.arange(len(shape_1)) - index_1[2] = axes[1] + 1 - index_1[axes[1] + 1] = 2 - - bounds_m_0 = [ - K.transpose(bounds_m_0[0], index_0), - bounds_m_0[1], - K.transpose(bounds_m_0[2], index_0), - bounds_m_0[3], - ] - bounds_m_1 = [ - K.transpose(bounds_m_1[0], index_1), - bounds_m_1[1], - K.transpose(bounds_m_1[2], index_1), - bounds_m_1[3], - ] - - return [bounds_m_0, bounds_m_1] diff --git a/src/decomon/backward_layers/convert.py b/src/decomon/backward_layers/convert.py deleted file mode 100644 index a4116463..00000000 --- a/src/decomon/backward_layers/convert.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any, Dict, Optional, Union - -from keras.layers import Layer - -import decomon.backward_layers.backward_layers -import decomon.backward_layers.backward_maxpooling -import decomon.backward_layers.backward_merge -from decomon.backward_layers.core import BackwardLayer -from decomon.core import BoxDomain, ForwardMode, PerturbationDomain, Slope - -_mapping_name2class: Dict[str, Any] = vars(decomon.backward_layers.backward_layers) -_mapping_name2class.update(vars(decomon.backward_layers.backward_merge)) -_mapping_name2class.update(vars(decomon.backward_layers.backward_maxpooling)) - - -def to_backward( - layer: Layer, - slope: Union[str, Slope] = Slope.V_SLOPE, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - finetune: bool = False, - **kwargs: Any, -) -> BackwardLayer: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - class_name = layer.__class__.__name__ - if class_name.startswith("Decomon"): - class_name = "".join(layer.__class__.__name__.split("Decomon")[1:]) - - backward_class_name = f"Backward{class_name}" - try: - class_ = _mapping_name2class[backward_class_name] - except KeyError: - raise NotImplementedError(f"The backward version of {class_name} is not yet implemented.") - backward_layer_name = f"{layer.name}_backward" - return class_( - layer, - slope=slope, - mode=mode, - perturbation_domain=perturbation_domain, - finetune=finetune, - dtype=layer.dtype, - name=backward_layer_name, - **kwargs, - ) diff --git a/src/decomon/backward_layers/core.py b/src/decomon/backward_layers/core.py deleted file mode 100644 index 92e98381..00000000 --- a/src/decomon/backward_layers/core.py +++ /dev/null @@ -1,144 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras -import numpy as np -from keras.layers import InputLayer, Layer, Wrapper - -from decomon.backward_layers.utils import get_identity_lirpa_shapes -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - get_affine, - get_ibp, -) -from decomon.layers.core import DecomonLayer -from decomon.types import BackendTensor - - -class BackwardLayer(ABC, Wrapper): - layer: Layer - _trainable_weights: List[keras.Variable] - - def __init__( - self, - layer: Layer, - rec: int = 1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - **kwargs: Any, - ): - kwargs.pop("slope", None) - kwargs.pop("finetune", None) - super().__init__(layer, **kwargs) - self.rec = rec - if isinstance(self.layer, DecomonLayer): - self.mode = self.layer.mode - self.perturbation_domain = self.layer.perturbation_domain - self.dc_decomp = self.layer.dc_decomp - else: - self.mode = ForwardMode(mode) - if perturbation_domain is None: - self.perturbation_domain = BoxDomain() - else: - self.perturbation_domain = perturbation_domain - self.dc_decomp = dc_decomp - self.inputs_outputs_spec = InputsOutputsSpec( - dc_decomp=self.dc_decomp, mode=self.mode, perturbation_domain=self.perturbation_domain - ) - - @property - def ibp(self) -> bool: - return get_ibp(self.mode) - - @property - def affine(self) -> bool: - return get_affine(self.mode) - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "rec": self.rec, - "mode": self.mode, - "perturbation_domain": self.perturbation_domain, - "dc_decomp": self.dc_decomp, - } - ) - return config - - @abstractmethod - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - """ - Args: - inputs - - Returns: - - """ - pass - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - input_shape - - Returns: - - """ - # generic case: nothing to do before call - pass - - def compute_output_shape(self, input_shape: List[Tuple[Optional[int], ...]]) -> List[Tuple[Optional[int], ...]]: - """Compute expected output shape according to input shape - - Will be called by symbolic calls on Keras Tensors. - - Args: - input_shape - - Returns: - - """ - if isinstance(self.layer, DecomonLayer): - decomon_input_shape = [inp.shape for inp in self.layer.input] - decomon_output_shape = [out.shape for out in self.layer.output] - keras_output_shape = self.inputs_outputs_spec.get_kerasinputshape_from_inputshapesformode( - decomon_output_shape - ) - keras_input_shape = self.inputs_outputs_spec.get_kerasinputshape_from_inputshapesformode( - decomon_input_shape - ) - else: # Keras layer - keras_output_shape = self.layer.output.shape - if isinstance(self.layer, InputLayer): - keras_input_shape = keras_output_shape - else: - keras_input_shape = self.layer.input.shape - - batch_size = keras_input_shape[0] - flattened_keras_output_shape = int(np.prod(keras_output_shape[1:])) # type: ignore - flattened_keras_input_shape = int(np.prod(keras_input_shape[1:])) # type: ignore - - b_shape = batch_size, flattened_keras_output_shape - w_shape = batch_size, flattened_keras_input_shape, flattened_keras_output_shape - - return [w_shape, b_shape, w_shape, b_shape] - - def freeze_weights(self) -> None: - pass - - def unfreeze_weights(self) -> None: - pass - - def freeze_alpha(self) -> None: - pass - - def unfreeze_alpha(self) -> None: - pass - - def reset_finetuning(self) -> None: - pass diff --git a/src/decomon/backward_layers/utils.py b/src/decomon/backward_layers/utils.py deleted file mode 100644 index a36425a7..00000000 --- a/src/decomon/backward_layers/utils.py +++ /dev/null @@ -1,669 +0,0 @@ -from typing import Any, List, Optional, Tuple, Union - -import keras.ops as K -import numpy as np -from keras.config import floatx -from keras.layers import Flatten - -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - get_affine, - get_ibp, - get_lower_box, - get_upper_box, -) -from decomon.keras_utils import BatchedIdentityLike -from decomon.layers.utils import sort -from decomon.types import Tensor -from decomon.utils import maximum, minus, relu_, subtract - - -def backward_add( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, -) -> List[List[Tensor]]: - """Backward LiRPA of inputs_0+inputs_1 - - Args: - inputs_0 - inputs_1 - w_u_out - b_u_out - w_l_out - b_l_out - perturbation_domain - mode - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - op_flat = Flatten(dtype=floatx()) # pas terrible a revoir - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x_0, u_c_0, w_u_0, b_u_0, l_c_0, w_l_0, b_l_0, h_0, g_0 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_0 - ) - x_1, u_c_1, w_u_1, b_u_1, l_c_1, w_l_1, b_l_1, h_1, g_1 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_1 - ) - - u_c_0 = op_flat(u_c_0) - u_c_1 = op_flat(u_c_1) - l_c_0 = op_flat(l_c_0) - l_c_1 = op_flat(l_c_1) - - upper_0 = get_upper_box(l_c_0, u_c_0, w_u_out, b_u_out) - upper_1 = get_upper_box(l_c_1, u_c_1, w_u_out, b_u_out) - lower_0 = get_lower_box(l_c_0, u_c_0, w_l_out, b_l_out) - lower_1 = get_lower_box(l_c_1, u_c_1, w_l_out, b_l_out) - - w_u_out_0 = w_u_out - b_u_out_0 = upper_1 - w_l_out_0 = w_l_out - b_l_out_0 = lower_1 - - w_u_out_1 = w_u_out - b_u_out_1 = upper_0 - w_l_out_1 = w_l_out - b_l_out_1 = lower_0 - - return [[w_u_out_0, b_u_out_0, w_l_out_0, b_l_out_0], [w_u_out_1, b_u_out_1, w_l_out_1, b_l_out_1]] - - -def backward_linear_prod( - x_0: Tensor, - bounds_x: List[Tensor], - back_bounds: List[Tensor], - perturbation_domain: Optional[PerturbationDomain] = None, -) -> List[Tensor]: - """Backward LiRPA of a subroutine prod - - Args: - bounds_x - back_bounds - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - z_value = K.cast(0.0, x_0.dtype) - o_value = K.cast(1.0, x_0.dtype) - - w_u_i, b_u_i, w_l_i, b_l_i = bounds_x - w_u, b_u, w_l, b_l = back_bounds - - if len(w_u_i.shape) > 3: - n_dim = w_u_i.shape[1] - w_u_i = K.reshape(w_u_i, (-1, n_dim, n_dim)) - w_l_i = K.reshape(w_l_i, (-1, n_dim, n_dim)) - b_u_i = K.reshape(b_u_i, (-1, n_dim)) - b_l_i = K.reshape(b_l_i, (-1, n_dim)) - - x_max = perturbation_domain.get_upper(x_0, w_u_i - w_l_i, b_u_i - b_l_i) - mask_b = o_value - K.sign(x_max) - mask_a = o_value - mask_b - - w_u_i_expanded = K.expand_dims(K.expand_dims(w_u_i, 1), -1) - w_l_i_expanded = K.expand_dims(K.expand_dims(w_l_i, 1), -1) - b_u_i_expanded = K.expand_dims(K.expand_dims(b_u_i, 1), -1) - b_l_i_expanded = K.expand_dims(K.expand_dims(b_l_i, 1), -1) - mask_a = K.expand_dims(K.expand_dims(mask_a, 1), -1) - mask_b = K.expand_dims(K.expand_dims(mask_b, 1), -1) - - w_u_pos = K.maximum(w_u, z_value) - w_u_neg = K.minimum(w_u, z_value) - w_l_pos = K.maximum(w_l, z_value) - w_l_neg = K.minimum(w_l, z_value) - - w_u_pos_expanded = K.expand_dims(w_u_pos, 2) - w_u_neg_expanded = K.expand_dims(w_u_neg, 2) - w_l_pos_expanded = K.expand_dims(w_l_pos, 2) - w_l_neg_expanded = K.expand_dims(w_l_neg, 2) - mask_a_expanded = K.expand_dims(mask_a, 2) - mask_b_expanded = K.expand_dims(mask_b, 2) - - w_u_out = K.sum( - mask_a_expanded * (w_u_pos_expanded * w_u_i_expanded + w_u_neg_expanded * w_l_i_expanded), 3 - ) + K.sum(K.expand_dims(w_u, 2) * mask_b_expanded * w_u_i_expanded, 3) - w_l_out = K.sum( - mask_a_expanded * (w_l_pos_expanded * w_l_i_expanded + w_l_neg_expanded * w_u_i_expanded), 3 - ) + K.sum(K.expand_dims(w_l, 2) * mask_b_expanded * w_l_i_expanded, 3) - - b_u_out = ( - K.sum(mask_a * (w_u_pos * b_u_i_expanded + w_u_neg * b_l_i_expanded), 2) - + K.sum(mask_b * (w_u * b_u_i_expanded), 2) - + b_u - ) - b_l_out = ( - K.sum(mask_a * (w_l_pos * b_l_i_expanded + w_l_neg * b_u_i_expanded), 2) - + K.sum(mask_b * (w_l * b_l_i_expanded), 2) - + b_l - ) - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - -def backward_maximum( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, - **kwargs: Any, -) -> List[List[Tensor]]: - """Backward LiRPA of maximum(inputs_0, inputs_1) - - Args: - inputs_0 - inputs_1 - w_u_out - b_u_out - w_l_out - b_l_out - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - input_step_a_0 = subtract( - inputs_0, inputs_1, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode - ) - input_step_0 = relu_( - input_step_a_0, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, **kwargs - ) - _, bounds_1 = backward_add( - input_step_0, - inputs_1, - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=perturbation_domain, - mode=mode, - dc_decomp=dc_decomp, - ) - - input_step_a_1 = subtract( - inputs_1, inputs_0, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode - ) - input_step_1 = relu_( - input_step_a_1, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, **kwargs - ) - _, bounds_0 = backward_add( - input_step_1, - inputs_0, - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=perturbation_domain, - mode=mode, - dc_decomp=dc_decomp, - ) - - return [bounds_0, bounds_1] - - -# convex hull of the maximum between two functions -def backward_max_( - inputs: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - axis: int = -1, - dc_decomp: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """Backward LiRPA of max - - Args: - inputs: list of tensors - dc_decomp: boolean that indicates - grad_bounds: boolean that indicates whether - perturbation_domain: the type of perturbation domain - axis: axis to perform the maximum - whether we return a difference of convex decomposition of our layer - we propagate upper and lower bounds on the values of the gradient - - Returns: - max operation along an axis - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - dtype = x.dtype - input_shape = inputs_outputs_spec.get_kerasinputshape(inputs) - max_dim = input_shape[axis] - if max_dim is None: - raise ValueError(f"Dimension {axis} corresponding to `axis` cannot be None") - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - empty_tensor_list = [empty_tensor] * max_dim - z_value = K.cast(0.0, dtype=dtype) - - # do some transpose so that the last axis is also at the end - if ibp: - u_c_list = K.split(u_c, max_dim, axis) - l_c_list = K.split(l_c, max_dim, axis) - u_c_tmp = u_c_list[0] + z_value * (u_c_list[0]) - l_c_tmp = l_c_list[0] + z_value * (l_c_list[0]) - else: - u_c_list, l_c_list = empty_tensor_list, empty_tensor_list - u_c_tmp, l_c_tmp = empty_tensor, empty_tensor - - if affine: - b_u_list = K.split(b_u, max_dim, axis) - b_l_list = K.split(b_l, max_dim, axis) - b_u_tmp = b_u_list[0] + z_value * (b_u_list[0]) - b_l_tmp = b_l_list[0] + z_value * (b_l_list[0]) - - if axis == -1: - w_u_list = K.split(w_u, max_dim, axis) - w_l_list = K.split(w_l, max_dim, axis) - else: - w_u_list = K.split(w_u, max_dim, axis + 1) - w_l_list = K.split(w_l, max_dim, axis + 1) - w_u_tmp = w_u_list[0] + z_value * (w_u_list[0]) - w_l_tmp = w_l_list[0] + z_value * (w_l_list[0]) - else: - b_u_list, b_l_list, w_u_list, w_l_list = ( - empty_tensor_list, - empty_tensor_list, - empty_tensor_list, - empty_tensor_list, - ) - b_u_tmp, b_l_tmp, w_u_tmp, w_l_tmp = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError() - else: - h_list, g_list = empty_tensor_list, empty_tensor_list - h_tmp, g_tmp = empty_tensor, empty_tensor - - outputs = [] - output_tmp = inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_tmp, w_u_tmp, b_u_tmp, l_c_tmp, w_l_tmp, b_l_tmp, h_tmp, g_tmp] - ) - for i in range(1, max_dim): - output_i = inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_list[i], w_u_list[i], b_u_list[i], l_c_list[i], w_l_list[i], b_l_list[i], h_list[i], g_list[i]] - ) - outputs.append([[elem for elem in output_tmp], output_i]) - output_tmp = maximum( - output_tmp, output_i, dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain - ) - - outputs = outputs[::-1] - bounds = [] - if len(outputs) > 0: - for input_0, input_1 in outputs: - bounds_0, bounds_1 = backward_maximum( - input_0, input_1, w_u_out, b_u_out, w_l_out, b_l_out, mode=mode, dc_decomp=dc_decomp, **kwargs - ) - bounds.append(bounds_1) - w_u_out, b_u_out, w_l_out, b_l_out = bounds_0 - bounds.append(bounds_0) - bounds = bounds[::-1] - - if axis < 0: - w_u_out = K.concatenate([b[0] for b in bounds], axis - 1) - w_l_out = K.concatenate([b[2] for b in bounds], axis - 1) - b_u_out = K.sum(K.concatenate([K.expand_dims(b[1], axis - 1) for b in bounds], axis - 1), axis - 1) - b_l_out = K.sum(K.concatenate([K.expand_dims(b[3], axis - 1) for b in bounds], axis - 1), axis - 1) - else: - w_u_out = K.concatenate([b[0] for b in bounds], axis) - w_l_out = K.concatenate([b[2] for b in bounds], axis) - b_u_out = K.sum(K.concatenate([K.expand_dims(b[1], axis) for b in bounds], axis), axis) - b_l_out = K.sum(K.concatenate([K.expand_dims(b[3], axis) for b in bounds], axis), axis) - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - -def backward_minimum( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, - **kwargs: Any, -) -> List[List[Tensor]]: - """Backward LiRPA of minimum(inputs_0, inputs_1) - - Args: - inputs_0 - inputs_1 - w_u_out - b_u_out - w_l_out - b_l_out - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - w_u_out, b_u_out, w_l_out, b_l_out = backward_minus(w_u_out, b_u_out, w_l_out, b_l_out) - bounds_0, bounds_1 = backward_maximum( - inputs_0, - inputs_1, - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=perturbation_domain, - mode=mode, - dc_decomp=dc_decomp, - **kwargs, - ) - - w_u_out_0, b_u_out_0, w_l_out_0, b_l_out_0 = bounds_0 - w_u_out_1, b_u_out_1, w_l_out_1, b_l_out_1 = bounds_1 - - bounds_0 = backward_minus(w_u_out_0, b_u_out_0, w_l_out_0, b_l_out_0) - bounds_1 = backward_minus(w_u_out_1, b_u_out_1, w_l_out_1, b_l_out_1) - - return [bounds_0, bounds_1] - - -def backward_minus( - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, -) -> List[Tensor]: - """Backward LiRPA of -x - - Args: - w_u_out - b_u_out - w_l_out - b_l_out - perturbation_domain - mode - - Returns: - - """ - return [-w_l_out, -b_l_out, -w_u_out, -b_u_out] - - -def backward_scale( - scale_factor: float, - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, -) -> List[Tensor]: - """Backward LiRPA of scale_factor*x - - Args: - scale_factor - w_u_out - b_u_out - w_l_out - b_l_out - - Returns: - - """ - - if scale_factor >= 0: - output = [scale_factor * w_u_out, b_u_out, scale_factor * w_l_out, b_l_out] - else: - output = [scale_factor * w_l_out, b_l_out, scale_factor * w_u_out, b_u_out] - - return output - - -def backward_subtract( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, -) -> List[List[Tensor]]: - """Backward LiRPA of inputs_0 - inputs_1 - - Args: - inputs_0 - inputs_1 - w_u_out - b_u_out - w_l_out - b_l_out - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - inputs_1 = minus(inputs_1, mode=mode, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain) - bounds_0, bounds_1 = backward_add( - inputs_0, - inputs_1, - w_u_out, - b_u_out, - w_l_out, - b_l_out, - perturbation_domain=perturbation_domain, - mode=mode, - dc_decomp=dc_decomp, - ) - - bounds_1 = [-bounds_1[0], bounds_1[1], -bounds_1[2], bounds_1[3]] - return [bounds_0, bounds_1] - - -def backward_multiply( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, -) -> List[List[Tensor]]: - """Backward LiRPA of element-wise multiply inputs_0*inputs_1 - - Args: - inputs_0 - inputs_1 - w_u_out - b_u_out - w_l_out - b_l_out - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x_0, u_c_0, w_u_0, b_u_0, l_c_0, w_l_0, b_l_0, h_0, g_0 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_0 - ) - x_1, u_c_1, w_u_1, b_u_1, l_c_1, w_l_1, b_l_1, h_1, g_1 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_1 - ) - - z_value = K.cast(0.0, u_c_0.dtype) - - n = int(np.prod(u_c_0.shape[1:])) - n_shape = [-1, n] - # broadcast dimensions if needed - n_out = len(w_u_out.shape[1:]) - for _ in range(n_out): - n_shape += [1] - a_u_0 = K.reshape(u_c_1, n_shape) - a_u_1 = K.reshape(u_c_0, n_shape) - - b_u_0 = K.reshape((K.maximum(l_c_0, z_value) * u_c_1 + K.minimum(l_c_0, z_value) * l_c_1 - u_c_1 * l_c_0), n_shape) - b_u_1 = K.reshape((K.maximum(l_c_1, z_value) * u_c_0 + K.minimum(l_c_1, z_value) * l_c_0 - u_c_0 * l_c_1), n_shape) - - a_l_0 = K.reshape(l_c_1, n_shape) - a_l_1 = K.reshape(l_c_0, n_shape) - b_l_0 = K.reshape((K.maximum(l_c_0, z_value) * l_c_1 + K.minimum(l_c_0, z_value) * u_c_1 - l_c_1 * l_c_0), n_shape) - b_l_1 = K.reshape((K.maximum(l_c_1, z_value) * l_c_0 + K.minimum(l_c_1, z_value) * u_c_0 - l_c_0 * l_c_1), n_shape) - - # upper - w_u_out_max = K.maximum(w_u_out, z_value) - w_u_out_min = K.minimum(w_u_out, z_value) - - w_u_out_0 = w_u_out_max * a_u_0 + w_u_out_min * a_l_0 - w_u_out_1 = w_u_out_max * a_u_1 + w_u_out_min * a_l_1 - - b_u_out_0 = K.sum(w_u_out_max * b_u_0, 1) + K.sum(w_u_out_min * b_l_0, 1) + b_u_out - b_u_out_1 = K.sum(w_u_out_max * b_u_1, 1) + K.sum(w_u_out_min * b_l_1, 1) + b_u_out - - # lower - w_l_out_max = K.maximum(w_l_out, z_value) - w_l_out_min = K.minimum(w_l_out, z_value) - - w_l_out_0 = w_l_out_max * a_l_0 + w_l_out_min * a_u_0 - w_l_out_1 = w_l_out_max * a_l_1 + w_l_out_min * a_u_1 - - b_l_out_0 = K.sum(w_l_out_max * b_l_0, 1) + K.sum(w_l_out_min * b_u_0, 1) + b_l_out - b_l_out_1 = K.sum(w_l_out_max * b_l_1, 1) + K.sum(w_l_out_min * b_u_1, 1) + b_l_out - - bounds_0 = [w_u_out_0, b_u_out_0, w_l_out_0, b_l_out_0] - bounds_1 = [w_u_out_1, b_u_out_1, w_l_out_1, b_l_out_1] - - return [bounds_0, bounds_1] - - -def backward_sort( - inputs: List[Tensor], - w_u_out: Tensor, - b_u_out: Tensor, - w_l_out: Tensor, - b_l_out: Tensor, - axis: int = -1, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, -) -> List[Tensor]: - """Backward LiRPA of sort - - Args: - inputs - w_u_out - b_u_out - w_l_out - b_l_out - axis - perturbation_domain - mode - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - z_value = K.cast(0.0, w_u_out.dtype) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - - # build fake inputs with no linearity - n_dim = int(np.prod(u_c.shape[1:])) - w_tmp = z_value * K.concatenate([u_c[:, None] * n_dim], 1) - - inputs_tmp = [x, u_c, w_tmp, u_c, l_c, w_tmp, l_c] - outputs_tmp = sort( - inputs_tmp, axis=axis, perturbation_domain=perturbation_domain, mode=ForwardMode.HYBRID, dc_decomp=False - ) - _, _, w_u_tmp, b_u_tmp, _, w_l_tmp, b_l_tmp = outputs_tmp - - # w_u_tmp (None, n_dim, y.shape[1:) - # w_u_out (None, 1, n_dim, n_out) - w_u_tmp = K.reshape(w_u_tmp, [-1, n_dim, n_dim]) # (None, n_dim, n_dim) - b_u_tmp = K.reshape(b_u_tmp, [-1, n_dim]) # (None, n_dim) - w_l_tmp = K.reshape(w_l_tmp, [-1, n_dim, n_dim]) - b_l_tmp = K.reshape(b_l_tmp, [-1, n_dim]) - - # combine with backward bounds - w_u_out_pos = K.maximum(w_u_out, z_value) # (None, 1, n_dim, n_out) - w_u_out_neg = K.minimum(w_u_out, z_value) - w_l_out_pos = K.maximum(w_l_out, z_value) - w_l_out_neg = K.minimum(w_l_out, z_value) - - w_u_out = K.sum(w_u_out_pos * K.expand_dims(w_u_tmp, -1) + w_u_out_pos * K.expand_dims(w_l_tmp, -1), 1) - w_l_out = K.sum(w_u_out_pos * K.expand_dims(w_u_tmp, -1) + w_u_out_pos * K.expand_dims(w_l_tmp, -1), 1) - b_u_out = b_u_out + K.sum(w_u_out_pos * b_u_tmp, 1) + K.sum(w_u_out_neg * b_l_tmp, 1) - b_l_out = b_l_out + K.sum(w_l_out_pos * b_l_tmp, 1) + K.sum(w_l_out_neg * b_u_tmp, 1) - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - -def get_identity_lirpa(inputs: List[Tensor]) -> List[Tensor]: - y = inputs[-1] - shape = int(np.prod(y.shape[1:])) - - y_flat = K.reshape(y, [-1, shape]) - - w_u_out, w_l_out = [BatchedIdentityLike()(y_flat)] * 2 - b_u_out, b_l_out = [K.zeros_like(y_flat)] * 2 - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - -def get_identity_lirpa_shapes(input_shapes: List[Tuple[Optional[int], ...]]) -> List[Tuple[Optional[int], ...]]: - y_shape = input_shapes[-1] - batch_size = y_shape[0] - flatten_dim = int(np.prod(y_shape[1:])) # type: ignore - - b_shape = batch_size, flatten_dim - w_shape = batch_size, flatten_dim, flatten_dim - - return [w_shape, b_shape, w_shape, b_shape] diff --git a/src/decomon/backward_layers/utils_conv.py b/src/decomon/backward_layers/utils_conv.py deleted file mode 100644 index ae0faf87..00000000 --- a/src/decomon/backward_layers/utils_conv.py +++ /dev/null @@ -1,159 +0,0 @@ -import warnings - -import keras -import keras.ops as K -import numpy as np -from keras.layers import Conv2D, Input -from keras.ops.image import extract_patches - -from decomon.types import BackendTensor - - -def get_toeplitz(conv_layer: Conv2D, flatten: bool = True) -> BackendTensor: - """Express formally the affine component of the convolution - Conv is a linear operator but its affine component is implicit - we use im2col and extract_patches to express the affine matrix - Note that this matrix is Toeplitz - - Args: - conv_layer: Keras Conv2D layer or Decomon Conv2D layer - flatten (optional): convert the affine component as a 2D matrix (n_in, n_out). Defaults to True. - - Returns: - the affine operator W: conv(x)= Wx + bias - """ - if conv_layer.dtype == "float16": - warnings.warn("Loss of precision for float16 !") - if conv_layer.data_format == "channels_last": - return get_toeplitz_channels_last(conv_layer, flatten) - else: - return get_toeplitz_channels_first(conv_layer, flatten) - - -def get_toeplitz_channels_last(conv_layer: Conv2D, flatten: bool = True) -> BackendTensor: - """Express formally the affine component of the convolution for data_format=channels_last - Conv is a linear operator but its affine component is implicit - we use im2col and extract_patches to express the affine matrix - Note that this matrix is Toeplitz - - Args: - conv_layer: Keras Conv2D layer or Decomon Conv2D layer - flatten (optional): convert the affine component as a 2D matrix (n_in, n_out). Defaults to True. - - Returns: - the affine operator W: conv(x)= Wx + bias - """ - - input = conv_layer.input - if isinstance(input, keras.KerasTensor): - input_shape = input.shape - else: # list of inputs - input_shape = input[-1].shape - _, w_in, h_in, c_in = input_shape - output = conv_layer.output - if isinstance(output, keras.KerasTensor): - output_shape = output.shape - else: # list of outputs - output_shape = output[-1].shape - _, w_out, h_out, c_out = output_shape - - kernel_filter = conv_layer.kernel - filter_size = kernel_filter.shape[0] - - diag = K.reshape(K.identity(w_in * h_in * c_in), (w_in * h_in * c_in, w_in, h_in, c_in)) - - diag_patches = extract_patches( - diag, - size=[filter_size, filter_size], - strides=list(conv_layer.strides), - dilation_rate=list(conv_layer.dilation_rate), - padding=conv_layer.padding, - ) - - diag_patches_ = K.reshape(diag_patches, (w_in, h_in, c_in, w_out, h_out, filter_size**2, c_in)) - - shape = list(range(len(diag_patches_.shape))) - shape[-1] -= 1 - shape[-2] += 1 - diag_patches_ = K.transpose(diag_patches_, shape) - kernel = conv_layer.kernel # (filter_size, filter_size, c_in, c_out) - - kernel = K.transpose(K.reshape(kernel, (filter_size**2, c_in, c_out)), (1, 2, 0))[ - None, None, None, None, None - ] # (1,1,c_in, c_out, filter_size^2) - - # element-wise multiplication is only compatible with float32 - diag_patches_ = K.cast(diag_patches_, "float32") - kernel = K.cast(kernel, "float32") - - w = K.sum(K.expand_dims(diag_patches_, -2) * kernel, (5, 7)) - # kernel shape: (1,1,1,1,1,c_in, c_out, filter_size^2) - # diag_patches shape: (w_in, h_in, c_in, w_out, h_out, c_in, 1, filter_size^2) - # (w_in, h_in, c_in, w_out, h_out, c_in, c_out, filter^2) - - w = K.cast(w, conv_layer.dtype) - # cast w for dtype - - if flatten: - return K.reshape(w, (w_in * h_in * c_in, w_out * h_out * c_out)) - else: - return w - - -def get_toeplitz_channels_first(conv_layer: Conv2D, flatten: bool = True) -> BackendTensor: - """Express formally the affine component of the convolution for data_format=channels_first - Conv is a linear operator but its affine component is implicit - we use im2col and extract_patches to express the affine matrix - Note that this matrix is Toeplitz - - Args: - conv_layer: Keras Conv2D layer or Decomon Conv2D layer - flatten (optional): convert the affine component as a 2D matrix (n_in, n_out). Defaults to True. - - Returns: - the affine operator W: conv(x)= Wx + bias - """ - - input = conv_layer.input - if isinstance(input, keras.KerasTensor): - input_shape = input.shape - else: # list of inputs - input_shape = input[-1].shape - _, c_in, w_in, h_in = input_shape - output = conv_layer.output - if isinstance(output, keras.KerasTensor): - output_shape = output.shape - else: # list of outputs - output_shape = output[-1].shape - _, c_out, w_out, h_out = output_shape - kernel_filter = conv_layer.kernel - filter_size = kernel_filter.shape[0] - - diag = K.reshape(K.identity(w_in * h_in * c_in), (w_in * h_in * c_in, w_in, h_in, c_in)) - - diag_patches = extract_patches(diag, [filter_size, filter_size], [1, 1], [1, 1], padding=conv_layer.padding) - - diag_patches_ = K.reshape(diag_patches, (w_in, h_in, c_in, w_out, h_out, filter_size**2, c_in)) - - shape = list(range(len(diag_patches_.shape))) - shape[-1] -= 1 - shape[-2] += 1 - diag_patches_ = K.transpose(diag_patches_, shape) - kernel = conv_layer.kernel # (filter_size, filter_size, c_in, c_out) - - kernel = K.transpose(K.reshape(kernel, (filter_size**2, c_in, c_out)), (1, 2, 0))[ - None, None, None, None, None - ] # (1,1,c_in, c_out, filter_size^2) - - w = K.sum(K.expand_dims(diag_patches_, -2) * kernel, (5, 7)) - # kernel shape: (1, 1, 1, 1, 1, c_in, c_out, filter_size^2) - # diag_patches shape: (w_in, h_in, c_in, w_out, h_out, c_in, 1, filter_size^2) - # (w_in, h_in, c_in, w_out, h_out, c_in, c_out, filter^2) - - w = K.cast(w, conv_layer.dtype) - # cast w for dtype - - if flatten: - return K.reshape(w, (w_in * h_in * c_in, w_out * h_out * c_out)) - else: - return w diff --git a/src/decomon/constants.py b/src/decomon/constants.py new file mode 100644 index 00000000..4bbc291c --- /dev/null +++ b/src/decomon/constants.py @@ -0,0 +1,42 @@ +from enum import Enum + + +class Slope(str, Enum): + V_SLOPE = "volume-slope" + A_SLOPE = "adaptative-slope" + S_SLOPE = "same-slope" + Z_SLOPE = "zero-lb" + O_SLOPE = "one-lb" + + +class Propagation(str, Enum): + """Propagation direction.""" + + FORWARD = "forward" + BACKWARD = "backward" + + +class ConvertMethod(str, Enum): + CROWN = "crown" + """Crown fully recursive: backward propagation using crown oracle. + + (spawning subcrowns for each non-linear layer) + + """ + CROWN_FORWARD_IBP = "crown-forward-ibp" + """Crown + forward ibp: backward propagation using a forward-ibp oracle.""" + CROWN_FORWARD_AFFINE = "crown-forward-affine" + """Crown + forward ibp: backward propagation using a forward-affine oracle.""" + CROWN_FORWARD_HYBRID = "crown-forward-hybrid" + """Crown + forward ibp: backward propagation using a forward-hybrid oracle.""" + FORWARD_IBP = "forward-ibp" + """Forward propagation of constant bounds.""" + FORWARD_AFFINE = "forward-affine" + """Forward propagation of affine bounds.""" + FORWARD_HYBRID = "forward-hybrid" + """Forward propagation of constant+affine bounds. + + After each layer, the tightest constant bounds is keep between the ibp one + and the affine one combined with perturbation domain input. + + """ diff --git a/src/decomon/core.py b/src/decomon/core.py deleted file mode 100644 index 52eedae5..00000000 --- a/src/decomon/core.py +++ /dev/null @@ -1,722 +0,0 @@ -from abc import ABC, abstractmethod -from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras.ops as K -import numpy as np -from keras.config import floatx - -from decomon.types import Tensor - - -class Option(str, Enum): - lagrangian = "lagrangian" - milp = "milp" - - -class Slope(str, Enum): - V_SLOPE = "volume-slope" - A_SLOPE = "adaptative-slope" - S_SLOPE = "same-slope" - Z_SLOPE = "zero-lb" - O_SLOPE = "one-lb" - - -class PerturbationDomain(ABC): - opt_option: Option - - def __init__(self, opt_option: Union[str, Option] = Option.milp): - self.opt_option = Option(opt_option) - - @abstractmethod - def get_upper(self, x: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - ... - - @abstractmethod - def get_lower(self, x: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - ... - - @abstractmethod - def get_nb_x_components(self) -> int: - ... - - def get_config(self) -> Dict[str, Any]: - return { - "opt_option": self.opt_option, - } - - def get_x_input_shape_wo_batchsize(self, original_input_dim: int) -> Tuple[int, ...]: - n_comp_x = self.get_nb_x_components() - if n_comp_x == 1: - return (original_input_dim,) - else: - return ( - n_comp_x, - original_input_dim, - ) - - -class BoxDomain(PerturbationDomain): - def get_upper(self, x: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - x_min = x[:, 0] - x_max = x[:, 1] - return get_upper_box(x_min=x_min, x_max=x_max, w=w, b=b, **kwargs) - - def get_lower(self, x: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - x_min = x[:, 0] - x_max = x[:, 1] - return get_lower_box(x_min=x_min, x_max=x_max, w=w, b=b, **kwargs) - - def get_nb_x_components(self) -> int: - return 2 - - -class GridDomain(PerturbationDomain): - pass - - -class VertexDomain(PerturbationDomain): - pass - - -class BallDomain(PerturbationDomain): - def __init__(self, eps: float, p: float = 2, opt_option: Option = Option.milp): - super().__init__(opt_option=opt_option) - self.eps = eps - # check on p - p_error_msg = "p must be a positive integer or np.inf" - try: - if p != np.inf and (int(p) != p or p <= 0): - raise ValueError(p_error_msg) - except: - raise ValueError(p_error_msg) - self.p = p - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "eps": self.eps, - "p": self.p, - } - ) - return config - - def get_lower(self, x: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - return get_lower_ball(x_0=x, eps=self.eps, p=self.p, w=w, b=b, **kwargs) - - def get_upper(self, x: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - return get_upper_ball(x_0=x, eps=self.eps, p=self.p, w=w, b=b, **kwargs) - - def get_nb_x_components(self) -> int: - return 1 - - -class ForwardMode(str, Enum): - """The different forward (from input to output) linear based relaxation perturbation analysis.""" - - IBP = "ibp" - """Propagation of constant bounds from input to output.""" - - AFFINE = "affine" - """Propagation of affine bounds from input to output.""" - - HYBRID = "hybrid" - """Propagation of constant and affines bounds from input to output.""" - - -def get_mode(ibp: bool = True, affine: bool = True) -> ForwardMode: - if ibp: - if affine: - return ForwardMode.HYBRID - else: - return ForwardMode.IBP - else: - return ForwardMode.AFFINE - - -def get_ibp(mode: Union[str, ForwardMode] = ForwardMode.HYBRID) -> bool: - mode = ForwardMode(mode) - if mode in [ForwardMode.HYBRID, ForwardMode.IBP]: - return True - return False - - -def get_affine(mode: Union[str, ForwardMode] = ForwardMode.HYBRID) -> bool: - mode = ForwardMode(mode) - if mode in [ForwardMode.HYBRID, ForwardMode.AFFINE]: - return True - return False - - -class InputsOutputsSpec: - """Storing specifications for inputs and outputs of decomon/backward layer/model.""" - - def __init__( - self, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - model_input_dim: int = -1, - ): - """ - Args: - dc_decomp: boolean that indicates whether we return a - difference of convex decomposition of our layer - mode: type of Forward propagation (ibp, affine, or hybrid) - perturbation_domain: type of perturbation domain (box, ball, ...) - - """ - - self.model_input_dim = model_input_dim - self.mode = ForwardMode(mode) - self.dc_decomp = dc_decomp - self.perturbation_domain: PerturbationDomain - if perturbation_domain is None: - self.perturbation_domain = BoxDomain() - else: - self.perturbation_domain = perturbation_domain - - @property - def nb_tensors(self) -> int: - if self.mode == ForwardMode.HYBRID: - nb_tensors = 7 - elif self.mode == ForwardMode.IBP: - nb_tensors = 2 - elif self.mode == ForwardMode.AFFINE: - nb_tensors = 5 - else: - raise NotImplementedError(f"unknown forward mode {self.mode}") - - if self.dc_decomp: - nb_tensors += 2 - - return nb_tensors - - @property - def ibp(self) -> bool: - return get_ibp(self.mode) - - @property - def affine(self) -> bool: - return get_affine(self.mode) - - def get_kerasinputshape(self, inputsformode: List[Tensor]) -> Tuple[Optional[int], ...]: - return inputsformode[-1].shape - - def get_kerasinputshape_from_inputshapesformode( - self, inputshapesformode: List[Tuple[Optional[int], ...]] - ) -> Tuple[Optional[int], ...]: - return inputshapesformode[-1] - - def get_fullinputshapes_from_inputshapesformode( - self, - inputshapesformode: List[Tuple[Optional[int], ...]], - ) -> List[Tuple[Optional[int], ...]]: - nb_tensors = self.nb_tensors - empty_shape: Tuple[Optional[int], ...] = tuple() - if self.dc_decomp: - if self.mode == ForwardMode.HYBRID: - ( - x_shape, - u_c_shape, - w_u_shape, - b_u_shape, - l_c_shape, - w_l_shape, - b_l_shape, - h_shape, - g_shape, - ) = inputshapesformode[:nb_tensors] - elif self.mode == ForwardMode.IBP: - u_c_shape, l_c_shape, h_shape, g_shape = inputshapesformode[:nb_tensors] - batchsize = u_c_shape[0] - x_shape = (batchsize,) + self.perturbation_domain.get_x_input_shape_wo_batchsize(self.model_input_dim) - b_shape = tuple(u_c_shape) - w_shape = tuple(u_c_shape) + (u_c_shape[-1],) - x_shape, w_u_shape, b_u_shape, w_l_shape, b_l_shape = ( - x_shape, - w_shape, - b_shape, - w_shape, - b_shape, - ) - elif self.mode == ForwardMode.AFFINE: - x_shape, w_u_shape, b_u_shape, w_l_shape, b_l_shape, h_shape, g_shape = inputshapesformode[:nb_tensors] - u_l_shape = tuple(b_u_shape) - u_c_shape, l_c_shape = u_l_shape, u_l_shape - else: - raise ValueError(f"Unknown mode {self.mode}") - else: - h_shape, g_shape = empty_shape, empty_shape - if self.mode == ForwardMode.HYBRID: - x_shape, u_c_shape, w_u_shape, b_u_shape, l_c_shape, w_l_shape, b_l_shape = inputshapesformode[ - :nb_tensors - ] - elif self.mode == ForwardMode.IBP: - u_c_shape, l_c_shape = inputshapesformode[:nb_tensors] - batchsize = u_c_shape[0] - x_shape = (batchsize,) + self.perturbation_domain.get_x_input_shape_wo_batchsize(self.model_input_dim) - b_shape = tuple(u_c_shape) - w_shape = tuple(u_c_shape) + (u_c_shape[-1],) - x_shape, w_u_shape, b_u_shape, w_l_shape, b_l_shape = ( - x_shape, - w_shape, - b_shape, - w_shape, - b_shape, - ) - elif self.mode == ForwardMode.AFFINE: - x_shape, w_u_shape, b_u_shape, w_l_shape, b_l_shape = inputshapesformode[:nb_tensors] - u_l_shape = tuple(b_u_shape) - u_c_shape, l_c_shape = u_l_shape, u_l_shape - else: - raise ValueError(f"Unknown mode {self.mode}") - - return [x_shape, u_c_shape, w_u_shape, b_u_shape, l_c_shape, w_l_shape, b_l_shape, h_shape, g_shape] - - def get_fullinputs_from_inputsformode( - self, inputsformode: List[Tensor], compute_ibp_from_affine: bool = True, tight: bool = True - ) -> List[Tensor]: - """ - - Args: - inputsformode: - compute_ibp_from_affine: if True and mode == affine, compute ibp bounds from affine ones - with get_upper/get_lower - tight: if True and mode==hybrid, compute tight ibp bounds, i.e. take tighter bound between - - the ones from inputs, and - - the ones computed from affine bounds with get_upper/get_lower - - Returns: - - """ - dtype = inputsformode[0].dtype - nb_tensors = self.nb_tensors - nonelike_tensor = self.get_empty_tensor(dtype=dtype) - if self.dc_decomp: - if self.mode == ForwardMode.HYBRID: - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputsformode[:nb_tensors] - elif self.mode == ForwardMode.IBP: - u_c, l_c, h, g = inputsformode[:nb_tensors] - x, w_u, b_u, w_l, b_l = ( - nonelike_tensor, - nonelike_tensor, - nonelike_tensor, - nonelike_tensor, - nonelike_tensor, - ) - elif self.mode == ForwardMode.AFFINE: - x, w_u, b_u, w_l, b_l, h, g = inputsformode[:nb_tensors] - u_c, l_c = nonelike_tensor, nonelike_tensor - else: - raise ValueError(f"Unknown mode {self.mode}") - else: - h, g = nonelike_tensor, nonelike_tensor - if self.mode == ForwardMode.HYBRID: - x, u_c, w_u, b_u, l_c, w_l, b_l = inputsformode[:nb_tensors] - elif self.mode == ForwardMode.IBP: - u_c, l_c = inputsformode[:nb_tensors] - x, w_u, b_u, w_l, b_l = ( - nonelike_tensor, - nonelike_tensor, - nonelike_tensor, - nonelike_tensor, - nonelike_tensor, - ) - elif self.mode == ForwardMode.AFFINE: - x, w_u, b_u, w_l, b_l = inputsformode[:nb_tensors] - u_c, l_c = nonelike_tensor, nonelike_tensor - else: - raise ValueError(f"Unknown mode {self.mode}") - - compute_ibp_from_affine = (compute_ibp_from_affine and self.mode == ForwardMode.AFFINE) or ( - tight and self.mode == ForwardMode.HYBRID - ) - - if compute_ibp_from_affine: - u_c_affine = self.perturbation_domain.get_upper(x, w_u, b_u) - l_c_affine = self.perturbation_domain.get_lower(x, w_l, b_l) - if self.mode == ForwardMode.AFFINE: - u_c = u_c_affine - l_c = l_c_affine - else: - u_c = K.minimum(u_c, u_c_affine) - l_c = K.maximum(l_c, l_c_affine) - - return [x, u_c, w_u, b_u, l_c, w_l, b_l, h, g] - - def get_fullinputs_by_type_from_inputsformode_to_merge( - self, inputsformode: List[Tensor], compute_ibp_from_affine: bool = False, tight: bool = True - ) -> List[List[Tensor]]: - """ - - Args: - inputsformode: - compute_ibp_from_affine: if True and mode == affine, compute ibp bounds from affine ones - with get_upper/get_lower - tight: if True and mode==hybrid, compute tight ibp bounds, i.e. take tighter bound between - - the ones from inputs, and - - the ones computed from affine bounds with get_upper/get_lower - - Returns: - - """ - dtype = inputsformode[0].dtype - nb_tensors_by_input = self.nb_tensors - nb_inputs = len(inputsformode) // nb_tensors_by_input - nonelike_tensor = self.get_empty_tensor(dtype=dtype) - nonelike_tensor_list = [nonelike_tensor] * nb_inputs - if self.mode == ForwardMode.HYBRID: - inputs_x = inputsformode[0::nb_tensors_by_input] - inputs_u_c = inputsformode[1::nb_tensors_by_input] - inputs_w_u = inputsformode[2::nb_tensors_by_input] - inputs_b_u = inputsformode[3::nb_tensors_by_input] - inputs_l_c = inputsformode[4::nb_tensors_by_input] - inputs_w_l = inputsformode[5::nb_tensors_by_input] - inputs_b_l = inputsformode[6::nb_tensors_by_input] - elif self.mode == ForwardMode.IBP: - inputs_u_c = inputsformode[0::nb_tensors_by_input] - inputs_l_c = inputsformode[1::nb_tensors_by_input] - inputs_x, inputs_w_u, inputs_b_u, inputs_w_l, inputs_b_l = ( - nonelike_tensor_list, - nonelike_tensor_list, - nonelike_tensor_list, - nonelike_tensor_list, - nonelike_tensor_list, - ) - elif self.mode == ForwardMode.AFFINE: - inputs_x = inputsformode[0::nb_tensors_by_input] - inputs_w_u = inputsformode[1::nb_tensors_by_input] - inputs_b_u = inputsformode[2::nb_tensors_by_input] - inputs_w_l = inputsformode[3::nb_tensors_by_input] - inputs_b_l = inputsformode[4::nb_tensors_by_input] - inputs_u_c, inputs_l_c = nonelike_tensor_list, nonelike_tensor_list - else: - raise ValueError(f"Unknown mode {self.mode}") - - if self.dc_decomp: - inputs_h = inputsformode[nb_tensors_by_input - 2 :: nb_tensors_by_input] - inputs_g = inputsformode[nb_tensors_by_input - 1 :: nb_tensors_by_input] - else: - inputs_h, inputs_g = nonelike_tensor_list, nonelike_tensor_list - - # compute ibp bounds from affine bounds - compute_ibp_from_affine = (compute_ibp_from_affine and self.mode == ForwardMode.AFFINE) or ( - tight and self.mode == ForwardMode.HYBRID - ) - - if compute_ibp_from_affine: - for i in range(len(inputs_x)): - u_c_affine = self.perturbation_domain.get_upper(inputs_x[i], inputs_w_u[i], inputs_b_u[i]) - l_c_affine = self.perturbation_domain.get_lower(inputs_x[i], inputs_w_l[i], inputs_b_l[i]) - if self.mode == ForwardMode.AFFINE: - inputs_u_c[i] = u_c_affine - inputs_l_c[i] = l_c_affine - else: - inputs_u_c[i] = K.minimum(inputs_u_c[i], u_c_affine) - inputs_l_c[i] = K.maximum(inputs_l_c[i], l_c_affine) - - return [inputs_x, inputs_u_c, inputs_w_u, inputs_b_u, inputs_l_c, inputs_w_l, inputs_b_l, inputs_h, inputs_g] - - def split_inputsformode_to_merge(self, inputsformode: List[Any]) -> List[List[Any]]: - n_comp = self.nb_tensors - return [inputsformode[n_comp * i : n_comp * (i + 1)] for i in range(len(inputsformode) // n_comp)] - - def extract_inputsformode_from_fullinputs(self, inputs: List[Tensor]) -> List[Tensor]: - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs - if self.mode == ForwardMode.HYBRID: - inputsformode = [x, u_c, w_u, b_u, l_c, w_l, b_l] - elif self.mode == ForwardMode.IBP: - inputsformode = [u_c, l_c] - elif self.mode == ForwardMode.AFFINE: - inputsformode = [x, w_u, b_u, w_l, b_l] - else: - raise ValueError(f"Unknown mode {self.mode}") - if self.dc_decomp: - inputsformode += [h, g] - return inputsformode - - def extract_inputshapesformode_from_fullinputshapes( - self, inputshapes: List[Tuple[Optional[int], ...]] - ) -> List[Tuple[Optional[int], ...]]: - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputshapes - if self.mode == ForwardMode.HYBRID: - inputshapesformode = [x, u_c, w_u, b_u, l_c, w_l, b_l] - elif self.mode == ForwardMode.IBP: - inputshapesformode = [u_c, l_c] - elif self.mode == ForwardMode.AFFINE: - inputshapesformode = [x, w_u, b_u, w_l, b_l] - else: - raise ValueError(f"Unknown mode {self.mode}") - if self.dc_decomp: - inputshapesformode += [h, g] - return inputshapesformode - - def extract_outputsformode_from_fulloutputs(self, outputs: List[Tensor]) -> List[Tensor]: - return self.extract_inputsformode_from_fullinputs(outputs) - - @staticmethod - def get_empty_tensor(dtype: Optional[str] = None) -> Tensor: - if dtype is None: - dtype = floatx() - return K.convert_to_tensor([], dtype=dtype) - - -def get_upper_box(x_min: Tensor, x_max: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - """#compute the max of an affine function - within a box (hypercube) defined by its extremal corners - - Args: - x_min: lower bound of the box domain - x_max: upper bound of the box domain - w: weights of the affine function - b: bias of the affine function - - Returns: - max_(x >= x_min, x<=x_max) w*x + b - """ - - if len(w.shape) == len(b.shape): # identity function - return x_max - - # split into positive and negative components - z_value = K.cast(0.0, dtype=x_min.dtype) - w_pos = K.maximum(w, z_value) - w_neg = K.minimum(w, z_value) - - x_min_out = x_min + z_value * x_min - x_max_out = x_max + z_value * x_max - - for _ in range(len(w.shape) - len(x_max.shape)): - x_min_out = K.expand_dims(x_min_out, -1) - x_max_out = K.expand_dims(x_max_out, -1) - - return K.sum(w_pos * x_max_out + w_neg * x_min_out, 1) + b - - -def get_lower_box(x_min: Tensor, x_max: Tensor, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - """ - Args: - x_min: lower bound of the box domain - x_max: upper bound of the box domain - w_l: weights of the affine lower bound - b_l: bias of the affine lower bound - - Returns: - min_(x >= x_min, x<=x_max) w*x + b - """ - - if len(w.shape) == len(b.shape): - return x_min - - z_value = K.cast(0.0, dtype=x_min.dtype) - - w_pos = K.maximum(w, z_value) - w_neg = K.minimum(w, z_value) - - x_min_out = x_min + z_value * x_min - x_max_out = x_max + z_value * x_max - - for _ in range(len(w.shape) - len(x_max.shape)): - x_min_out = K.expand_dims(x_min_out, -1) - x_max_out = K.expand_dims(x_max_out, -1) - - return K.sum(w_pos * x_min_out + w_neg * x_max_out, 1) + b - - -def get_lq_norm(x: Tensor, p: float, axis: int = -1) -> Tensor: - """compute Lp norm (p=1 or 2) - - Args: - x: tensor - p: the power must be an integer in (1, 2) - axis: the axis on which we compute the norm - - Returns: - ||w||^p - """ - if p == 1: - x_q = K.max(K.abs(x), axis) - elif p == 2: - x_q = K.sqrt(K.sum(K.power(x, p), axis)) - else: - raise NotImplementedError("p must be equal to 1 or 2") - - return x_q - - -def get_upper_ball(x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - """max of an affine function over an Lp ball - - Args: - x_0: the center of the ball - eps: the radius - p: the type of Lp norm considered - w: weights of the affine function - b: bias of the affine function - - Returns: - max_(|x - x_0|_p<= eps) w*x + b - """ - if len(w.shape) == len(b.shape): - return x_0 + eps - - if p == np.inf: - # compute x_min and x_max according to eps - x_min = x_0 - eps - x_max = x_0 + eps - return get_upper_box(x_min, x_max, w, b) - - else: - # use Holder's inequality p+q=1 - # ||w||_q*eps + w*x_0 + b - - if len(kwargs): - return get_upper_ball_finetune(x_0, eps, p, w, b, **kwargs) - - upper = eps * get_lq_norm(w, p, axis=1) + b - - for _ in range(len(w.shape) - len(x_0.shape)): - x_0 = K.expand_dims(x_0, -1) - - return K.sum(w * x_0, 1) + upper - - -def get_lower_ball(x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - """min of an affine fucntion over an Lp ball - - Args: - x_0: the center of the ball - eps: the radius - p: the type of Lp norm considered - w: weights of the affine function - b: bias of the affine function - - Returns: - min_(|x - x_0|_p<= eps) w*x + b - """ - if len(w.shape) == len(b.shape): - return x_0 - eps - - if p == np.inf: - # compute x_min and x_max according to eps - x_min = x_0 - eps - x_max = x_0 + eps - return get_lower_box(x_min, x_max, w, b) - - else: - # use Holder's inequality p+q=1 - # ||w||_q*eps + w*x_0 + b - - if len(kwargs): - return get_lower_ball_finetune(x_0, eps, p, w, b, **kwargs) - - lower = -eps * get_lq_norm(w, p, axis=1) + b - - for _ in range(len(w.shape) - len(x_0.shape)): - x_0 = K.expand_dims(x_0, -1) - - return K.sum(w * x_0, 1) + lower - - -def get_lower_ball_finetune(x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - if "finetune_lower" in kwargs and "upper" in kwargs or "lower" in kwargs: - alpha = kwargs["finetune_lower"] - # assume alpha is the same shape as w, minus the batch dimension - n_shape = len(w.shape) - 2 - z_value = K.cast(0.0, dtype=w.dtype) - - if "upper" and "lower" in kwargs: - upper = kwargs["upper"] # flatten vector - lower = kwargs["lower"] # flatten vector - - upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) - lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) - - w_alpha = w * alpha[None] - w_alpha_bar = w * (1 - alpha) - - score_box = K.sum(K.maximum(z_value, w_alpha_bar) * lower_reshaped, 1) + K.sum( - K.minimum(z_value, w_alpha_bar) * upper_reshaped, 1 - ) - score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) - - return score_box + score_ball - - if "upper" in kwargs: - upper = kwargs["upper"] # flatten vector - upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) - - w_alpha = K.minimum(z_value, w) * alpha[None] + K.maximum(z_value, w) - w_alpha_bar = K.minimum(z_value, w) * (1 - alpha[None]) - - score_box = K.sum(K.minimum(z_value, w_alpha_bar) * upper_reshaped, 1) - score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) - - return score_box + score_ball - - if "lower" in kwargs: - lower = kwargs["lower"] # flatten vector - lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) - - w_alpha = K.maximum(z_value, w) * alpha[None] + K.minimum(z_value, w) - w_alpha_bar = K.maximum(z_value, w) * (1 - alpha[None]) - - score_box = K.sum(K.maximum(z_value, w_alpha_bar) * lower_reshaped, 1) - score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) - - return score_box + score_ball - - return get_lower_ball(x_0, eps, p, w, b) - - -def get_upper_ball_finetune(x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, **kwargs: Any) -> Tensor: - if "finetune_upper" in kwargs and "upper" in kwargs or "lower" in kwargs: - alpha = kwargs["finetune_upper"] - # assume alpha is the same shape as w, minus the batch dimension - n_shape = len(w.shape) - 2 - z_value = K.cast(0.0, dtype=w.dtype) - - if "upper" and "lower" in kwargs: - upper = kwargs["upper"] # flatten vector - lower = kwargs["lower"] # flatten vector - - upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) - lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) - - w_alpha = w * alpha[None] - w_alpha_bar = w * (1 - alpha) - - score_box = K.sum(K.maximum(z_value, w_alpha_bar) * upper_reshaped, 1) + K.sum( - K.minimum(z_value, w_alpha_bar) * lower_reshaped, 1 - ) - score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) - - return score_box + score_ball - - if "upper" in kwargs: - upper = kwargs["upper"] # flatten vector - upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) - - w_alpha = K.minimum(z_value, w) * alpha[None] + K.maximum(z_value, w) - w_alpha_bar = K.minimum(z_value, w) * (1 - alpha[None]) - - score_box = K.sum(K.maximum(z_value, w_alpha_bar) * upper_reshaped, 1) - score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) - - return score_box + score_ball - - if "lower" in kwargs: - lower = kwargs["lower"] # flatten vector - lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) - - w_alpha = K.maximum(z_value, w) * alpha[None] + K.minimum(z_value, w) - w_alpha_bar = K.maximum(z_value, w) * (1 - alpha[None]) - - score_box = K.sum(K.minimum(z_value, w_alpha_bar) * lower_reshaped, 1) - score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) - - return score_box + score_ball - - return get_upper_ball(x_0, eps, p, w, b) diff --git a/src/decomon/keras_utils.py b/src/decomon/keras_utils.py index 0a6b6547..38af5247 100644 --- a/src/decomon/keras_utils.py +++ b/src/decomon/keras_utils.py @@ -1,9 +1,8 @@ -from typing import Any, List +from typing import Optional import keras import keras.ops as K -import numpy as np -from keras.layers import Layer +from keras.layers import Dot, Layer, Reshape from decomon.types import BackendTensor, Tensor @@ -13,193 +12,200 @@ BACKEND_JAX = "jax" -class LinalgSolve(keras.Operation): - """Keras operation mimicking tensorflow.linalg.solve().""" +def batch_multid_dot( + x: Tensor, + y: Tensor, + nb_merging_axes: Optional[int] = None, + missing_batchsize: tuple[bool, bool] = (False, False), + diagonal: tuple[bool, bool] = (False, False), +) -> Tensor: + """Dot product of tensors by batch, along multiple axes - def compute_output_spec(self, matrix: keras.KerasTensor, rhs: keras.KerasTensor) -> keras.KerasTensor: - rhs_shape = rhs.shape - rhs_dtype = getattr(rhs, "dtype", type(rhs)) - rhs_sparse = getattr(rhs, "sparse", False) - return keras.KerasTensor( - shape=rhs_shape, - dtype=rhs_dtype, - sparse=rhs_sparse, - ) - - def call(self, matrix: BackendTensor, rhs: BackendTensor) -> BackendTensor: - backend = keras.config.backend() - if backend == BACKEND_TENSORFLOW: - import tensorflow as tf - - return tf.linalg.solve(matrix=matrix, rhs=rhs) - elif backend == BACKEND_PYTORCH: - import torch - - return torch.linalg.solve(A=matrix, B=rhs) - else: - raise NotImplementedError(f"linalg_solve() not yet implemented for backend {backend}.") + Hypothesis: we sum over last axes of x and first axes (skipping the batch one) of x. + The 1-dimensional equivalent would be `batch_dot(x,y, axes=(-1, 1))` + or `keras.layers.Dot(axes=(-1, 1))(x,y)` -class BatchedIdentityLike(keras.Operation): - """Keras Operation creating an identity tensor with shape (including batch_size) based on input. - - The output shape is tuple(x.shape) + (x.shape[-1],), the tensor being the identity - along the 2 last dimensions. - - """ - - def call(self, x: BackendTensor) -> Tensor: - input_shape = x.shape - identity_tensor = K.identity(input_shape[-1], dtype=x.dtype) - n_repeat = int(np.prod(input_shape[:-1])) - return K.reshape(K.repeat(identity_tensor[None], n_repeat, axis=0), tuple(input_shape) + (-1,)) - - def compute_output_spec(self, x: Tensor) -> keras.KerasTensor: - x_shape = x.shape - x_type = getattr(x, "dtype", type(x)) - x_sparse = getattr(x, "sparse", False) - return keras.KerasTensor( - shape=tuple(x_shape) + (x_shape[-1],), - dtype=x_type, - sparse=x_sparse, - ) - + Args: + x: + y: + nb_merging_axes: number of axes to be merged. + By default, all (non-batch) axes of x,i.e. + len(x.shape) if missing_batchsize[0] else len(x.shape) - 1 + missing_batchsize: specify if a tensor is missing the batch dimension, for x and y. + In that case, the corresponding tensor is broadcasted accordingly. + diagonal: specify is a tensor is only represented by its diagonal. See below for an example. -class BatchedDiagLike(keras.Operation): - """Keras Operation transforming last dimension into a diagonal tensor. + Returns: - The output shape is tuple(x.shape) + (x.shape[-1],). - When fixing all but 2 last dimensions, the output tensor is a square tensor - whose main diagonal is the input tensor with same first dimensions fixed, and 0 elsewhere. + For performance reasons, instead of actually repeating the tensor `batchsize` along a new first axis, + we rather use `keras.ops.tensordot` directly on tensors without broadcasting them. - This is a replacement for tensorflow.linalg.diag(). + Notes: + - The dimensions of axes along which we perform the dot product + (i.e. x.shape[-nb_merging_axes:] and y.shape[1:1 + nb_merging_axes] when no batch axe is missing) should match. + - diagonal example: if x is diagonal and missing its batch axis, it means that the full tensor x is retrieve with + x_full = K.reshape(K.diag(K.ravel(x)), x.shape + x.shape) + With the batch axis, the above computation should be made batch element by batch element. """ - - def call(self, x: BackendTensor) -> Tensor: - return K.concatenate([K.diag(K.ravel(w_part))[None] for w_part in K.split(x, len(x), axis=0)], axis=0) - - def compute_output_spec(self, x: Tensor) -> keras.KerasTensor: - x_shape = x.shape - x_type = getattr(x, "dtype", type(x)) - x_sparse = getattr(x, "sparse", False) - return keras.KerasTensor( - shape=tuple(x_shape) + (x_shape[-1],), - dtype=x_type, - sparse=x_sparse, + diag_x, diag_y = diagonal + missing_batchsize_x, missing_batchsize_y = missing_batchsize + nb_batch_axe_x = 0 if missing_batchsize_x else 1 + nb_batch_axe_y = 0 if missing_batchsize_y else 1 + if nb_merging_axes is None: + nb_merging_axes = len(x.shape) - nb_batch_axe_x + + # check shapes compatibility + x_merging_axes_shape = x.shape[-nb_merging_axes:] + y_merging_axes_shape = y.shape[nb_batch_axe_y : nb_batch_axe_y + nb_merging_axes] + if x_merging_axes_shape != y_merging_axes_shape: + raise ValueError( + "Incompatible input shapes: " + f"Merging axes dimension should match. " + f"Found {x_merging_axes_shape} and {y_merging_axes_shape}. " + f"Full shapes: {x.shape} and {y.shape}." ) + # Special cases: diagonal entries (represented only by their diagonal) + if diag_x and diag_y: + # all inputs diagonal: we keep a diagonal output (with batch axis if one input has one) + return x * y + elif diag_x: + # reshape to make broadcast possible + nb_missing_batch_axe_x = nb_batch_axe_y - nb_batch_axe_x + nb_missing_axes_x_wo_batch = len(y.shape) - nb_batch_axe_y - len(x.shape) + nb_batch_axe_x + new_shape = nb_missing_batch_axe_x * (1,) + x.shape + (1,) * nb_missing_axes_x_wo_batch + return K.reshape(x, new_shape) * y + elif diag_y: + # reshape necessary for broadcast, only if y has a batch axis + if not missing_batchsize_y: + nb_missing_axes_y_wo_batch = len(x.shape) - nb_batch_axe_x - len(y.shape) + nb_batch_axe_y + new_shape = y.shape[:1] + (1,) * nb_missing_axes_y_wo_batch + y.shape[1:] + return x * K.reshape(y, new_shape) + else: + return x * y + else: + # switch on missing batch axe (e.g. with affine layer representation like Dense's kernel) + if missing_batchsize_y: + return K.tensordot(x, y, axes=nb_merging_axes) + elif missing_batchsize_x: + # axes along which summing + merging_axes_x = list(range(-nb_merging_axes, 0)) + merging_axes_y = list(range(nb_batch_axe_y, nb_batch_axe_y + nb_merging_axes)) + # transposition to make to put back batch axe at the beginning + nb_axes_after_merge = len(x.shape) + len(y.shape) - 2 * nb_merging_axes + nb_axes_after_merge_from_x = len(x.shape) - nb_merging_axes + transpose_indices = ( + (nb_axes_after_merge_from_x,) + + tuple(range(nb_axes_after_merge_from_x)) + + tuple(range(nb_axes_after_merge_from_x + 1, nb_axes_after_merge)) + ) + return K.transpose(K.tensordot(x, y, axes=[merging_axes_x, merging_axes_y]), transpose_indices) + else: + new_x_shape = tuple(x.shape[1:-nb_merging_axes]) + (-1,) + new_y_shape = (-1,) + tuple(y.shape[nb_merging_axes + 1 :]) + return Dot(axes=(-1, 1))([Reshape(new_x_shape)(x), Reshape(new_y_shape)(y)]) -def is_symbolic_tensor(x: Tensor) -> bool: - """Check whether the tensor is symbolic or not. - - Works even during backend calls made by layers without actual compute_output_shape(). - In this case, x is not KerasTensor anymore but a backend Tensor with None in its shape. - - """ - return None in x.shape - - -def get_weight_index(layer: Layer, weight: keras.Variable) -> int: - """Get weight index among layer tracked weights - - Args: - layer: layer we are looking - weight: weight supposed to be part of tracked weights by the layer - - Returns: - the index of the weight in `layer.weights` list - Raises: - IndexError: if `weight` is not part of `layer.weights` +def add_tensors( + x: Tensor, + y: Tensor, + missing_batchsize: tuple[bool, bool] = (False, False), + diagonal: tuple[bool, bool] = (False, False), +) -> Tensor: + """Sum tensors in a compatible way. - """ - indexes = [i for i, w in enumerate(layer.weights) if w is weight] - try: - return indexes[0] - except IndexError: - raise IndexError(f"The weight {weight} is not tracked by the layer {layer}.") + Generate broadcastable versions of the tensors before summing them, + depending on 2 characteristics: + - missing batchsize? + - diagonal representation? -def get_weight_index_from_name(layer: Layer, weight_name: str) -> int: - """Get weight index among layer tracked weights + We only have to modify the tensors if a characteristic differ between them. + More precisely: + - missing batchsize: we add a batch axis (of dimension 1) + - diagonal representation: we make a full representation of the tensor Args: - layer: layer we are looking - weight_name: name of the weight supposed to be part of tracked weights by the layer + x: + y: + missing_batchsize: + diagonal: Returns: - the index of the weight in `layer.weights` list - - Raises: - AttributeError: if `weight_name` is not the name of an attribute of `layer` - IndexError: if the corresponding layer attribute is not part of `layer.weights` """ - weight = getattr(layer, weight_name) - try: - return get_weight_index(layer=layer, weight=weight) - except IndexError: - raise IndexError(f"The weight {weight_name} is not tracked by the layer {layer}.") - - -def reset_layer(new_layer: Layer, original_layer: Layer, weight_names: List[str]) -> None: - """Reset some weights of a layer by using the weights of another layer. + # get broadcastable version of the tensors + x_broadcastable, y_broadcastable = _convert_to_broacastable_tensors( + x=x, y=y, missing_batchsize=missing_batchsize, diagonal=diagonal + ) + # operate on broadcastable tensors + return x_broadcastable + y_broadcastable + + +def _convert_to_broacastable_tensors( + x: Tensor, + y: Tensor, + missing_batchsize: tuple[bool, bool], + diagonal: tuple[bool, bool], +) -> tuple[Tensor, Tensor]: + x_broadcastable = x + y_broadcastable = y + x_full_shape = x.shape + y_full_shape = y.shape + if missing_batchsize == (True, False): + batchsize = y_full_shape[0] + x_full_shape = (batchsize,) + x_full_shape + x_broadcastable = x_broadcastable[None] + elif missing_batchsize == (False, True): + batchsize = x_full_shape[0] + y_full_shape = (batchsize,) + y_full_shape + y_broadcastable = y_broadcastable[None] + if diagonal == (True, False): + x_broadcastable, x_full_shape = _convert_from_diag_to_generic( + x_broadcastable=x_broadcastable, x_full_shape=x_full_shape, missing_batchsize=all(missing_batchsize) + ) + elif diagonal == (False, True): + y_broadcastable, y_full_shape = _convert_from_diag_to_generic( + x_broadcastable=y_broadcastable, x_full_shape=y_full_shape, missing_batchsize=all(missing_batchsize) + ) + # check shapes + if x_full_shape != y_full_shape: + raise ValueError( + f"Incompatible shapes: {x.shape} and {y.shape}, " + f"with missing_batchsize={missing_batchsize} and diagonal={diagonal}." + ) - Args: - new_layer: the decomon layer whose weights will be updated - original_layer: the layer used to update the weights - weight_names: the names of the weights to update + return x_broadcastable, y_broadcastable - Returns: - """ - if not original_layer.built: - raise ValueError(f"the layer {original_layer.name} has not been built yet") - if not new_layer.built: - raise ValueError(f"the layer {new_layer.name} has not been built yet") +def _convert_from_diag_to_generic( + x_broadcastable: Tensor, x_full_shape: tuple[int, ...], missing_batchsize: bool = False +) -> tuple[Tensor, tuple[int, ...]]: + if missing_batchsize: + x_full_shape = x_full_shape + x_full_shape + new_shape = x_broadcastable.shape + x_broadcastable.shape + x_broadcastable = K.reshape(K.diag(K.ravel(x_broadcastable)), new_shape) else: - new_params = new_layer.get_weights() - original_params = original_layer.get_weights() - for weight_name in weight_names: - new_params[get_weight_index_from_name(new_layer, weight_name)] = original_params[ - get_weight_index_from_name(original_layer, weight_name) - ] - new_layer.set_weights(new_params) - - -def reset_layer_all_weights(new_layer: Layer, original_layer: Layer) -> None: - """Reset all the weights of a layer by using the weights of another layer. - - Args: - new_layer: the decomon layer whose weights will be updated - original_layer: the layer used to update the weights - - Returns: + x_full_shape = x_full_shape[:1] + x_full_shape[1:] + x_full_shape[1:] + partial_new_shape = x_broadcastable.shape[1:] + x_broadcastable.shape[1:] + x_broadcastable = K.concatenate( + [ + K.reshape(K.diag(K.ravel(x_broadcastable[i])), partial_new_shape)[None] + for i in range(len(x_broadcastable)) + ], + axis=0, + ) - """ - reset_layer(new_layer=new_layer, original_layer=original_layer, weight_names=[w.name for w in new_layer.weights]) + return x_broadcastable, x_full_shape -def share_layer_all_weights( - original_layer: Layer, - new_layer: Layer, -) -> None: - """Share all the weights of an already built layer to another unbuilt layer. +def is_a_merge_layer(layer: Layer) -> bool: + return hasattr(layer, "_merge_function") - Args: - original_layer: the layer used to share the weights - new_layer: the new layer which will be buit and will share the weights of the original layer - Returns: - - """ - share_weights_and_build( - new_layer=new_layer, original_layer=original_layer, weight_names=[w.name for w in original_layer.weights] - ) - - -def share_weights_and_build(original_layer: Layer, new_layer: Layer, weight_names: List[str]) -> None: +def share_weights_and_build(original_layer: Layer, new_layer: Layer, weight_names: list[str]) -> None: """Share the weights specidifed by names of an already built layer to another unbuilt layer. We assume that each weight is also an original_laer's attribute whose name is the weight name. @@ -247,21 +253,3 @@ def share_weights_and_build(original_layer: Layer, new_layer: Layer, weight_name setattr(new_layer, w_name, w) # untrack the not used anymore weight new_layer._tracker.untrack(w_to_drop) - - -def check_if_single_shape(shape: Any) -> bool: - """ - - Args: - input_shape: - - Returns: - - """ - if isinstance(shape, list) and shape and isinstance(shape[0], (int, type(None))): - return True - - if not isinstance(shape, (list, tuple, dict)): - shape = tuple(shape) - - return isinstance(shape, tuple) and len(shape) > 0 and isinstance(shape[0], (int, type(None))) diff --git a/src/decomon/layers/__init__.py b/src/decomon/layers/__init__.py index e69de29b..17d47221 100644 --- a/src/decomon/layers/__init__.py +++ b/src/decomon/layers/__init__.py @@ -0,0 +1,4 @@ +from .activations.activation import DecomonActivation, DecomonReLU +from .core.dense import DecomonDense +from .layer import DecomonLayer +from .merging.add import DecomonAdd diff --git a/src/decomon/layers/activations.py b/src/decomon/layers/activations.py deleted file mode 100644 index 128d449e..00000000 --- a/src/decomon/layers/activations.py +++ /dev/null @@ -1,580 +0,0 @@ -import warnings -from typing import Any, Callable, Dict, List, Optional, Union - -import keras.ops as K -import numpy as np - -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - Slope, - get_affine, - get_ibp, -) -from decomon.layers.core import DecomonLayer -from decomon.layers.utils import exp, expand_dims, frac_pos, multiply, softplus_, sum -from decomon.types import Tensor -from decomon.utils import ( - get_linear_hull_s_shape, - minus, - relu_, - sigmoid_prime, - softsign_prime, - tanh_prime, -) - -ELU = "elu" -SELU = "selu" -SOFTPLUS = "softplus" -SOFTSIGN = "softsign" -SOFTMAX = "softmax" -RELU = "relu" -SIGMOID = "sigmoid" -TANH = "tanh" -EXPONENTIAL = "exponential" -HARD_SIGMOID = "hard_sigmoid" -LINEAR = "linear" -GROUP_SORT_2 = "GroupSort2" - - -def relu( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - alpha: float = 0.0, - max_value: Optional[float] = None, - threshold: float = 0.0, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """ - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - alpha: see Keras official documentation - max_value: see Keras official documentation - threshold: see Keras official documentation - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if threshold != 0: - raise NotImplementedError() - - if not alpha and max_value is None: - # default values: return relu_(x) = max(x, 0) - return relu_( - inputs, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, slope=slope, **kwargs - ) - - raise NotImplementedError() - - -def linear_hull_s_shape( - inputs: List[Tensor], - func: Callable[[Tensor], Tensor] = K.sigmoid, - f_prime: Callable[[Tensor], Tensor] = sigmoid_prime, - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, -) -> List[Tensor]: - """Computing the linear hull of s-shape functions - - Args: - inputs: list of input tensors - func: the function (sigmoid, tanh, softsign...) - f_prime: the derivative of the function (sigmoid_prime...) - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if ibp: - u_c_out = func(u_c) - l_c_out = func(l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if affine: - w_u_0, b_u_0, w_l_0, b_l_0 = get_linear_hull_s_shape( - inputs, func=func, f_prime=f_prime, perturbation_domain=perturbation_domain, mode=mode - ) - if len(w_u.shape) == len(b_u.shape): - # it happens with the convert function to spare memory footprint - n_dim = int(np.prod(w_u.shape[1:])) - M = np.reshape( - np.diag([K.cast(1, dtype=w_u_0.dtype)] * n_dim), [1, n_dim] + list(w_u.shape[1:]) - ) # usage de numpy pb pour les types - w_u_out = M * K.concatenate([K.expand_dims(w_u_0, 1)] * n_dim, 1) - w_l_out = M * K.concatenate([K.expand_dims(w_l_0, 1)] * n_dim, 1) - b_u_out = b_u_0 - b_l_out = b_l_0 - else: - w_u_out = K.expand_dims(w_u_0, 1) * w_u # pour l'instant - b_u_out = b_u_0 + w_u_0 * b_u - w_l_out = K.expand_dims(w_l_0, 1) * w_l - b_l_out = b_l_0 + w_l_0 * b_l - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError() - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def sigmoid( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Sigmoid activation function . - `1 / (1 + exp(-x))`. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - func = K.sigmoid - f_prime = sigmoid_prime - return linear_hull_s_shape( - inputs, func, f_prime, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, slope=slope - ) - - -def tanh( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Hyperbolic activation function. - `tanh(x)=2*sigmoid(2*x)+1` - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - func = K.tanh - f_prime = tanh_prime - return linear_hull_s_shape( - inputs, func, f_prime, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, slope=slope - ) - - -def hard_sigmoid( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Hard sigmoid activation function. - Faster to compute than sigmoid activation. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - raise NotImplementedError() - - -def elu( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Exponential linear unit. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - raise NotImplementedError() - - -def selu( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Scaled Exponential Linear Unit (SELU). - - SELU is equal to: `scale * elu(x, alpha)`, where alpha and scale - are predefined constants. The values of `alpha` and `scale` are - chosen so that the mean and variance of the inputs are preserved - between two consecutive layers as long as the weights are initialized - correctly (see `lecun_normal` initialization) and the number of inputs - is "large enough" (see references for more information). - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - raise NotImplementedError() - - -def linear( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA foe Linear (i.e. identity) activation function. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - return inputs - - -def exponential( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Exponential activation function. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - return exp(inputs, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, slope=slope, **kwargs) - - -def softplus( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Softplus activation function `log(exp(x) + 1)`. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - - return softplus_(inputs, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, slope=slope) - - -def softsign( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Softsign activation function `x / (abs(x) + 1)`. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - func = K.softsign - f_prime = softsign_prime - return linear_hull_s_shape( - inputs, func, f_prime, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, slope=slope - ) - - -def softmax( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - axis: int = -1, - clip: bool = True, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA for Softmax activation function. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: type of convex input domain (None or dict) - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: see Keras official documentation - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if dc_decomp: - raise NotImplementedError() - mode = ForwardMode(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - outputs_exp = exponential( - minus(inputs, mode=mode, perturbation_domain=perturbation_domain), - dc_decomp=dc_decomp, - perturbation_domain=perturbation_domain, - mode=mode, - slope=slope, - ) - outputs = expand_dims( - frac_pos( - sum(outputs_exp, axis=axis, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode), - dc_decomp=dc_decomp, - perturbation_domain=perturbation_domain, - mode=mode, - ), - mode=mode, - axis=axis, - perturbation_domain=perturbation_domain, - ) - outputs = multiply(outputs_exp, outputs, mode=mode, perturbation_domain=perturbation_domain) - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - outputs, compute_ibp_from_affine=False - ) - if ibp: - o_value = K.cast(1.0, dtype=u_c.dtype) - z_value = K.cast(0.0, dtype=u_c.dtype) - u_c = K.minimum(u_c, o_value) - l_c = K.maximum(l_c, z_value) - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs([x, u_c, w_u, b_u, l_c, w_l, b_l, h, g]) - - -def group_sort_2( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - data_format: str = "channels_last", - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - raise NotImplementedError() - - -def deserialize(name: str) -> Callable[..., List[Tensor]]: - """Get the activation from name. - - Args: - name: name of the method. - among the implemented Keras activation function. - - Returns: - the activation function - """ - name = name.lower() - - if name == SOFTMAX: - return softmax - elif name == ELU: - return elu - elif name == SELU: - return selu - elif name == SOFTPLUS: - return softplus - elif name == SOFTSIGN: - return softsign - elif name == SIGMOID: - return sigmoid - elif name == TANH: - return tanh - elif name == RELU: - return relu - elif name == EXPONENTIAL: - return exponential - elif name == LINEAR: - return linear - elif name == GROUP_SORT_2: - return group_sort_2 - else: - raise ValueError(f"Could not interpret activation function identifier: {name}") - - -def get(identifier: Any) -> Callable[..., List[Tensor]]: - """Get the `identifier` activation function. - - Args: - identifier: None or str, name of the function. - - Returns: - The activation function, `linear` if `identifier` is None. - - """ - if identifier is None: - return linear - elif isinstance(identifier, str): - return deserialize(identifier) - elif callable(identifier): - if isinstance(identifier, DecomonLayer): - warnings.warn( - "Do not pass a layer instance (such as {identifier}) as the " - "activation argument of another layer. Instead, advanced " - "activation layers should be used just like any other " - "layer in a model.".format(identifier=identifier.__class__.__name__) - ) - return identifier - else: - raise ValueError("Could not interpret " "activation function identifier:", identifier) diff --git a/src/decomon/backward_layers/__init__.py b/src/decomon/layers/activations/__init__.py similarity index 100% rename from src/decomon/backward_layers/__init__.py rename to src/decomon/layers/activations/__init__.py diff --git a/src/decomon/layers/activations/activation.py b/src/decomon/layers/activations/activation.py new file mode 100644 index 00000000..6390d5dc --- /dev/null +++ b/src/decomon/layers/activations/activation.py @@ -0,0 +1,248 @@ +from collections.abc import Callable +from typing import Any, Optional + +import keras +import keras.ops as K +from keras import Layer +from keras.activations import linear, relu, softsign +from keras.config import epsilon +from keras.layers import Activation + +from decomon.constants import Propagation, Slope +from decomon.layers.activations.utils import ( + get_linear_hull_relu, + get_linear_hull_s_shape, + softsign_prime, +) +from decomon.layers.layer import DecomonLayer +from decomon.perturbation_domain import PerturbationDomain +from decomon.types import Tensor + + +class DecomonBaseActivation(DecomonLayer): + """Base class for decomon layers corresponding to activation layers.""" + + def __init__( + self, + layer: Layer, + perturbation_domain: Optional[PerturbationDomain] = None, + ibp: bool = True, + affine: bool = True, + propagation: Propagation = Propagation.FORWARD, + model_input_shape: Optional[tuple[int, ...]] = None, + model_output_shape: Optional[tuple[int, ...]] = None, + slope: Slope = Slope.V_SLOPE, + **kwargs: Any, + ): + super().__init__( + layer=layer, + perturbation_domain=perturbation_domain, + ibp=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + **kwargs, + ) + self.slope = slope + + def forward_ibp_propagate(self, lower: Tensor, upper: Tensor) -> tuple[Tensor, Tensor]: + """Propagate ibp bounds through the activation layer. + + By default, we simply apply the activation function on bounds. + This is correct when the activation is an increasing function (like relu). + This is not correct when the activation is not monotonic (like gelu). + + Args: + lower: + upper: + + Returns: + + """ + return self.layer.activation(lower), self.layer.activation(upper) + + +class DecomonActivation(DecomonBaseActivation): + """Wrapping class for all decomon activation layer. + + Correspond to keras Activation layer. + Will wrap a more specific activation Layer (DecomonRelu, DecomonLinear, ...) + as it exists also a dedicated Relu layer in keras. + + """ + + layer: Activation + decomon_activation: DecomonBaseActivation + + def __init__( + self, + layer: Layer, + perturbation_domain: Optional[PerturbationDomain] = None, + ibp: bool = True, + affine: bool = True, + propagation: Propagation = Propagation.FORWARD, + model_input_shape: Optional[tuple[int, ...]] = None, + model_output_shape: Optional[tuple[int, ...]] = None, + slope: Slope = Slope.V_SLOPE, + **kwargs: Any, + ): + super().__init__( + layer=layer, + perturbation_domain=perturbation_domain, + ibp=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + **kwargs, + ) + decomon_activation_class = get(self.layer.activation) + self.decomon_activation = decomon_activation_class( + layer=layer, + perturbation_domain=perturbation_domain, + ibp=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + **kwargs, + ) + # linearity of the wrapping activation layer is decided by the wrapped activation layer + self.linear = self.decomon_activation.linear + # so do the inputs/outputs format + self.inputs_outputs_spec = self.decomon_activation.inputs_outputs_spec + + def get_affine_representation(self) -> tuple[Tensor, Tensor]: + return self.decomon_activation.get_affine_representation() + + def get_affine_bounds(self, lower: Tensor, upper: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]: + return self.decomon_activation.get_affine_bounds(lower=lower, upper=upper) + + def forward_affine_propagate( + self, input_affine_bounds: list[Tensor], input_constant_bounds: list[Tensor] + ) -> tuple[Tensor, Tensor, Tensor, Tensor]: + return self.decomon_activation.forward_affine_propagate( + input_affine_bounds=input_affine_bounds, input_constant_bounds=input_constant_bounds + ) + + def backward_affine_propagate( + self, output_affine_bounds: list[Tensor], input_constant_bounds: list[Tensor] + ) -> tuple[Tensor, Tensor, Tensor, Tensor]: + return self.decomon_activation.backward_affine_propagate( + output_affine_bounds=output_affine_bounds, input_constant_bounds=input_constant_bounds + ) + + def forward_ibp_propagate(self, lower: Tensor, upper: Tensor) -> tuple[Tensor, Tensor]: + return self.decomon_activation.forward_ibp_propagate(lower=lower, upper=upper) + + def build(self, input_shape: list[tuple[Optional[int], ...]]) -> None: + self.decomon_activation.build(input_shape=input_shape) + super().build(input_shape=input_shape) + + def compute_output_shape(self, input_shape: list[tuple[Optional[int], ...]]) -> list[tuple[Optional[int], ...]]: + return self.decomon_activation.compute_output_shape(input_shape) + + def call(self, inputs: list[Tensor]) -> list[Tensor]: + return self.decomon_activation.call(inputs=inputs) + + +class DecomonLinear(DecomonBaseActivation): + linear = True + + def call(self, inputs: list[Tensor]) -> list[Tensor]: + ( + affine_bounds_to_propagate, + constant_oracle_bounds, + perturbation_domain_inputs, + ) = self.inputs_outputs_spec.split_inputs(inputs=inputs) + return self.inputs_outputs_spec.flatten_outputs( + affine_bounds_propagated=affine_bounds_to_propagate, constant_bounds_propagated=constant_oracle_bounds + ) + + def compute_output_spec(self, inputs: list[keras.KerasTensor]) -> list[keras.KerasTensor]: + return self.call(inputs=inputs) + + def compute_output_shape( + self, + input_shape: list[tuple[Optional[int], ...]], + ) -> list[tuple[Optional[int], ...]]: + ( + affine_bounds_to_propagate_shape, + constant_oracle_bounds_shape, + perturbation_domain_inputs_shape, + ) = self.inputs_outputs_spec.split_input_shape(input_shape=input_shape) + return self.inputs_outputs_spec.flatten_outputs_shape( + affine_bounds_propagated_shape=affine_bounds_to_propagate_shape, + constant_bounds_propagated_shape=constant_oracle_bounds_shape, # type: ignore + ) + + +class DecomonReLU(DecomonBaseActivation): + diagonal = True + + def get_affine_bounds(self, lower: Tensor, upper: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]: + w_u, b_u, w_l, b_l = get_linear_hull_relu(upper=upper, lower=lower, slope=self.slope) + return w_l, b_l, w_u, b_u + + +class DecomonSoftSign(DecomonBaseActivation): + diagonal = True + + def get_affine_bounds(self, lower: Tensor, upper: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]: + func = softsign + func_prime = softsign_prime + + # chord + w_chord = (func(upper) - func(lower)) / K.maximum(K.cast(epsilon(), dtype=upper.dtype), upper - lower) + b_chord = func(lower) - w_chord * lower + + # tangent at upper + w_tangent_upper = func_prime(upper) + b_tangent_upper = func(upper) - w_tangent_upper * upper + + # tangent at lower + w_tangent_lower = func_prime(lower) + b_tangent_lower = func(lower) - w_tangent_lower * lower + + # compare slopes to choose between chord and tangent + w_l = K.where( + w_chord <= w_tangent_lower, + w_chord, + w_tangent_lower, + ) + b_l = K.where( + w_chord <= w_tangent_lower, + b_chord, + b_tangent_lower, + ) + + w_u = K.where( + w_chord <= w_tangent_upper, + w_chord, + w_tangent_upper, + ) + b_u = K.where( + w_chord <= w_tangent_upper, + b_chord, + b_tangent_upper, + ) + + return w_l, b_l, w_u, b_u + + +MAPPING_KERAS_ACTIVATION_TO_DECOMON_ACTIVATION: dict[Callable[[Tensor], Tensor], type[DecomonBaseActivation]] = { + linear: DecomonLinear, + relu: DecomonReLU, + softsign: DecomonSoftSign, +} + + +def get(identifier: Any) -> type[DecomonBaseActivation]: + """Retrieve a decomon activation layer via an identifier.""" + try: + return MAPPING_KERAS_ACTIVATION_TO_DECOMON_ACTIVATION[identifier] + except KeyError: + raise NotImplementedError(f"No decomon layer existing for activation function {identifier}") diff --git a/src/decomon/layers/activations/utils.py b/src/decomon/layers/activations/utils.py new file mode 100644 index 00000000..0621813f --- /dev/null +++ b/src/decomon/layers/activations/utils.py @@ -0,0 +1,387 @@ +from collections.abc import Callable +from typing import Any, Union + +import numpy as np +from keras import ops as K +from keras.src.backend import epsilon + +from decomon.constants import Slope +from decomon.types import Tensor + +TensorFunction = Callable[[Tensor], Tensor] + + +def sigmoid_prime(x: Tensor) -> Tensor: + """Derivative of sigmoid + + Args: + x + + Returns: + + """ + + s_x = K.sigmoid(x) + return s_x * (K.cast(1, dtype=x.dtype) - s_x) + + +def tanh_prime(x: Tensor) -> Tensor: + """Derivative of tanh + + Args: + x + + Returns: + + """ + + s_x = K.tanh(x) + return K.cast(1, dtype=x.dtype) - K.power(s_x, K.cast(2, dtype=x.dtype)) + + +def get_linear_hull_relu( + upper: Tensor, + lower: Tensor, + slope: Union[str, Slope], + upper_g: float = 0.0, + lower_g: float = 0.0, + **kwargs: Any, +) -> list[Tensor]: + slope = Slope(slope) + # in case upper=lower, this cases are + # considered with index_dead and index_linear + alpha = (K.relu(upper) - K.relu(lower)) / K.maximum(K.cast(epsilon(), dtype=upper.dtype), upper - lower) + + # scaling factor for the upper bound on the relu + # see README + + w_u = alpha + b_u = K.relu(lower) - alpha * lower + z_value = K.cast(0.0, dtype=upper.dtype) + o_value = K.cast(1.0, dtype=upper.dtype) + + if slope == Slope.V_SLOPE: + # 1 if upper<=-lower else 0 + index_a = -K.clip(K.sign(upper + lower) - o_value, -o_value, z_value) + + # 1 if upper>-lower else 0 + index_b = o_value - index_a + w_l = index_b + b_l = z_value * b_u + + elif slope == Slope.A_SLOPE: + w_l = K.clip(K.sign(w_u - 0.5), 0, 1) + b_l = z_value * b_u + + elif slope == Slope.Z_SLOPE: + w_l = z_value * w_u + b_l = z_value * b_u + + elif slope == Slope.O_SLOPE: + w_l = z_value * w_u + o_value + b_l = z_value * b_u + + elif slope == Slope.S_SLOPE: + w_l = w_u + b_l = z_value * b_u + + else: + raise NotImplementedError(f"Not implemented for slope {slope}") + + if "upper_grid" in kwargs: + raise NotImplementedError() + + gamma = o_value + if "finetune" in kwargs: + # retrieve variables to optimize the slopes + gamma = kwargs["finetune"][None] + + w_l = gamma * w_l + (o_value - gamma) * (o_value - w_l) + + # check inactive relu state: u<=0 + index_dead = -K.clip(K.sign(upper) - o_value, -o_value, z_value) # =1 if inactive state + index_linear = K.clip(K.sign(lower) + o_value, z_value, o_value) # 1 if linear state + + w_u = (o_value - index_dead) * w_u + w_l = (o_value - index_dead) * w_l + b_u = (o_value - index_dead) * b_u + b_l = (o_value - index_dead) * b_l + + w_u = (o_value - index_linear) * w_u + index_linear + w_l = (o_value - index_linear) * w_l + index_linear + b_u = (o_value - index_linear) * b_u + b_l = (o_value - index_linear) * b_l + + return [w_u, b_u, w_l, b_l] + + +def get_linear_softplus_hull(upper: Tensor, lower: Tensor, slope: Union[str, Slope], **kwargs: Any) -> list[Tensor]: + slope = Slope(slope) + # in case upper=lower, this cases are + # considered with index_dead and index_linear + u_c = K.softsign(upper) + l_c = K.softsign(lower) + alpha = (u_c - l_c) / K.maximum(K.cast(epsilon(), dtype=upper.dtype), (upper - lower)) + w_u = alpha + b_u = -alpha * lower + l_c + + z_value = K.cast(0.0, dtype=upper.dtype) + o_value = K.cast(1.0, dtype=upper.dtype) + + if slope == Slope.V_SLOPE: + # 1 if upper<=-lower else 0 + index_a = -K.clip(K.sign(upper + lower) - o_value, -o_value, z_value) + # 1 if upper>-lower else 0 + index_b = o_value - index_a + w_l = index_b + b_l = z_value * b_u + elif slope == Slope.Z_SLOPE: + w_l = z_value * w_u + b_l = z_value * b_u + elif slope == Slope.O_SLOPE: + w_l = z_value * w_u + o_value + b_l = z_value * b_u + elif slope == Slope.S_SLOPE: + w_l = w_u + b_l = z_value * b_u + else: + raise ValueError(f"Unknown slope {slope}") + + index_dead = -K.clip(K.sign(upper) - o_value, -o_value, z_value) + + w_u = (o_value - index_dead) * w_u + w_l = (o_value - index_dead) * w_l + b_u = (o_value - index_dead) * b_u + b_l = (o_value - index_dead) * b_l + + if "finetune" in kwargs: + # weighted linear combination + alpha_u, alpha_l = kwargs["finetune"] + alpha_u = alpha_u[None] + alpha_l = alpha_l[None] + + w_u = alpha_u * w_u + b_u = alpha_u * b_u + (o_value - alpha_u) * K.maximum(upper, z_value) + + w_l = alpha_l * w_l + b_l = alpha_l * b_l + (o_value - alpha_l) * K.maximum(lower, z_value) + + return [w_u, b_u, w_l, b_l] + + +def relu_prime(x: Tensor) -> Tensor: + """Derivative of relu + + Args: + x + + Returns: + + """ + + return K.clip(K.sign(x), K.cast(0, dtype=x.dtype), K.cast(1, dtype=x.dtype)) + + +def softsign_prime(x: Tensor) -> Tensor: + """Derivative of softsign + + Args: + x + + Returns: + + """ + + return K.cast(1.0, dtype=x.dtype) / K.power(K.cast(1.0, dtype=x.dtype) + K.abs(x), K.cast(2, dtype=x.dtype)) + + +def get_linear_hull_s_shape( + lower: Tensor, + upper: Tensor, + func: TensorFunction = K.sigmoid, + f_prime: TensorFunction = sigmoid_prime, +) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Computing the linear hull of shape functions given the pre activation neurons + + Args: + lower: lower bound on keras input + upper: upper bound on keras input + func: the function (sigmoid, tanh, softsign...) + f_prime: the derivative of the function (sigmoid_prime...) + perturbation_domain: the type of convex input domain + mode: type of Forward propagation (ibp, affine, or hybrid) + + Returns: + w_l, b_l, w_u, b_u: affine bounds on activation layer + """ + + dtype = lower.dtype + + z_value = K.cast(0.0, dtype=dtype) + o_value = K.cast(1.0, dtype=dtype) + t_value = K.cast(2.0, dtype=dtype) + + # flatten + shape = list(lower.shape[1:]) + upper_flat = K.reshape(upper, (-1, int(np.prod(shape)))) # (None, n) + lower_flat = K.reshape(lower, (-1, int(np.prod(shape)))) # (None, n) + + # upper bound + # derivative + s_u_prime = f_prime(upper_flat) # (None, n) + s_l_prime = f_prime(lower_flat) # (None, n) + s_u = func(upper_flat) # (None, n) + s_l = func(lower_flat) # (None, n) + + # case 0: + coeff = (s_u - s_l) / K.maximum(K.cast(epsilon(), dtype=dtype), upper_flat - lower_flat) + alpha_u_0 = K.where( + K.greater_equal(s_u_prime, coeff), o_value + z_value * upper_flat, z_value * upper_flat + ) # (None, n) + alpha_u_1 = (o_value - alpha_u_0) * ((K.sign(lower_flat) + o_value) / t_value) + + w_u_0 = coeff + b_u_0 = -w_u_0 * lower_flat + s_l + + w_u_1 = z_value * upper_flat + b_u_1 = s_u + + w_u_2, b_u_2 = get_t_upper(upper_flat, lower_flat, s_l, func=func, f_prime=f_prime) + + w_u_out = K.reshape(alpha_u_0 * w_u_0 + alpha_u_1 * w_u_1 + (o_value - alpha_u_0 - alpha_u_1) * w_u_2, [-1] + shape) + b_u_out = K.reshape(alpha_u_0 * b_u_0 + alpha_u_1 * b_u_1 + (o_value - alpha_u_0 - alpha_u_1) * b_u_2, [-1] + shape) + + # linear hull + # case 0: + alpha_l_0 = K.where( + K.greater_equal(s_l_prime, coeff), o_value + z_value * lower_flat, z_value * lower_flat + ) # (None, n) + alpha_l_1 = (o_value - alpha_l_0) * ((K.sign(-upper_flat) + o_value) / t_value) + + w_l_0 = coeff + b_l_0 = -w_l_0 * upper_flat + s_u + + w_l_1 = z_value * upper_flat + b_l_1 = s_l + + w_l_2, b_l_2 = get_t_lower(upper_flat, lower_flat, s_u, func=func, f_prime=f_prime) + + w_l_out = K.reshape(alpha_l_0 * w_l_0 + alpha_l_1 * w_l_1 + (o_value - alpha_l_0 - alpha_l_1) * w_l_2, [-1] + shape) + b_l_out = K.reshape(alpha_l_0 * b_l_0 + alpha_l_1 * b_l_1 + (o_value - alpha_l_0 - alpha_l_1) * b_l_2, [-1] + shape) + + return w_l_out, b_l_out, w_u_out, b_u_out + + +def get_t_upper( + u_c_flat: Tensor, + l_c_flat: Tensor, + s_l: Tensor, + func: TensorFunction = K.sigmoid, + f_prime: TensorFunction = sigmoid_prime, +) -> tuple[Tensor, Tensor]: + """linear interpolation between lower and upper bounds on the function func to have a symbolic approximation of the best + coefficient for the affine upper bound + + Args: + u_c_flat: flatten tensor of constant upper bound + l_c_flat: flatten tensor of constant lower bound + s_l: lowest value of the function func on the domain + func: the function (sigmoid, tanh, softsign) + f_prime: the derivative of the function + + Returns: + the upper affine bounds in this subcase + """ + + o_value = K.cast(1.0, dtype=u_c_flat.dtype) + z_value = K.cast(0.0, dtype=u_c_flat.dtype) + + # step1: find t + u_c_reshaped = K.expand_dims(u_c_flat, -1) # (None, n , 1) + l_c_reshaped = K.expand_dims(l_c_flat, -1) # (None, n, 1) + t = K.cast(np.linspace(0, 1, 100)[None, None, :], dtype=u_c_flat.dtype) * u_c_reshaped # (None, n , 100) + + s_p_t = f_prime(t) # (None, n, 100) + s_t = func(t) # (None, n, 100) + + score = K.abs(s_p_t - (s_t - K.expand_dims(s_l, -1)) / (t - l_c_reshaped)) # (None, n, 100) + index = K.argmin(score, -1) # (None, n) + threshold = K.min(score, -1) # (None, n) + + index_t = K.cast( + K.where(K.greater(threshold, z_value * threshold), index, K.clip(index - 1, 0, 100)), dtype=u_c_flat.dtype + ) # (None, n) + t_value = K.sum( + K.where( + K.equal( + o_value * K.cast(np.arange(0, 100)[None, None, :], dtype=u_c_flat.dtype) + z_value * u_c_reshaped, + K.expand_dims(index_t, -1) + z_value * u_c_reshaped, + ), + t, + z_value * t, + ), + -1, + ) # (None, n) + + s_t = func(t_value) # (None, n) + w_u = (s_t - s_l) / K.maximum(K.cast(epsilon(), dtype=u_c_flat.dtype), t_value - l_c_flat) # (None, n) + b_u = -w_u * l_c_flat + s_l # + func(l_c_flat) + + return w_u, b_u + + +def get_t_lower( + u_c_flat: Tensor, + l_c_flat: Tensor, + s_u: Tensor, + func: TensorFunction = K.sigmoid, + f_prime: TensorFunction = sigmoid_prime, +) -> tuple[Tensor, Tensor]: + """linear interpolation between lower and upper bounds on the function func to have a symbolic approximation of the best + coefficient for the affine lower bound + + Args: + u_c_flat: flatten tensor of constant upper bound + l_c_flat: flatten tensor of constant lower bound + s_u: highest value of the function func on the domain + func: the function (sigmoid, tanh, softsign) + f_prime: the derivative of the function + + Returns: + the lower affine bounds in this subcase + """ + z_value = K.cast(0.0, dtype=u_c_flat.dtype) + o_value = K.cast(1.0, dtype=u_c_flat.dtype) + + # step1: find t + u_c_reshaped = K.expand_dims(u_c_flat, -1) # (None, n , 1) + l_c_reshaped = K.expand_dims(l_c_flat, -1) # (None, n, 1) + t = K.cast(np.linspace(0, 1.0, 100)[None, None, :], dtype=u_c_flat.dtype) * l_c_reshaped # (None, n , 100) + + s_p_t = f_prime(t) # (None, n, 100) + s_t = func(t) # (None, n, 100) + + score = K.abs(s_p_t - (K.expand_dims(s_u, -1) - s_t) / (u_c_reshaped - t)) # (None, n, 100) + index = K.argmin(score, -1) # (None, n) + + threshold = K.min(score, -1) + index_t = K.cast( + K.where(K.greater(threshold, z_value * threshold), index, K.clip(index + 1, 0, 100)), dtype=u_c_flat.dtype + ) # (None, n) + t_value = K.sum( + K.where( + K.equal( + o_value * K.cast(np.arange(0, 100)[None, None, :], dtype=u_c_flat.dtype) + z_value * u_c_reshaped, + K.expand_dims(index_t, -1) + z_value * u_c_reshaped, + ), + t, + z_value * t, + ), + -1, + ) + + s_t = func(t_value) # (None, n) + w_l = (s_u - s_t) / K.maximum(K.cast(epsilon(), dtype=u_c_flat.dtype), u_c_flat - t_value) # (None, n) + b_l = -w_l * u_c_flat + s_u # func(u_c_flat) + + return w_l, b_l diff --git a/src/decomon/layers/convert.py b/src/decomon/layers/convert.py index b0d51e17..071d0ffb 100644 --- a/src/decomon/layers/convert.py +++ b/src/decomon/layers/convert.py @@ -1,186 +1,101 @@ -from typing import Any, Dict, List, Optional, Tuple, Union +import logging +from typing import Any, Optional -import keras -from keras.layers import Activation, Input, Layer +from keras.layers import Activation, Add, Dense, Layer -import decomon.layers.decomon_layers -import decomon.layers.decomon_merge_layers -import decomon.layers.decomon_reshape -import decomon.layers.maxpooling -from decomon.core import BoxDomain, ForwardMode, PerturbationDomain, Slope, get_mode -from decomon.layers.core import DecomonLayer +import decomon.layers +from decomon.constants import Propagation, Slope +from decomon.layers import DecomonActivation, DecomonAdd, DecomonDense, DecomonLayer +from decomon.perturbation_domain import PerturbationDomain -# mapping between decomon class names and actual classes -_mapping_name2class = vars(decomon.layers.decomon_layers) -_mapping_name2class.update(vars(decomon.layers.decomon_merge_layers)) -_mapping_name2class.update(vars(decomon.layers.decomon_reshape)) -_mapping_name2class.update(vars(decomon.layers.maxpooling)) +logger = logging.getLogger(__name__) -def to_decomon( - layer: Layer, - input_dim: int, - slope: Union[str, Slope] = Slope.V_SLOPE, - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - finetune: bool = False, - ibp: bool = True, - affine: bool = True, - shared: bool = True, - fast: bool = True, -) -> DecomonLayer: - """Transform a standard keras layer into a Decomon layer. +DECOMON_PREFIX = "Decomon" - Type of layer is tested to know how to transform it into a DecomonLayer of the good type. - If type is not treated yet, raises an TypeError +default_mapping_keras2decomon_classes: dict[type[Layer], type[DecomonLayer]] = { + Add: DecomonAdd, + Dense: DecomonDense, + Activation: DecomonActivation, +} +"""Default mapping between keras layers and decomon layers.""" - Args: - layer: a Keras Layer - input_dim: an integer that represents the dim - of the input perturbation domain - slope: - dc_decomp: boolean that indicates whether we return a difference - of convex decomposition of our layer - perturbation_domain: the type of perturbation domain - ibp: boolean that indicates whether we propagate constant bounds - affine: boolean that indicates whether we propagate affine - bounds +default_mapping_kerasname2decomonclass: dict[str, type[DecomonLayer]] = { + k[len(DECOMON_PREFIX) :]: v + for k, v in vars(decomon.layers).items() + if k.startswith(DECOMON_PREFIX) and issubclass(v, DecomonLayer) +} +"""Default mapping from a keras class name to a decomon layer class. - Returns: - the associated DecomonLayer - """ +This mapping is generated automatically from `decomon.layers` namespace. +It is used only when `default_mapping_keras2decomon_classes` does not contain +the desired keras layer class. - # get class name - if perturbation_domain is None: - perturbation_domain = BoxDomain() +""" - mode = get_mode(ibp=ibp, affine=affine) - layer_decomon = _to_decomon_wo_input_init( - layer=layer, - namespace=_mapping_name2class, - slope=slope, - dc_decomp=dc_decomp, - perturbation_domain=perturbation_domain, - finetune=finetune, - mode=mode, - shared=shared, - fast=fast, - ) - input_tensors = _prepare_input_tensors( - layer=layer, input_dim=input_dim, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode - ) - layer_decomon(input_tensors) - layer_decomon.reset_layer(layer) - - # return layer_decomon - return layer_decomon - - -def _to_decomon_wo_input_init( +def to_decomon( layer: Layer, - namespace: Dict[str, Any], - slope: Union[str, Slope] = Slope.V_SLOPE, - dc_decomp: bool = False, perturbation_domain: Optional[PerturbationDomain] = None, - finetune: bool = False, - mode: ForwardMode = ForwardMode.HYBRID, - shared: bool = True, - fast: bool = True, + ibp: bool = True, + affine: bool = True, + propagation: Propagation = Propagation.FORWARD, + model_input_shape: Optional[tuple[int, ...]] = None, + model_output_shape: Optional[tuple[int, ...]] = None, + slope: Slope = Slope.V_SLOPE, + mapping_keras2decomon_classes: Optional[dict[type[Layer], type[DecomonLayer]]] = None, + **kwargs: Any, ) -> DecomonLayer: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - class_name = layer.__class__.__name__ - # check if layer has a built argument that built is set to True - if hasattr(layer, "built"): - if not layer.built: - raise ValueError(f"the layer {layer.name} has not been built yet") - decomon_class_name = f"Decomon{class_name}" - config_layer = layer.get_config() - config_layer["name"] = layer.name + "_decomon" - config_layer["dc_decomp"] = dc_decomp - config_layer["perturbation_domain"] = perturbation_domain - - config_layer["mode"] = mode - config_layer["finetune"] = finetune - config_layer["slope"] = slope - config_layer["shared"] = shared - config_layer["fast"] = fast - if not isinstance(layer, Activation): - config_layer.pop("activation", None) # Hyp: no non-linear activation in dense or conv2d layers - try: - layer_decomon = namespace[decomon_class_name].from_config(config_layer) - except: - raise NotImplementedError(f"The decomon version of {class_name} is not yet implemented.") - - layer_decomon.share_weights(layer) - return layer_decomon - - -def _prepare_input_tensors( - layer: Layer, input_dim: int, dc_decomp: bool, perturbation_domain: PerturbationDomain, mode: ForwardMode -) -> List[keras.KerasTensor]: - original_input_shapes = get_layer_input_shape(layer) - decomon_input_shapes: List[List[Optional[int]]] = [list(input_shape[1:]) for input_shape in original_input_shapes] - n_input = len(decomon_input_shapes) - x_input_shape = perturbation_domain.get_x_input_shape_wo_batchsize(input_dim) - x_input = Input(x_input_shape, dtype=layer.dtype) - w_input = [Input(tuple([input_dim] + decomon_input_shapes[i])) for i in range(n_input)] - y_input = [Input(tuple(decomon_input_shapes[i])) for i in range(n_input)] - - if mode == ForwardMode.HYBRID: - nested_input_list = [ - [x_input, y_input[i], w_input[i], y_input[i], y_input[i], w_input[i], y_input[i]] for i in range(n_input) - ] - elif mode == ForwardMode.IBP: - nested_input_list = [[y_input[i], y_input[i]] for i in range(n_input)] - elif mode == ForwardMode.AFFINE: - nested_input_list = [[x_input, w_input[i], y_input[i], w_input[i], y_input[i]] for i in range(n_input)] - else: - raise ValueError(f"Unknown mode {mode}") - - flatten_input_list = [tensor for input_list in nested_input_list for tensor in input_list] - - if dc_decomp: - if n_input == 1: - flatten_input_list += [y_input[0], y_input[0]] - else: - raise NotImplementedError() - - return flatten_input_list - - -SingleInputShapeType = Tuple[Optional[int], ...] - - -def get_layer_input_shape(layer: Layer) -> List[SingleInputShapeType]: - """Retrieves the input shape(s) of a layer. - - Only applicable if the layer has exactly one input, - i.e. if it is connected to one incoming layer, or if all inputs - have the same shape. + """Convert a keras layer into the corresponding decomon layer. Args: - layer: + layer: keras layer to convert + perturbation_domain: perturbation domain. Default to a box domain + ibp: if True, forward propagate constant bounds + affine: if True, forward propagate affine bounds + propagation: direction of bounds propagation + - forward: from input to output + - backward: from output to input + model_output_shape: shape of the underlying model output (omitting batch axis). + It allows determining if the backward bounds are with a batch axis or not. + model_input_shape: shape of the underlying keras model input (omitting batch axis). + slope: slope used for the affine relaxation of activation layers + mapping_keras2decomon_classes: user-defined mapping between keras and decomon layers classes + **kwargs: keyword-args passed to decomon layers Returns: - Input shape, as an integer shape tuple - (or list of shape tuples, one tuple per input tensor). + the converted decomon layer - Raises: - AttributeError: if the layer has no defined input_shape. - RuntimeError: if called in Eager mode. - """ + The decomon class is chosen as followed (from higher to lower priority): + - user-defined `mapping_keras2decomon_classes`, + - default mapping `default_mapping_keras2decomon_classes`, + - using keras class name, by adding a "Decomon" prefix, thanks to `default_mapping_kerasname2decomonclass`. - if not layer._inbound_nodes: - raise AttributeError(f'The layer "{layer.name}" has never been called ' "and thus has no defined input shape.") - all_input_shapes = set([str([tensor.shape for tensor in node.input_tensors]) for node in layer._inbound_nodes]) - if len(all_input_shapes) == 1: - return [tensor.shape for tensor in layer._inbound_nodes[0].input_tensors] - else: - raise AttributeError( - 'The layer "' + str(layer.name) + '" has multiple inbound nodes, ' - "with different input shapes. Hence " - 'the notion of "input shape" is ' - "ill-defined for the layer. " + """ + # Choose the corresponding decomon class. User mapping -> default mapping -> name. + keras_class = type(layer) + decomon_class = None + if mapping_keras2decomon_classes is not None: + decomon_class = mapping_keras2decomon_classes.get(keras_class, None) + if decomon_class is None: + decomon_class = default_mapping_keras2decomon_classes.get(keras_class, None) + if decomon_class is None: + logger.warning( + f"Keras layer {layer} not in user-defined nor default mapping: " + f"using class name to deduce the proper decomon class to use." ) + decomon_class = default_mapping_kerasname2decomonclass.get(keras_class.__name__, None) + if decomon_class is None: + raise NotImplementedError(f"The decomon version of {keras_class} is not yet implemented.") + + return decomon_class( + layer=layer, + perturbation_domain=perturbation_domain, + ibp=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + **kwargs, + ) diff --git a/src/decomon/backward_layers/backward_reshape.py b/src/decomon/layers/convolutional/__init__.py similarity index 100% rename from src/decomon/backward_layers/backward_reshape.py rename to src/decomon/layers/convolutional/__init__.py diff --git a/src/decomon/layers/convolutional/conv2d.py b/src/decomon/layers/convolutional/conv2d.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/core.py b/src/decomon/layers/core.py deleted file mode 100644 index 9d2fb6cb..00000000 --- a/src/decomon/layers/core.py +++ /dev/null @@ -1,247 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple, Type, Union - -import keras -from keras.layers import Layer - -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - get_affine, - get_ibp, -) -from decomon.keras_utils import check_if_single_shape, reset_layer -from decomon.types import BackendTensor - - -class DecomonLayer(ABC, Layer): - """Abstract class that contains the common information of every implemented layers for Forward LiRPA""" - - _trainable_weights: List[keras.Variable] - - @property - @abstractmethod - def original_keras_layer_class(self) -> Type[Layer]: - """The keras layer class from which this class is the decomon equivalent.""" - pass - - def __init__( - self, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - """ - Args: - perturbation_domain: type of convex input domain (None or dict) - dc_decomp: boolean that indicates whether we return a - mode: type of Forward propagation (ibp, affine, or hybrid) - **kwargs: extra parameters - difference of convex decomposition of our layer - """ - kwargs.pop("slope", None) # remove it if not used by the decomon layer - super().__init__(**kwargs) - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - self.inputs_outputs_spec = InputsOutputsSpec( - dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain - ) - self.nb_tensors = self.inputs_outputs_spec.nb_tensors - self.dc_decomp = dc_decomp - self.perturbation_domain = perturbation_domain - self.mode = ForwardMode(mode) - self.finetune = finetune # extra optimization with hyperparameters - self.frozen_weights = False - self.frozen_alpha = False - self.shared = shared - self.fast = fast - self.has_backward_bounds = False # optimizing Forward LiRPA for adversarial perturbation - - @property - def ibp(self) -> bool: - return get_ibp(self.mode) - - @property - def affine(self) -> bool: - return get_affine(self.mode) - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "dc_decomp": self.dc_decomp, - "shared": self.shared, - "fast": self.fast, - "finetune": self.finetune, - "mode": self.mode, - "perturbation_domain": self.perturbation_domain, - } - ) - return config - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - input_shape - - Returns: - - """ - # generic case: call build from underlying keras layer with the proper intput_shape - y_input_shape = input_shape[-1] - self.original_keras_layer_class.build(self, y_input_shape) - - @abstractmethod - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - """ - Args: - inputs - - Returns: - - """ - - def compute_output_shape( - self, input_shape: Union[Tuple[Optional[int], ...], List[Tuple[Optional[int], ...]]] - ) -> Union[Tuple[Optional[int], ...], List[Tuple[Optional[int], ...]]]: - """Compute expected output shape according to input shape - - Will be called by symbolic calls on Keras Tensors. - - - We use the original (Keras) layer compute_output_shape() if available to update accordingly the input shapes. - - Else we simply return the input shapes - - Beware, compute_output_shape() is sometimes called by the original keras layer inside its `call()`, - which can be called inside the decomon layer call(). Check this by looking at input_shape - (list of shapes or simple shape?) - - Args: - input_shape - - Returns: - - """ - if check_if_single_shape(input_shape): - # call from a keras layer call() with a single shape - try: - return self.original_keras_layer_class.compute_output_shape( - self, input_shape - ) # output shape of the original layer - except NotImplementedError: - # no compute_output_shape implemented (e.g. InputLayer) - return input_shape - - # we know from here that we got a list of input shapes. Mypy does not. - input_shapes: List[Tuple[Optional[int], ...]] = input_shape # type: ignore - y_shape = self.inputs_outputs_spec.get_kerasinputshape_from_inputshapesformode( - input_shapes - ) # input shape for the original layer - try: - y_out_shape = self.original_keras_layer_class.compute_output_shape( - self, y_shape - ) # output shape of the original layer - except NotImplementedError: - # no compute_output_shape implemented (e.g. InputLayer) - return input_shape - else: - # we now the original output shape => deduce the decomon output shapes - ( - x_shape, - u_c_shape, - w_u_shape, - b_u_shape, - l_c_shape, - w_l_shape, - b_l_shape, - h_shape, - g_shape, - ) = self.inputs_outputs_spec.get_fullinputshapes_from_inputshapesformode(input_shapes) - y_out_shape_wo_batchsize = y_out_shape[1:] - if self.inputs_outputs_spec.affine: - model_inputdim = x_shape[-1] - batchsize = x_shape[0] - w_out_shape = (batchsize, model_inputdim) + y_out_shape_wo_batchsize - else: - w_out_shape = tuple() - fulloutputshapes = [ - x_shape, - y_out_shape, - w_out_shape, - y_out_shape, - y_out_shape, - w_out_shape, - y_out_shape, - y_out_shape, - y_out_shape, - ] - return self.inputs_outputs_spec.extract_inputshapesformode_from_fullinputshapes(fulloutputshapes) - - def compute_output_spec(self, *args: Any, **kwargs: Any) -> keras.KerasTensor: - """Compute output spec from output shape in case of symbolic call.""" - return Layer.compute_output_spec(self, *args, **kwargs) - - def reset_layer(self, layer: Layer) -> None: - """Reset the weights by using the weights of another (a priori non-decomon) layer. - - It set the weights whose names are listed by `keras_weights_names`. - - Args: - layer - - Returns: - - """ - weight_names = self.keras_weights_names - if len(weight_names) > 0: - reset_layer(new_layer=self, original_layer=layer, weight_names=weight_names) - - @property - def keras_weights_names(self) -> List[str]: - """Weights names of the corresponding Keras layer. - - Will be used to decide which weight to take from the keras layer in `reset_layer()` - - """ - return [] - - def join(self, bounds: List[BackendTensor]) -> List[BackendTensor]: - """ - Args: - bounds - - Returns: - - """ - raise NotImplementedError() - - def freeze_weights(self) -> None: - pass - - def unfreeze_weights(self) -> None: - pass - - def freeze_alpha(self) -> None: - pass - - def unfreeze_alpha(self) -> None: - pass - - def reset_finetuning(self) -> None: - pass - - def share_weights(self, layer: Layer) -> None: - pass - - def split_kwargs(self, **kwargs: Any) -> None: - # necessary for InputLayer - pass - - def set_back_bounds(self, has_backward_bounds: bool) -> None: - pass diff --git a/src/decomon/layers/core/__init__.py b/src/decomon/layers/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/core/dense.py b/src/decomon/layers/core/dense.py new file mode 100644 index 00000000..057cd76a --- /dev/null +++ b/src/decomon/layers/core/dense.py @@ -0,0 +1,30 @@ +import keras.ops as K +from keras.layers import Dense + +from decomon.layers.layer import DecomonLayer +from decomon.types import Tensor + + +class DecomonDense(DecomonLayer): + layer: Dense + linear = True + + def get_affine_representation(self) -> tuple[Tensor, Tensor]: + w = self.layer.kernel + b = self.layer.bias if self.layer.use_bias else K.zeros((self.layer.units,)) + + # manage tensor-multid input + for dim in self.layer.input.shape[-2:0:-1]: + # Construct a multid-tensor diagonal by blocks + reshaped_outer_shape = (dim, dim) + w.shape + transposed_outer_axes = ( + (0,) + + tuple(range(2, 2 + len(b.shape))) + + (1,) + + tuple(range(2 + len(b.shape), len(reshaped_outer_shape))) + ) + w = K.transpose(K.reshape(K.outer(K.identity(dim), w), reshaped_outer_shape), transposed_outer_axes) + # repeat bias along first dimensions + b = K.repeat(b[None], dim, axis=0) + + return w, b diff --git a/src/decomon/layers/crown.py b/src/decomon/layers/crown.py new file mode 100644 index 00000000..52e5ad76 --- /dev/null +++ b/src/decomon/layers/crown.py @@ -0,0 +1,114 @@ +"""Layers needed by crown algorithm.""" + + +from typing import Any, Optional + +import keras.ops as K +from keras.layers import Layer + +from decomon.constants import Propagation +from decomon.keras_utils import add_tensors +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.types import BackendTensor + + +class ReduceCrownBounds(Layer): + """Layer reducing crown bounds computed from parent nodes of a merging layer. + + When encoutering a merging layer, crown algorithm propagates crown bounds for each parent. + As keras models are supposed to have a single input, all resulting crown bounds are affine bounds + on the same keras model output w.r.t this same single keras input. + Thus we can reduce them to a single crown bound by simply summing each component (lower/upper weight/bias). + + NB: We need to take into account the fact that some bounds can be empty, diagonal, and/or w/o batchsize. + + """ + + def __init__( + self, + model_output_shape: tuple[int, ...], + **kwargs: Any, + ): + """ + Args: + model_output_shape: shape of the model output for which the crown bounds have been computed + **kwargs: + + """ + super().__init__(**kwargs) + self.model_output_shape = model_output_shape + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "model_output_shape": self.model_output_shape, + } + ) + return config + + def call(self, inputs: list[list[BackendTensor]]) -> list[BackendTensor]: + """Reduce the list of crown bounds to a single one by summation. + + Args: + inputs: list of crown bounds of the form [[w_l[i], b_l[i], w_u[i], b_u[i]]_i] + + Returns: + [w_l_tot, b_l_tot, w_u_tot, b_u_tot]: a single crown bound + + """ + if len(inputs) == 0: + raise ValueError("inputs should not be empty") + + affine_bounds = inputs[0] + for i in range(1, len(inputs)): + affine_bounds_i = inputs[i] + # identity: put on diag + w/o batchsize form to be able to sum + missing_batchsize = ( + self.inputs_outputs_spec.is_wo_batch_bounds(affine_bounds, 0), + self.inputs_outputs_spec.is_wo_batch_bounds(affine_bounds_i, i), + ) + diagonal = ( + self.inputs_outputs_spec.is_diagonal_bounds(affine_bounds, 0), + self.inputs_outputs_spec.is_diagonal_bounds(affine_bounds_i, i), + ) + if len(affine_bounds) == 0: + w = K.ones(self.model_output_shape) + b = K.zeros(self.model_output_shape) + w_l, b_l, w_u, b_u = w, b, w, b + else: + w_l, b_l, w_u, b_u = affine_bounds + if len(affine_bounds_i) == 0: + w = K.ones(self.model_output_shape) + b = K.zeros(self.model_output_shape) + w_l_i, b_l_i, w_u_i, b_u_i = w, b, w, b + else: + w_l_i, b_l_i, w_u_i, b_u_i = affine_bounds_i + w_l = add_tensors(w_l, w_l_i, missing_batchsize=missing_batchsize, diagonal=diagonal) + w_u = add_tensors(w_u, w_u_i, missing_batchsize=missing_batchsize, diagonal=diagonal) + b_l = add_tensors(b_l, b_l_i, missing_batchsize=missing_batchsize) + b_u = add_tensors(b_u, b_u_i, missing_batchsize=missing_batchsize) + + affine_bounds = [w_l, b_l, w_u, b_u] + return affine_bounds + + def build(self, input_shape: list[list[tuple[Optional[int], ...]]]) -> None: + self.nb_keras_inputs = len(input_shape) + self.inputs_outputs_spec = InputsOutputsSpec( + ibp=False, + affine=True, + propagation=Propagation.BACKWARD, + is_merging_layer=True, + model_output_shape=self.model_output_shape, + layer_input_shape=[tuple()] * self.nb_keras_inputs, + ) + self.built = True + + def compute_output_shape( + self, + input_shape: list[list[tuple[Optional[int], ...]]], + ) -> list[tuple[Optional[int], ...]]: + """Compute output shape in case of symbolic call.""" + if len(input_shape) == 0: + raise ValueError("inputs should not be empty") + return input_shape[0] diff --git a/src/decomon/layers/decomon_layers.py b/src/decomon/layers/decomon_layers.py deleted file mode 100644 index a89d5efe..00000000 --- a/src/decomon/layers/decomon_layers.py +++ /dev/null @@ -1,982 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras -import keras.ops as K -import numpy as np -from keras.layers import ( - Activation, - BatchNormalization, - Conv2D, - Dense, - Dot, - Dropout, - Flatten, - InputLayer, - InputSpec, - Lambda, - Layer, -) -from keras.src.backend import rnn - -from decomon.core import ForwardMode, PerturbationDomain, Slope -from decomon.layers import activations -from decomon.layers.core import DecomonLayer -from decomon.layers.utils import ( - ClipAlpha, - ClipAlphaAndSumtoOne, - NonPos, - Project_initializer_pos, -) -from decomon.types import BackendTensor - - -class DecomonConv2D(DecomonLayer, Conv2D): - """Forward LiRPA implementation of Conv2d layers. - See Keras official documentation for further details on the Conv2d operator - - """ - - original_keras_layer_class = Conv2D - - def __init__( - self, - filters: int, - kernel_size: Union[int, Tuple[int, int]], - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - kwargs.pop("activation", None) - super().__init__( - filters=filters, - kernel_size=kernel_size, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - if self.mode == ForwardMode.HYBRID: - self.input_spec = [ - InputSpec(min_ndim=2), # z - InputSpec(min_ndim=4), # u - InputSpec(min_ndim=4), # wu - InputSpec(min_ndim=4), # bu - InputSpec(min_ndim=4), # l - InputSpec(min_ndim=4), # wl - InputSpec(min_ndim=4), # bl - ] - elif self.mode == ForwardMode.IBP: - self.input_spec = [ - InputSpec(min_ndim=4), # u - InputSpec(min_ndim=4), # l - ] - elif self.mode == ForwardMode.AFFINE: - self.input_spec = [ - InputSpec(min_ndim=2), # z - InputSpec(min_ndim=4), # wu - InputSpec(min_ndim=4), # bu - InputSpec(min_ndim=4), # wl - InputSpec(min_ndim=4), # bl - ] - if self.dc_decomp: - self.input_spec += [InputSpec(min_ndim=4), InputSpec(min_ndim=4)] - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - input_shape - - Returns: - - """ - - assert len(input_shape) == self.nb_tensors - if self.data_format == "channels_first": - channel_axis = 1 - else: - channel_axis = -1 - if input_shape[0][channel_axis] is None: - raise ValueError("The channel dimension of the inputs " "should be defined. Found `None`.") - input_dim = input_shape[-1][channel_axis] - - kernel_shape = self.kernel_size + (input_dim, self.filters) - - if not self.shared: - self.kernel = self.add_weight( - shape=kernel_shape, - initializer=self.kernel_initializer, - name="kernel_all", - regularizer=self.kernel_regularizer, - constraint=self.kernel_constraint, - ) - - if self.use_bias: - self.bias = self.add_weight( - shape=(self.filters,), - initializer=self.bias_initializer, - name="bias_pos", - regularizer=self.bias_regularizer, - constraint=self.bias_constraint, - ) - else: - self.bias = None - - self.built = True - - def share_weights(self, layer: Layer) -> None: - if not self.shared: - return - self.kernel = layer.kernel - self.bias = layer.bias - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - """computing the perturbation analysis of the operator without the activation function - - Args: - inputs: list of input tensors - **kwargs - - Returns: - List of updated tensors - """ - z_value = K.cast(0.0, self.dtype) - o_value = K.cast(1.0, self.dtype) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - def conv_pos(x: BackendTensor) -> BackendTensor: - return K.conv( - x, - K.maximum(z_value, self.kernel), - strides=list(self.strides), - padding=self.padding, - data_format=self.data_format, - dilation_rate=self.dilation_rate, - ) - - def conv_neg(x: BackendTensor) -> BackendTensor: - return K.conv( - x, - K.minimum(z_value, self.kernel), - strides=list(self.strides), - padding=self.padding, - data_format=self.data_format, - dilation_rate=self.dilation_rate, - ) - - if self.dc_decomp: - h_out = conv_pos(h) + conv_neg(g) - g_out = conv_pos(g) + conv_neg(h) - else: - h_out, g_out = empty_tensor, empty_tensor - - if self.ibp: - u_c_out = conv_pos(u_c) + conv_neg(l_c) - l_c_out = conv_pos(l_c) + conv_neg(u_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - y = K.conv( - b_u, - self.kernel, - strides=list(self.strides), - padding=self.padding, - data_format=self.data_format, - dilation_rate=self.dilation_rate, - ) - - if len(w_u.shape) == len(b_u.shape): - flatdim = int(np.prod(b_u.shape[1:])) - identity_tensor = K.reshape(K.identity(flatdim, dtype=self.dtype), (-1,) + tuple(b_u.shape[1:])) - w_u_out = K.conv( - identity_tensor, - self.kernel, - strides=list(self.strides), - padding=self.padding, - data_format=self.data_format, - dilation_rate=self.dilation_rate, - ) - w_u_out = K.expand_dims(w_u_out, 0) + z_value * K.expand_dims(y, 1) - w_l_out = w_u_out - b_u_out = 0 * y - b_l_out = 0 * y - - else: - # check for linearity - x_max = self.perturbation_domain.get_upper(x, w_u - w_l, b_u - b_l) - mask_b = o_value - K.sign(x_max) - - def step_pos(x: BackendTensor, _: List[BackendTensor]) -> Tuple[BackendTensor, List[BackendTensor]]: - return conv_pos(x), [] - - def step_neg(x: BackendTensor, _: List[BackendTensor]) -> Tuple[BackendTensor, List[BackendTensor]]: - return conv_neg(x), [] - - b_u_out = conv_pos(b_u) + conv_neg(b_l) - b_l_out = conv_pos(b_l) + conv_neg(b_u) - - w_u_out = ( - rnn(step_function=step_pos, inputs=w_u, initial_states=[], unroll=False)[1] - + rnn(step_function=step_neg, inputs=w_l, initial_states=[], unroll=False)[1] - ) - w_l_out = ( - rnn(step_function=step_pos, inputs=w_l, initial_states=[], unroll=False)[1] - + rnn(step_function=step_neg, inputs=w_u, initial_states=[], unroll=False)[1] - ) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.use_bias: - # put bias to the correct shape - if self.data_format == "channels_last": - bias_shape = (1,) * (self.rank + 1) + (self.filters,) - else: - bias_shape = (1, self.filters) + (1,) * self.rank - reshaped_bias = K.reshape(self.bias, bias_shape) - - if self.dc_decomp: - g_out += K.reshape(K.minimum(z_value, self.bias), bias_shape) - h_out += K.reshape(K.maximum(z_value, self.bias), bias_shape) - if self.affine: - b_u_out += reshaped_bias - b_l_out += reshaped_bias - if self.ibp: - u_c_out += reshaped_bias - l_c_out += reshaped_bias - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - @property - def keras_weights_names(self) -> List[str]: - """Weights names of the corresponding Keras layer. - - Will be used to decide which weight to take from the keras layer in `reset_layer()` - - """ - weight_names = ["kernel"] - if self.use_bias: - weight_names.append("bias") - return weight_names - - def freeze_weights(self) -> None: - if not self.frozen_weights: - self._trainable_weights = [] - self.frozen_weights = True - - def unfreeze_weights(self) -> None: - if self.frozen_weights: - if self.use_bias: - self._trainable_weights = [self.bias] + self._trainable_weights - self._trainable_weights = [self.kernel] + self._trainable_weights - self.frozen_weights = False - - -class DecomonDense(DecomonLayer, Dense): - """Forward LiRPA implementation of Dense layers. - See Keras official documentation for further details on the Dense operator - """ - - original_keras_layer_class = Dense - - def __init__( - self, - units: int, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - kwargs.pop("activation", None) - kwargs.pop("kernel_constraint", None) - super().__init__( - units=units, - kernel_constraint=None, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - self.input_spec = [InputSpec(min_ndim=2) for _ in range(self.nb_tensors)] - self.input_shape_build: Optional[List[Tuple[Optional[int], ...]]] = None - self.op_dot = K.dot - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - input_shape: list of input shape - - Returns: - - """ - - assert len(input_shape) >= self.nb_tensors - - input_dim = input_shape[-1][-1] - - if not self.shared: - kernel = self.add_weight( - shape=(input_dim, self.units), - initializer=Project_initializer_pos(self.kernel_initializer), - name="kernel_pos", - regularizer=self.kernel_regularizer, - ) - try: - self.kernel = kernel - except AttributeError: - # manage hidden weights introduced for LoRA https://github.com/keras-team/keras/pull/18942 - self._kernel = kernel - - if self.use_bias: - self.bias = self.add_weight( - shape=(self.units,), - initializer=self.bias_initializer, - name="bias_pos", - regularizer=self.bias_regularizer, - constraint=self.bias_constraint, - ) - else: - self.bias = None - - # 6 inputs tensors : h, g, x_min, x_max, W_u, b_u, W_l, b_l - if self.mode == ForwardMode.HYBRID: - self.input_spec = [ - InputSpec(min_ndim=2), # x_0 - InputSpec(min_ndim=2, axes={-1: input_dim}), # u_c - InputSpec(min_ndim=2, axes={-1: input_dim}), # W_u - InputSpec(min_ndim=2, axes={-1: input_dim}), # b_u - InputSpec(min_ndim=2, axes={-1: input_dim}), # l_c - InputSpec(min_ndim=2, axes={-1: input_dim}), # W_l - InputSpec(min_ndim=2, axes={-1: input_dim}), # b_l - ] - elif self.mode == ForwardMode.IBP: - self.input_spec = [ - InputSpec(min_ndim=2, axes={-1: input_dim}), # u_c - InputSpec(min_ndim=2, axes={-1: input_dim}), # l_c - ] - elif self.mode == ForwardMode.AFFINE: - self.input_spec = [ - InputSpec(min_ndim=2), # x_0 - InputSpec(min_ndim=2, axes={-1: input_dim}), # W_u - InputSpec(min_ndim=2, axes={-1: input_dim}), # b_u - InputSpec(min_ndim=2, axes={-1: input_dim}), # W_l - InputSpec(min_ndim=2, axes={-1: input_dim}), # b_l - ] - if self.dc_decomp: - self.input_spec += [ - InputSpec(min_ndim=2, axes={-1: input_dim}), # h - InputSpec(min_ndim=2, axes={-1: input_dim}), - ] # g - - if self.has_backward_bounds: - self.input_spec += [InputSpec(ndim=3, axes={-1: self.units, -2: self.units})] - - self.built = True - self.input_shape_build = input_shape - - def share_weights(self, layer: Layer) -> None: - if not self.shared: - return - try: - self.kernel = layer.kernel - except AttributeError: - # manage hidden weights introduced for LoRA https://github.com/keras-team/keras/pull/18942 - self._kernel = layer._kernel - if self.use_bias: - self.bias = layer.bias - - def set_back_bounds(self, has_backward_bounds: bool) -> None: - # check for activation - if self.activation is not None and self.activation_name != "linear" and has_backward_bounds: - raise ValueError() - self.has_backward_bounds = has_backward_bounds - if self.built and has_backward_bounds: - if self.input_shape_build is None: - raise ValueError("self.input_shape_build should not be None when calling set_back_bounds") - # rebuild with an additional input - self.build(self.input_shape_build) - if self.has_backward_bounds: - op = Dot(1) - self.op_dot = lambda x, y: op([x, y]) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - """ - Args: - inputs - - Returns: - - """ - if self.kernel is None: - raise RuntimeError("self.kernel cannot be None when calling call()") - - z_value = K.cast(0.0, self.dtype) - - if self.has_backward_bounds: - back_bound = inputs[-1] - inputs = inputs[:-1] - kernel = K.sum(self.kernel[None, :, :, None] * back_bound[:, None], 2) - kernel_pos_back = K.maximum(z_value, kernel) - kernel_neg_back = K.minimum(z_value, kernel) - - kernel_pos = K.maximum(z_value, self.kernel) - kernel_neg = K.minimum(z_value, self.kernel) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if self.dc_decomp: - h_out = K.dot(h, kernel_pos) + K.dot(g, kernel_neg) - g_out = K.dot(g, kernel_pos) + K.dot(h, kernel_neg) - else: - h_out, g_out = empty_tensor, empty_tensor - - if self.ibp: - if not self.has_backward_bounds: - u_c_out = self.op_dot(u_c, kernel_pos) + self.op_dot(l_c, kernel_neg) - l_c_out = self.op_dot(l_c, kernel_pos) + self.op_dot(u_c, kernel_neg) - else: - u_c_out = self.op_dot(u_c, kernel_pos_back) + self.op_dot(l_c, kernel_neg_back) - l_c_out = self.op_dot(l_c, kernel_pos_back) + self.op_dot(u_c, kernel_neg_back) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - if len(w_u.shape) == len(b_u.shape): - y = K.dot(b_u, self.kernel) - w_u_out = K.expand_dims(0 * y, 1) + K.expand_dims(self.kernel, 0) - w_l_out = w_u_out - b_u_out = z_value * y - b_l_out = b_u_out - - if len(w_u.shape) != len(b_u.shape): - b_u_out = K.dot(b_u, kernel_pos) + K.dot(b_l, kernel_neg) - b_l_out = K.dot(b_l, kernel_pos) + K.dot(b_u, kernel_neg) - - if not self.has_backward_bounds: - w_u_out = K.dot(w_u, kernel_pos) + K.dot(w_l, kernel_neg) - w_l_out = K.dot(w_l, kernel_pos) + K.dot(w_u, kernel_neg) - else: - raise NotImplementedError() # bug somewhere - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.use_bias: - if not self.has_backward_bounds: - if self.ibp: - u_c_out += self.bias - l_c_out += self.bias - if self.affine: - b_u_out += self.bias - b_l_out += self.bias - else: - b = K.sum(back_bound * self.bias[None, None], 1) - if self.ibp: - u_c_out = u_c_out + b - l_c_out = l_c_out + b - if self.affine: - b_u_out = b_u_out + b - b_l_out = b_l_out + b - - if self.dc_decomp: - if self.has_backward_bounds: - raise NotImplementedError() - h_out += K.maximum(z_value, self.bias) - g_out += K.minimum(z_value, self.bias) - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - @property - def keras_weights_names(self) -> List[str]: - """Weights names of the corresponding Keras layer. - - Will be used to decide which weight to take from the keras layer in `reset_layer()` - - """ - weight_names = ["kernel"] - if self.use_bias: - weight_names.append("bias") - return weight_names - - def freeze_weights(self) -> None: - if not self.frozen_weights: - self._trainable_weights = [] - self.frozen_weights = True - - def unfreeze_weights(self) -> None: - if self.frozen_weights: - if self.use_bias: - self._trainable_weights = [self.bias] + self._trainable_weights - self._trainable_weights = [self.kernel] + self._trainable_weights - self.frozen_weights = False - - -class DecomonActivation(DecomonLayer, Activation): - """Forward LiRPA implementation of Activation layers. - See Keras official documentation for further details on the Activation operator - """ - - original_keras_layer_class = Activation - - def __init__( - self, - activation: str, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - activation=activation, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - self.slope = Slope(slope) - self.supports_masking = True - self.activation = activations.get(activation) - self.activation_name = activation - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - if self.finetune and self.mode in [ForwardMode.HYBRID, ForwardMode.AFFINE]: - shape = input_shape[-1][1:] - - if self.activation_name != "linear" and self.mode != ForwardMode.IBP: - if self.activation_name[:4] != "relu": - self.beta_u_f = self.add_weight( - shape=shape, - initializer="ones", - name="beta_u_f", - regularizer=None, - constraint=ClipAlpha(), - ) - self.beta_l_f = self.add_weight( - shape=shape, - initializer="ones", - name="beta_l_f", - regularizer=None, - constraint=ClipAlpha(), - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if self.finetune and self.mode in [ForwardMode.AFFINE, ForwardMode.HYBRID] and self.activation_name != "linear": - if self.activation_name[:4] == "relu": - return self.activation( - inputs, - mode=self.mode, - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - finetune=self.beta_l_f, - slope=self.slope, - ) - else: - return self.activation( - inputs, - mode=self.mode, - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - finetune=[self.beta_u_f, self.beta_l_f], - slope=self.slope, - ) - else: - output = self.activation( - inputs, - mode=self.mode, - perturbation_domain=self.perturbation_domain, - dc_decomp=self.dc_decomp, - slope=self.slope, - ) - return output - - def reset_finetuning(self) -> None: - if self.finetune and self.mode != ForwardMode.IBP: - if self.activation_name != "linear": - if self.activation_name[:4] == "relu": - self.beta_l_f.assign(np.ones_like(self.beta_l_f.value())) - else: - self.beta_u_f.assign(np.ones_like(self.beta_u_f.value())) - self.beta_l_f.assign(np.ones_like(self.beta_l_f.value())) - - def freeze_alpha(self) -> None: - if not self.frozen_alpha: - if self.finetune and self.mode in [ForwardMode.AFFINE, ForwardMode.HYBRID]: - self._trainable_weights = [] - self.frozen_alpha = True - - def unfreeze_alpha(self) -> None: - if self.frozen_alpha: - if self.finetune and self.mode in [ForwardMode.AFFINE, ForwardMode.HYBRID]: - if self.activation_name != "linear": - if self.activation_name[:4] != "relu": - self._trainable_weights += [self.beta_u_f, self.beta_l_f] - else: - self._trainable_weights += [self.beta_l_f] - self.frozen_alpha = False - - -class DecomonFlatten(DecomonLayer, Flatten): - """Forward LiRPA implementation of Flatten layers. - See Keras official documentation for further details on the Flatten operator - """ - - original_keras_layer_class = Flatten - - def __init__( - self, - data_format: Optional[str] = None, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - """ - Args: - data_format - **kwargs - """ - super().__init__( - data_format=data_format, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - if self.mode == ForwardMode.HYBRID: - self.input_spec = [ - InputSpec(min_ndim=1), # z - InputSpec(min_ndim=1), # u - InputSpec(min_ndim=2), # w_u - InputSpec(min_ndim=2), # b_u - InputSpec(min_ndim=1), # l - InputSpec(min_ndim=2), # w_l - InputSpec(min_ndim=2), # b_l - ] - elif self.mode == ForwardMode.IBP: - self.input_spec = [ - InputSpec(min_ndim=1), # u - InputSpec(min_ndim=1), # l - ] - elif self.mode == ForwardMode.AFFINE: - self.input_spec = [ - InputSpec(min_ndim=1), # z - InputSpec(min_ndim=2), # w_u - InputSpec(min_ndim=2), # b_u - InputSpec(min_ndim=2), # w_l - InputSpec(min_ndim=2), # b_l - ] - if self.dc_decomp: - self.input_spec += [InputSpec(min_ndim=1), InputSpec(min_ndim=1)] - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - """ - Args: - self - input_shape - - Returns: - - """ - return None - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - def op(x: BackendTensor) -> BackendTensor: - return Flatten.call(self, x) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if self.dc_decomp: - h_out = op(h) - g_out = op(g) - else: - h_out, g_out = empty_tensor, empty_tensor - - if self.ibp: - u_c_out = op(u_c) - l_c_out = op(l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_out = op(b_u) - b_l_out = op(b_l) - input_dim = x.shape[-1] - output_shape = int(np.prod(b_u_out.shape[1:])) - w_u_out = K.reshape(w_u, (-1, input_dim, output_shape)) - w_l_out = K.reshape(w_l, (-1, input_dim, output_shape)) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -class DecomonBatchNormalization(DecomonLayer, BatchNormalization): - """Forward LiRPA implementation of Batch Normalization layers. - See Keras official documentation for further details on the BatchNormalization operator - """ - - original_keras_layer_class = BatchNormalization - - def __init__( - self, - axis: int = -1, - momentum: float = 0.99, - epsilon: float = 1e-3, - center: bool = True, - scale: bool = True, - beta_initializer: str = "zeros", - gamma_initializer: str = "ones", - moving_mean_initializer: str = "zeros", - moving_variance_initializer: str = "ones", - beta_regularizer: Optional[str] = None, - gamma_regularizer: Optional[str] = None, - beta_constraint: Optional[str] = None, - gamma_constraint: Optional[str] = None, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - axis=axis, - momentum=momentum, - epsilon=epsilon, - center=center, - scale=scale, - beta_initializer=beta_initializer, - gamma_initializer=gamma_initializer, - moving_mean_initializer=moving_mean_initializer, - moving_variance_initializer=moving_variance_initializer, - beta_regularizer=beta_regularizer, - gamma_regularizer=gamma_regularizer, - beta_constraint=beta_constraint, - gamma_constraint=gamma_constraint, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - super().build(input_shape) - self.input_spec = [InputSpec(min_ndim=len(elem)) for elem in input_shape] - - def call(self, inputs: List[BackendTensor], training: bool = False, **kwargs: Any) -> List[BackendTensor]: - z_value = K.cast(0.0, self.dtype) - - if training: - raise NotImplementedError("not working during training") - - def call_op(x: BackendTensor, training: bool) -> BackendTensor: - return BatchNormalization.call(self, x, training=training) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - y = call_op(inputs[-1], training=training) - - n_dim = len(y.shape) - shape = [1] * n_dim - shape[self.axis] = self.moving_mean.shape[0] - - if not hasattr(self, "gamma") or self.gamma is None: # scale = False - gamma = K.ones(shape) - else: # scale = True - gamma = K.reshape(self.gamma + z_value, shape) - if not hasattr(self, "beta") or self.beta is None: # center = False - beta = K.zeros(shape) - else: # center = True - beta = K.reshape(self.beta + z_value, shape) - moving_mean = K.reshape(self.moving_mean + z_value, shape) - moving_variance = K.reshape(self.moving_variance + z_value, shape) - - if self.ibp: - u_c_0 = (u_c - moving_mean) / K.sqrt(moving_variance + self.epsilon) - l_c_0 = (l_c - moving_mean) / K.sqrt(moving_variance + self.epsilon) - u_c_out = K.maximum(z_value, gamma) * u_c_0 + K.minimum(z_value, gamma) * l_c_0 + beta - l_c_out = K.maximum(z_value, gamma) * l_c_0 + K.minimum(z_value, gamma) * u_c_0 + beta - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_0 = (b_u - moving_mean) / K.sqrt(moving_variance + self.epsilon) - b_l_0 = (b_l - moving_mean) / K.sqrt(moving_variance + self.epsilon) - - b_u_out = K.maximum(z_value, gamma) * b_u_0 + K.minimum(z_value, gamma) * b_l_0 + beta - b_l_out = K.maximum(z_value, gamma) * b_l_0 + K.minimum(z_value, gamma) * b_u_0 + beta - - gamma = K.expand_dims(gamma, 1) - moving_variance = K.expand_dims(moving_variance, 1) - - w_u_0 = w_u / K.sqrt(moving_variance + self.epsilon) - w_l_0 = w_l / K.sqrt(moving_variance + self.epsilon) - w_u_out = K.maximum(z_value, gamma) * w_u_0 + K.minimum(z_value, gamma) * w_l_0 - w_l_out = K.maximum(z_value, gamma) * w_l_0 + K.minimum(z_value, gamma) * w_u_0 - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.dc_decomp: - raise NotImplementedError() - else: - h_out, g_out = empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - @property - def keras_weights_names(self) -> List[str]: - """Weights names of the corresponding Keras layer. - - Will be used to decide which weight to take from the keras layer in `reset_layer()` - - """ - weight_names = ["moving_mean", "moving_variance"] - if self.center: - weight_names.append("beta") - if self.scale: - weight_names.append("gamma") - return weight_names - - -class DecomonDropout(DecomonLayer, Dropout): - """Forward LiRPA implementation of Dropout layers. - See Keras official documentation for further details on the Dropout operator - """ - - original_keras_layer_class = Dropout - - def __init__( - self, - rate: float, - noise_shape: Optional[Tuple[int, ...]] = None, - seed: Optional[int] = None, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - rate=rate, - noise_shape=noise_shape, - seed=seed, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - super().build(input_shape) - self.input_spec = [InputSpec(min_ndim=len(elem)) for elem in input_shape] - - def call(self, inputs: List[BackendTensor], training: bool = False, **kwargs: Any) -> List[BackendTensor]: - if training: - raise NotImplementedError("not working during training") - - return inputs - - -class DecomonInputLayer(DecomonLayer, InputLayer): - """Forward LiRPA implementation of Dropout layers. - See Keras official documentation for further details on the Dropout operator - """ - - original_keras_layer_class = InputLayer - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - return inputs - - def __init__( - self, - shape: Optional[Tuple[int, ...]] = None, - batch_size: Optional[int] = None, - dtype: Optional[str] = None, - sparse: Optional[bool] = None, - batch_shape: Optional[Tuple[Optional[int], ...]] = None, - input_tensor: Optional[keras.KerasTensor] = None, - name: Optional[str] = None, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - shape=shape, - batch_size=batch_size, - dtype=dtype, - input_tensor=input_tensor, - sparse=sparse, - name=name, - batch_shape=batch_shape, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) diff --git a/src/decomon/layers/decomon_merge_layers.py b/src/decomon/layers/decomon_merge_layers.py deleted file mode 100644 index 8d925da3..00000000 --- a/src/decomon/layers/decomon_merge_layers.py +++ /dev/null @@ -1,633 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras.ops as K -from keras.layers import ( - Add, - Average, - Concatenate, - Dot, - Lambda, - Layer, - Maximum, - Minimum, - Multiply, - Subtract, -) - -from decomon.core import ForwardMode, PerturbationDomain -from decomon.layers.core import DecomonLayer -from decomon.layers.utils import broadcast, multiply, permute_dimensions -from decomon.types import BackendTensor -from decomon.utils import maximum, minus, subtract - -##### Merge Layer #### - - -class DecomonMerge(DecomonLayer): - """Base class for Decomon layers based on Mergind Keras layers.""" - - def compute_output_shape(self, input_shape: List[Tuple[Optional[int], ...]]) -> List[Tuple[Optional[int], ...]]: # type: ignore - """Compute output shapes from input shapes. - - By default, we assume that all inputs will be merged into "one" (still a list of tensors though). - - """ - # split inputs - input_shapes_list = self.inputs_outputs_spec.split_inputsformode_to_merge(input_shape) - - # compute original layer output shape - y_shapes = [ - self.inputs_outputs_spec.get_kerasinputshape_from_inputshapesformode(input_shapes) - for input_shapes in input_shapes_list - ] # input shapes for the original layer - y_out_shape = self.original_keras_layer_class.compute_output_shape( - self, y_shapes - ) # output shape of the original layer - - # same shape as one input or not? - if y_out_shape == y_shapes[0]: - # same output shape as one input - return input_shapes_list[0] - else: - # something change along the way (cf Concatenate), => we deduce decomon output shape - ( - x_shape, - u_c_shape, - w_u_shape, - b_u_shape, - l_c_shape, - w_l_shape, - b_l_shape, - h_shape, - g_shape, - ) = self.inputs_outputs_spec.get_fullinputshapes_from_inputshapesformode(input_shapes_list[0]) - y_out_shape_wo_batchsize = y_out_shape[1:] - if self.inputs_outputs_spec.affine: - model_inputdim = x_shape[-1] - batchsize = x_shape[0] - w_out_shape = (batchsize, model_inputdim) + y_out_shape_wo_batchsize - else: - w_out_shape = tuple() - fulloutputshapes = [ - x_shape, - y_out_shape, - w_out_shape, - y_out_shape, - y_out_shape, - w_out_shape, - y_out_shape, - y_out_shape, - y_out_shape, - ] - return self.inputs_outputs_spec.extract_inputshapesformode_from_fullinputshapes(fulloutputshapes) - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - n_comp = self.nb_tensors - input_shape_y = input_shape[n_comp - 1 :: n_comp] - self.original_keras_layer_class.build(self, input_shape_y) - - -class DecomonAdd(DecomonMerge, Add): - """LiRPA implementation of Add layers. - See Keras official documentation for further details on the Add operator - - """ - - original_keras_layer_class = Add - - def __init__( - self, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - # splits the inputs - ( - inputs_x, - inputs_u_c, - inputs_w_u, - inputs_b_u, - inputs_l_c, - inputs_w_l, - inputs_b_l, - inputs_h, - inputs_g, - ) = self.inputs_outputs_spec.get_fullinputs_by_type_from_inputsformode_to_merge(inputs) - x_out = inputs_x[0] - dtype = x_out.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - # outputs - if self.ibp: - u_c_out = sum(inputs_u_c) - l_c_out = sum(inputs_l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_out = sum(inputs_b_u) - b_l_out = sum(inputs_b_l) - w_u_out = sum(inputs_w_u) - w_l_out = sum(inputs_w_l) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.dc_decomp: - raise NotImplementedError() - else: - h_out, g_out = empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x_out, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -class DecomonAverage(DecomonMerge, Average): - """LiRPA implementation of Average layers. - See Keras official documentation for further details on the Average operator - - """ - - original_keras_layer_class = Average - - def __init__( - self, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - self.op = Lambda(lambda x: sum(x) / len(x)) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - # splits the inputs - ( - inputs_x, - inputs_u_c, - inputs_w_u, - inputs_b_u, - inputs_l_c, - inputs_w_l, - inputs_b_l, - inputs_h, - inputs_g, - ) = self.inputs_outputs_spec.get_fullinputs_by_type_from_inputsformode_to_merge(inputs) - x_out = inputs_x[0] - dtype = x_out.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - # outputs - if self.ibp: - u_c_out = self.op(inputs_u_c) - l_c_out = self.op(inputs_l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_out = self.op(inputs_b_u) - b_l_out = self.op(inputs_b_l) - w_u_out = self.op(inputs_w_u) - w_l_out = self.op(inputs_w_l) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.dc_decomp: - raise NotImplementedError() - else: - h_out, g_out = empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x_out, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -class DecomonSubtract(DecomonMerge, Subtract): - """LiRPA implementation of Subtract layers. - See Keras official documentation for further details on the Subtract operator - - """ - - original_keras_layer_class = Subtract - - def __init__( - self, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if self.dc_decomp: - raise NotImplementedError() - - # splits the inputs - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - - # check number of inputs - if len(inputs_list) != 2: - raise ValueError("This layer is intended to merge only 2 layers.") - - output = subtract( - inputs_list[0], - inputs_list[1], - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - return output - - -class DecomonMinimum(DecomonMerge, Minimum): - """LiRPA implementation of Minimum layers. - See Keras official documentation for further details on the Minimum operator - - """ - - original_keras_layer_class = Minimum - - def __init__( - self, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if self.dc_decomp: - raise NotImplementedError() - - # splits the inputs - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - # look at minus the input to apply maximum - inputs_list = [ - minus(single_inputs, mode=self.mode, dc_decomp=self.dc_decomp, perturbation_domain=self.perturbation_domain) - for single_inputs in inputs_list - ] - - #  check number of inputs - if len(inputs_list) == 1: # nothing to merge - return inputs - else: - output = maximum( - inputs_list[0], - inputs_list[1], - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - for j in range(2, len(inputs_list)): - output = maximum( - output, - inputs_list[j], - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - - return minus(output, mode=self.mode, dc_decomp=self.dc_decomp, perturbation_domain=self.perturbation_domain) - - -class DecomonMaximum(DecomonMerge, Maximum): - """LiRPA implementation of Maximum layers. - See Keras official documentation for further details on the Maximum operator - - """ - - original_keras_layer_class = Maximum - - def __init__( - self, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if self.dc_decomp: - raise NotImplementedError() - - # splits the inputs - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - - #  check number of inputs - if len(inputs_list) == 1: # nothing to merge - return inputs - else: - output = maximum( - inputs_list[0], - inputs_list[1], - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - for j in range(2, len(inputs_list)): - output = maximum( - output, - inputs_list[j], - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - - return output - - -class DecomonConcatenate(DecomonMerge, Concatenate): - """LiRPA implementation of Concatenate layers. - See Keras official documentation for further details on the Concatenate operator - - """ - - original_keras_layer_class = Concatenate - - def __init__( - self, - axis: int = -1, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - axis=axis, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def func(inputs: List[BackendTensor]) -> BackendTensor: - return Concatenate.call(self, inputs) - - self.op = func - if self.axis == -1: - self.op_w = self.op - else: - self.op_w = Concatenate(axis=self.axis + 1) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - # splits the inputs - ( - inputs_x, - inputs_u_c, - inputs_w_u, - inputs_b_u, - inputs_l_c, - inputs_w_l, - inputs_b_l, - inputs_h, - inputs_g, - ) = self.inputs_outputs_spec.get_fullinputs_by_type_from_inputsformode_to_merge(inputs) - x_out = inputs_x[0] - dtype = x_out.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - # outputs - if self.ibp: - u_c_out = self.op(inputs_u_c) - l_c_out = self.op(inputs_l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_out = self.op(inputs_b_u) - b_l_out = self.op(inputs_b_l) - w_u_out = self.op_w(inputs_w_u) - w_l_out = self.op_w(inputs_w_l) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.dc_decomp: - raise NotImplementedError() - else: - h_out, g_out = empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x_out, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -class DecomonMultiply(DecomonMerge, Multiply): - """LiRPA implementation of Multiply layers. - See Keras official documentation for further details on the Multiply operator - - """ - - original_keras_layer_class = Multiply - - def __init__( - self, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if self.dc_decomp: - raise NotImplementedError() - - # splits the inputs - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - - #  check number of inputs - if len(inputs_list) == 1: # nothing to merge - return inputs - else: - output = multiply( - inputs_list[0], - inputs_list[1], - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - for j in range(2, len(inputs_list)): - output = multiply( - output, - inputs_list[j], - dc_decomp=self.dc_decomp, - perturbation_domain=self.perturbation_domain, - mode=self.mode, - ) - - return output - - -class DecomonDot(DecomonMerge, Dot): - """LiRPA implementation of Dot layers. - See Keras official documentation for further details on the Dot operator - - """ - - original_keras_layer_class = Dot - - def __init__( - self, - axes: Union[int, Tuple[int, int]] = (-1, -1), - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - axes=axes, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - if isinstance(axes, int): - self.axes = (axes, axes) - else: - self.axes = axes - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if self.dc_decomp: - raise NotImplementedError() - - # splits the inputs - inputs_list = self.inputs_outputs_spec.split_inputsformode_to_merge(inputs) - - #  check number of inputs - if len(inputs_list) == 1: # nothing to merge - return inputs - elif len(inputs_list) == 2: - inputs_0, inputs_1 = inputs_list - else: - raise NotImplementedError("This layer is not implemented to merge more than 2 layers.") - - input_shape_0 = self.inputs_outputs_spec.get_kerasinputshape(inputs_0) - input_shape_1 = self.inputs_outputs_spec.get_kerasinputshape(inputs_1) - n_0 = len(input_shape_0) - 2 - n_1 = len(input_shape_1) - 2 - - inputs_0 = permute_dimensions(inputs_0, self.axes[0], mode=self.mode, dc_decomp=self.dc_decomp) - inputs_1 = permute_dimensions(inputs_1, self.axes[1], mode=self.mode, dc_decomp=self.dc_decomp) - inputs_0 = broadcast( - inputs_0, n_1, -1, mode=self.mode, dc_decomp=self.dc_decomp, perturbation_domain=self.perturbation_domain - ) - inputs_1 = broadcast( - inputs_1, n_0, 2, mode=self.mode, dc_decomp=self.dc_decomp, perturbation_domain=self.perturbation_domain - ) - outputs_multiply = multiply( - inputs_0, inputs_1, dc_decomp=self.dc_decomp, perturbation_domain=self.perturbation_domain, mode=self.mode - ) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - outputs_multiply, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if self.ibp: - u_c_out = K.sum(u_c, 1) - l_c_out = K.sum(l_c, 1) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - w_u_out = K.sum(w_u, 2) - b_u_out = K.sum(b_u, 1) - w_l_out = K.sum(w_l, 2) - b_l_out = K.sum(b_l, 1) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.dc_decomp: - raise NotImplementedError() - else: - h_out, g_out = empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) diff --git a/src/decomon/layers/decomon_reshape.py b/src/decomon/layers/decomon_reshape.py deleted file mode 100644 index f0937010..00000000 --- a/src/decomon/layers/decomon_reshape.py +++ /dev/null @@ -1,221 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple, Type, Union - -from keras.layers import InputSpec, Layer, Permute, Reshape -from keras.src.backend import rnn - -from decomon.core import ForwardMode, PerturbationDomain, get_affine, get_ibp -from decomon.layers.core import DecomonLayer -from decomon.types import BackendTensor - - -class DecomonReshape(DecomonLayer, Reshape): - """Forward LiRPA implementation of Reshape layers. - See Keras official documentation for further details on the Reshape operator - """ - - original_keras_layer_class = Reshape - - def __init__( - self, - target_shape: Tuple[int, ...], - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - """ - Args: - data_format - **kwargs - """ - super().__init__( - target_shape=target_shape, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - if self.mode == ForwardMode.HYBRID: - self.input_spec = [ - InputSpec(min_ndim=1), # z - InputSpec(min_ndim=1), # u_c - InputSpec(min_ndim=1), # w_u - InputSpec(min_ndim=1), # b_u - InputSpec(min_ndim=1), # l_c - InputSpec(min_ndim=1), # w_l - InputSpec(min_ndim=1), # b_l - ] - elif self.mode == ForwardMode.IBP: - self.input_spec = [ - InputSpec(min_ndim=1), # u_c - InputSpec(min_ndim=1), # l_c - ] - if self.mode == ForwardMode.AFFINE: - self.input_spec = [ - InputSpec(min_ndim=1), # z - InputSpec(min_ndim=1), # w_u - InputSpec(min_ndim=1), # b_u - InputSpec(min_ndim=1), # w_l - InputSpec(min_ndim=1), # b_l - ] - - if self.dc_decomp: - self.input_spec += [InputSpec(min_ndim=1), InputSpec(min_ndim=1)] - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - def op(x: BackendTensor) -> BackendTensor: - return Reshape.call(self, x) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if self.dc_decomp: - h_out = op(h) - g_out = op(g) - else: - h_out, g_out = empty_tensor, empty_tensor - - if self.ibp: - u_c_out = op(u_c) - l_c_out = op(l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_out = op(b_u) - b_l_out = op(b_l) - - if len(w_u.shape) == len(b_u.shape): - w_u_out = op(w_u) - w_l_out = op(w_l) - - else: - - def step_func(x: BackendTensor, _: List[BackendTensor]) -> Tuple[BackendTensor, List[BackendTensor]]: - return op(x), _ - - w_u_out = rnn(step_function=step_func, inputs=w_u, initial_states=[], unroll=False)[1] - w_l_out = rnn(step_function=step_func, inputs=w_l, initial_states=[], unroll=False)[1] - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -class DecomonPermute(DecomonLayer, Permute): - """Forward LiRPA implementation of Reshape layers. - See Keras official documentation for further details on the Reshape operator - """ - - original_keras_layer_class = Permute - - def __init__( - self, - dims: Tuple[int, ...], - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - """ - Args: - data_format - **kwargs - """ - super().__init__( - dims=dims, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - if self.mode == ForwardMode.HYBRID: - self.input_spec = [ - InputSpec(min_ndim=1), # z - InputSpec(min_ndim=1), # u_c - InputSpec(min_ndim=1), # w_u - InputSpec(min_ndim=1), # b_u - InputSpec(min_ndim=1), # l_c - InputSpec(min_ndim=1), # w_l - InputSpec(min_ndim=1), # b_l - ] - elif self.mode == ForwardMode.IBP: - self.input_spec = [ - InputSpec(min_ndim=1), # u_c - InputSpec(min_ndim=1), # l_c - ] - elif self.mode == ForwardMode.AFFINE: - self.input_spec = [ - InputSpec(min_ndim=1), # z - InputSpec(min_ndim=1), # w_u - InputSpec(min_ndim=1), # b_u - InputSpec(min_ndim=1), # w_l - InputSpec(min_ndim=1), # b_l - ] - else: - raise ValueError(f"Unknown mode {mode}") - - if self.dc_decomp: - self.input_spec += [InputSpec(min_ndim=1), InputSpec(min_ndim=1)] - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - def op(x: BackendTensor) -> BackendTensor: - return Permute.call(self, x) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if self.dc_decomp: - h_out = op(h) - g_out = op(g) - else: - h_out, g_out = empty_tensor, empty_tensor - - if self.ibp: - u_c_out = op(u_c) - l_c_out = op(l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_out = op(b_u) - b_l_out = op(b_l) - - if len(w_u.shape) == len(b_u.shape): - w_u_out = op(w_u) - w_l_out = op(w_l) - else: - - def step_func(x: BackendTensor, _: List[BackendTensor]) -> Tuple[BackendTensor, List[BackendTensor]]: - return op(x), _ - - w_u_out = rnn(step_function=step_func, inputs=w_u, initial_states=[], unroll=False)[1] - w_l_out = rnn(step_function=step_func, inputs=w_l, initial_states=[], unroll=False)[1] - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) diff --git a/src/decomon/layers/fuse.py b/src/decomon/layers/fuse.py new file mode 100644 index 00000000..5886133c --- /dev/null +++ b/src/decomon/layers/fuse.py @@ -0,0 +1,597 @@ +"""Layers specifying constant oracle bounds on keras layer input.""" + + +from typing import Any, Optional + +from keras import ops as K +from keras.layers import Layer + +from decomon.keras_utils import batch_multid_dot +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.perturbation_domain import get_lower_box, get_upper_box +from decomon.types import BackendTensor, Tensor + + +class Fuse(Layer): + """Layer combining bounds on successive models. + + We merge (ibp and/or affine) bounds on 2 models which are supposed to be chained. + Both models can or not have both types of bounds + + We have, for any x in the given perturbation domain: + + m(x) = m2(m1(x)) + + w_l_1 * x + b_l_1 <= m1(x) <= w_u_1 * x + b_u_1 + l_c_1 <= m1(x) <= u_c_1 + + w_l_2 * m1(x) + b_l_2 <= m2(m1(x)) <= w_u_2 * m1(x) + b_u_2 + l_c_2 <= m2(m1(x)) <= u_c_2 + + and we will deduce [w_l, b_l, w_u, b_u, u_c, l_c] such that + + w_l * x + b_l <= m(x) <= w_u * x + b_u + l_c <= m(x) <= u_c + + In the case of multiple outputs for the first model, we assume that each output of m1 is branche to a model + with a single output. That is to say, we have something like this: + + m(x)[i] = m2[i](m1(x)[i]) + + i.e. i-th output of m(x) is the i-th output of m1(x) chained with a model m2[i] with single output. + + Hypothesis: the first model has only a single input. + + """ + + def __init__( + self, + ibp_1: bool, + affine_1: bool, + ibp_2: bool, + affine_2: bool, + m1_input_shape: tuple[int, ...], + m_1_output_shapes: list[tuple[int, ...]], + from_linear_2: list[bool], + **kwargs: Any, + ): + """ + + Args: + ibp_1: specifying if first model constant bounds have been computed + affine_1: specifying if first model affine bounds have been computed + ibp_2: specifying if second model constant bounds have been computed + affine_2: specifying if second model affine bounds have been computed + m1_input_shape: input shape of the first model (w/o batchsize) + m_1_output_shapes: shape of each output of the first model (w/o batchsize) + from_linear_2: specifying if affine bounds for second model are from linear layers + i.e. no bacthsize and w_l == w_u and b_l == b_u + **kwargs: passed to Layer.__init__() + + """ + if not ibp_1 and not affine_1: + raise ValueError("ibp_1 and affine_1 cannot be both False.") + if not ibp_2 and not affine_2: + raise ValueError("ibp_2 and affine_2 cannot be both False.") + if len(m_1_output_shapes) == 0: + raise ValueError("m_1_output_shapes cannot be empty") + if not isinstance(m_1_output_shapes[0], tuple): + raise ValueError("m_1_output_shapes must be a list of shapes (tuple of integers)") + + super().__init__(**kwargs) + + self.m_1_output_shapes = m_1_output_shapes + self.m1_input_shape = m1_input_shape + self.ibp_1 = ibp_1 + self.affine_1 = affine_1 + self.ibp_2 = ibp_2 + self.affine_2 = affine_2 + self.from_linear_2 = from_linear_2 + + self.nb_outputs_first_model = len(m_1_output_shapes) + + self.ibp_fused = self.ibp_2 or (self.ibp_1 and self.affine_2) + self.affine_fused = self.affine_1 and self.affine_2 + + self.inputs_outputs_spec_1 = InputsOutputsSpec(ibp=ibp_1, affine=affine_1, layer_input_shape=m1_input_shape) + self.inputs_outputs_spec_2 = [ + InputsOutputsSpec(ibp=ibp_2, affine=affine_2, layer_input_shape=m2_input_shape) + for m2_input_shape in m_1_output_shapes + ] + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "ibp_1": self.ibp_1, + "affine_1": self.affine_1, + "ibp_2": self.ibp_2, + "affine_2": self.affine_2, + "m1_input_shape": self.m1_input_shape, + "m_1_output_shapes": self.m_1_output_shapes, + "from_linear_2": self.from_linear_2, + } + ) + return config + + def build(self, input_shape: tuple[list[tuple[Optional[int], ...]], list[tuple[Optional[int], ...]]]) -> None: + input_shape_1, input_shape_2 = input_shape + + # check number of inputs + if len(input_shape_1) != self.inputs_outputs_spec_1.nb_output_tensors * self.nb_outputs_first_model: + raise ValueError( + f"The first input should be a list whose length is the product of " + f"{self.inputs_outputs_spec_1.nb_output_tensors} (number of tensors per bound) " + f"by {self.nb_outputs_first_model} (number of outputs for the first model)." + ) + + if len(input_shape_2) != self.inputs_outputs_spec_2[0].nb_output_tensors * self.nb_outputs_first_model: + raise ValueError( + f"The second input should be a list whose length is the product of" + f"{self.inputs_outputs_spec_2[0].nb_output_tensors} (number of tensors per bound) " + f"by {self.nb_outputs_first_model} (number of outputs for the first model)." + ) + + self.built = True + + def _is_from_linear_m1_ith_affine_bounds(self, affine_bounds: list[Tensor], i: int) -> bool: + return len(affine_bounds) == 0 or affine_bounds[1].shape == self.m_1_output_shapes[i] + + def _is_from_linear_m1_ith_affine_bounds_shape( + self, affine_bounds_shape: list[tuple[Optional[int], ...]], i: int + ) -> bool: + return len(affine_bounds_shape) == 0 or affine_bounds_shape[1] == self.m_1_output_shapes[i] + + def call(self, inputs: tuple[list[BackendTensor], list[BackendTensor]]) -> list[BackendTensor]: + """Fuse affine bounds. + + Args: + inputs: (sum_{i} affine_bounds_1[i] + constant_bounds_1[i], sum_{i} affine_bounds_2[i] + constant_bounds2[i]) + being the affine and constant bounds on first and second model for each output of the first model, with + - i: the indice of the *first* model output considered + - sum_{i}: the concatenation of subsequent lists over i + - affine_bounds_1[i]: empty if `self.affine_1` is False + - constant_bounds_1[i]: empty if `self.ibp_1` is False + - affine_bounds_2[i]: empty if `self.affine_2` is False + - constant_bounds_2[i]: empty if `self.ibp_2` is False + + Returns: + sum_{i} affine_bounds_fused[i] + constant_bounds_fused[i]: fused affine and constant bounds for each output of the first model + + """ + bounds_1, bounds_2 = inputs + + bounds_fused: list[BackendTensor] = [] + for i in range(self.nb_outputs_first_model): + bounds_1_i = bounds_1[ + i + * self.inputs_outputs_spec_1.nb_output_tensors : (i + 1) + * self.inputs_outputs_spec_1.nb_output_tensors + ] + affine_bounds_1, constant_bounds_1 = self.inputs_outputs_spec_1.split_outputs(bounds_1_i) + + bounds_2_i = bounds_2[ + i + * self.inputs_outputs_spec_2[0].nb_output_tensors : (i + 1) + * self.inputs_outputs_spec_2[0].nb_output_tensors + ] + affine_bounds_2, constant_bounds_2 = self.inputs_outputs_spec_2[0].split_outputs(bounds_2_i) + + # constant bounds + if self.ibp_2: + # ibp bounds already computed in second model + constant_bounds_fused = constant_bounds_2 + elif self.ibp_1 and self.affine_2: + # combine constant bounds on first model with affine bounds on second model + lower, upper = constant_bounds_1 + constant_bounds_fused = list( + combine_affine_bound_with_constant_bound( + lower=lower, + upper=upper, + affine_bounds=affine_bounds_2, + missing_batchsize=self.from_linear_2[i], + ) + ) + else: + constant_bounds_fused = [] + + # affine bounds + if self.affine_1 and self.affine_2: + diagonal = ( + self.inputs_outputs_spec_1.is_diagonal_bounds(affine_bounds_1), + self.inputs_outputs_spec_2[i].is_diagonal_bounds(affine_bounds_2), + ) + from_linear_layer = ( + self._is_from_linear_m1_ith_affine_bounds(affine_bounds=affine_bounds_1, i=i), + self.from_linear_2[i], + ) + affine_bounds_fused = list( + combine_affine_bounds( + affine_bounds_1=affine_bounds_1, + affine_bounds_2=affine_bounds_2, + diagonal=diagonal, + from_linear_layer=from_linear_layer, + ) + ) + else: + affine_bounds_fused = [] + + # concatenate bounds + bounds_fused += affine_bounds_fused + constant_bounds_fused + + return bounds_fused + + def compute_output_shape( + self, input_shape: tuple[list[tuple[Optional[int], ...]], list[tuple[Optional[int], ...]]] + ) -> list[tuple[Optional[int], ...]]: + bounds_1_shape, bounds_2_shape = input_shape + + bounds_fused_shape: list[tuple[Optional[int], ...]] = [] + for i in range(self.nb_outputs_first_model): + bounds_1_i_shape = bounds_1_shape[ + i + * self.inputs_outputs_spec_1.nb_output_tensors : (i + 1) + * self.inputs_outputs_spec_1.nb_output_tensors + ] + affine_bounds_1_shape: list[tuple[Optional[int], ...]] + constant_bounds_1_shape: list[tuple[Optional[int], ...]] + affine_bounds_1_shape, constant_bounds_1_shape = self.inputs_outputs_spec_1.split_output_shape( # type: ignore + bounds_1_i_shape + ) + + bounds_2_i_shape = bounds_2_shape[ + i + * self.inputs_outputs_spec_2[0].nb_output_tensors : (i + 1) + * self.inputs_outputs_spec_2[0].nb_output_tensors + ] + affine_bounds_2_shape: list[tuple[Optional[int], ...]] + constant_bounds_2_shape: list[tuple[Optional[int], ...]] + ( + affine_bounds_2_shape, + constant_bounds_2_shape, + ) = self.inputs_outputs_spec_2[ # type:ignore + 0 + ].split_output_shape(bounds_2_i_shape) + + # constant bounds + if self.ibp_2: + # ibp bounds already computed in second model + constant_bounds_fused_shape = constant_bounds_2_shape + elif self.ibp_1 and self.affine_2: + # combine constant bounds on first model with affine bounds on second model + _, b2_shape, _, _ = affine_bounds_2_shape + if self.from_linear_2[i]: + lower_fused_shape = (None,) + b2_shape + else: + lower_fused_shape = b2_shape + constant_bounds_fused_shape = [lower_fused_shape, lower_fused_shape] + else: + constant_bounds_fused_shape = [] + + # affine bounds + if self.affine_1 and self.affine_2: + _, b2_shape, _, _ = affine_bounds_2_shape + model_2_output_shape_wo_batchisze: tuple[int, ...] + if self.from_linear_2[i]: + model_2_output_shape_wo_batchisze = b2_shape # type: ignore + else: + model_2_output_shape_wo_batchisze = b2_shape[1:] # type: ignore + + diagonal = self.inputs_outputs_spec_1.is_diagonal_bounds_shape( + affine_bounds_1_shape + ) and self.inputs_outputs_spec_2[i].is_diagonal_bounds_shape(affine_bounds_2_shape) + if diagonal: + w_fused_shape_wo_batchsize = self.m1_input_shape + else: + w_fused_shape_wo_batchsize = self.m1_input_shape + model_2_output_shape_wo_batchisze + + from_linear_layer = ( + self._is_from_linear_m1_ith_affine_bounds_shape(affine_bounds_shape=affine_bounds_1_shape, i=i) + and self.from_linear_2[i] + ) + w_fused_shape: tuple[Optional[int], ...] + b_fused_shape: tuple[Optional[int], ...] + if from_linear_layer: + w_fused_shape = w_fused_shape_wo_batchsize + b_fused_shape = model_2_output_shape_wo_batchisze + else: + w_fused_shape = (None,) + w_fused_shape_wo_batchsize + b_fused_shape = (None,) + model_2_output_shape_wo_batchisze + + affine_bounds_fused_shape = [w_fused_shape, b_fused_shape, w_fused_shape, b_fused_shape] + else: + affine_bounds_fused_shape = [] + + # concatenate bounds + bounds_fused_shape += affine_bounds_fused_shape + constant_bounds_fused_shape + + return bounds_fused_shape + + +def combine_affine_bounds( + affine_bounds_1: list[Tensor], + affine_bounds_2: list[Tensor], + from_linear_layer: tuple[bool, bool] = (False, False), + diagonal: tuple[bool, bool] = (False, False), +) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Combine affine bounds + + Args: + affine_bounds_1: [w_l_1, b_l_1, w_u_1, b_u_1] first affine bounds + affine_bounds_2: [w_l_2, b_l_2, w_u_2, b_u_2] second affine bounds + from_linear_layer: specify if affine_bounds_1 or affine_bounds_2 + come from the affine representation of a linear layer + diagonal: specify if affine_bounds_1 or affine_bounds_2 + are in diagonal representation + + Returns: + w_l, b_l, w_u, b_u: combined affine bounds + + If x, y, z satisfy + w_l_1 * x + b_l_1 <= y <= w_u_1 * x + b_u_1 + w_l_2 * y + b_l_2 <= z <= w_u_2 * y + b_u_2 + + Then + w_l * x + b_l <= z <= w_u * x + b_u + + + Special cases + + - with linear layers: + + If the affine bounds come from the affine representation of a linear layer (e.g. affine_bounds_1), then + - lower and upper bounds are equal: affine_bounds_1 = [w_1, b_1, w_1, b_1] + - the tensors are missing the batch dimension + + In the generic case, tensors in affine_bounds have their first axis corresponding to the batch size. + + - diagonal representation: + + If w.shape == b.shape, this means that w is represented by its "diagonal" (potentially a tensor-multid). + + - empty affine bounds: + + when one affine bounds is an empty list, this is actually a convention for identity bounds, i.e. + w = identity, b = 0 + therefore we return the other affine_bounds, unchanged. + + """ + # special case: empty bounds <=> identity bounds + if len(affine_bounds_1) == 0: + return tuple(affine_bounds_2) + if len(affine_bounds_2) == 0: + return tuple(affine_bounds_1) + + if from_linear_layer == (False, False): + return _combine_affine_bounds_generic( + affine_bounds_1=affine_bounds_1, affine_bounds_2=affine_bounds_2, diagonal=diagonal + ) + elif from_linear_layer == (True, False): + return _combine_affine_bounds_left_from_linear( + affine_bounds_1=affine_bounds_1, affine_bounds_2=affine_bounds_2, diagonal=diagonal + ) + elif from_linear_layer == (False, True): + return _combine_affine_bounds_right_from_linear( + affine_bounds_1=affine_bounds_1, affine_bounds_2=affine_bounds_2, diagonal=diagonal + ) + else: + return _combine_affine_bounds_both_from_linear( + affine_bounds_1=affine_bounds_1, affine_bounds_2=affine_bounds_2, diagonal=diagonal + ) + + +def _combine_affine_bounds_generic( + affine_bounds_1: list[Tensor], + affine_bounds_2: list[Tensor], + diagonal: tuple[bool, bool], +) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Combine affine bounds + + Args: + affine_bounds_1: [w_l_1, b_l_1, w_u_1, b_u_1] first affine bounds + affine_bounds_2: [w_l_2, b_l_2, w_u_2, b_u_2] second affine bounds + diagonal: specify if weights of each affine bounds are in diagonal representation or not + + Returns: + w_l, b_l, w_u, b_u: combined affine bounds + + If x, y, z satisfy + w_l_1 * x + b_l_1 <= y <= w_u_1 * x + b_u_1 + w_l_2 * y + b_l_2 <= z <= w_u_2 * x + b_u_2 + + Then + w_l * x + b_l <= z <= w_u * x + b_u + + """ + w_l_1, b_l_1, w_u_1, b_u_1 = affine_bounds_1 + w_l_2, b_l_2, w_u_2, b_u_2 = affine_bounds_2 + nb_axes_wo_batchsize_y = len(b_l_1.shape) - 1 + + #  NB: bias is never a diagonal representation! => we split kwargs_dot_w and kwargs_dot_b + kwargs_dot_w: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + diagonal=diagonal, + ) + kwargs_dot_b: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + diagonal=(False, diagonal[1]), + ) + + z_value = K.cast(0.0, dtype=w_l_2.dtype) + w_l_2_pos = K.maximum(w_l_2, z_value) + w_u_2_pos = K.maximum(w_u_2, z_value) + w_l_2_neg = K.minimum(w_l_2, z_value) + w_u_2_neg = K.minimum(w_u_2, z_value) + + w_l = batch_multid_dot(w_l_1, w_l_2_pos, **kwargs_dot_w) + batch_multid_dot(w_u_1, w_l_2_neg, **kwargs_dot_w) + w_u = batch_multid_dot(w_u_1, w_u_2_pos, **kwargs_dot_w) + batch_multid_dot(w_l_1, w_u_2_neg, **kwargs_dot_w) + b_l = ( + batch_multid_dot(b_l_1, w_l_2_pos, **kwargs_dot_b) + batch_multid_dot(b_u_1, w_l_2_neg, **kwargs_dot_b) + b_l_2 + ) + b_u = ( + batch_multid_dot(b_u_1, w_u_2_pos, **kwargs_dot_b) + batch_multid_dot(b_l_1, w_u_2_neg, **kwargs_dot_b) + b_u_2 + ) + + return w_l, b_l, w_u, b_u + + +def _combine_affine_bounds_right_from_linear( + affine_bounds_1: list[Tensor], + affine_bounds_2: list[Tensor], + diagonal: tuple[bool, bool], +) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Combine affine bounds + + Args: + affine_bounds_1: [w_l_1, b_l_1, w_u_1, b_u_1] first affine bounds + affine_bounds_2: [w_2, b_2, w_2, b_2] second affine bounds, with lower=upper + no batchsize + diagonal: specify if weights of each affine bounds are in diagonal representation or not + + Returns: + w_l, b_l, w_u, b_u: combined affine bounds + + If x, y, z satisfy + w_l_1 * x + b_l_1 <= y <= w_u_1 * x + b_u_1 + z = w_2 * y + b_2 + + Then + w_l * x + b_l <= z <= w_u * x + b_u + + """ + w_l_1, b_l_1, w_u_1, b_u_1 = affine_bounds_1 + w_2, b_2 = affine_bounds_2[:2] + nb_axes_wo_batchsize_y = len(b_l_1.shape) - 1 + missing_batchsize = (False, True) + + # NB: bias is never a diagonal representation! => we split kwargs_dot_w and kwargs_dot_b + kwargs_dot_w: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + missing_batchsize=missing_batchsize, + diagonal=diagonal, + ) + kwargs_dot_b: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + missing_batchsize=missing_batchsize, + diagonal=(False, diagonal[1]), + ) + + z_value = K.cast(0.0, dtype=w_2.dtype) + w_2_pos = K.maximum(w_2, z_value) + w_2_neg = K.minimum(w_2, z_value) + + w_l = batch_multid_dot(w_l_1, w_2_pos, **kwargs_dot_w) + batch_multid_dot(w_u_1, w_2_neg, **kwargs_dot_w) + w_u = batch_multid_dot(w_u_1, w_2_pos, **kwargs_dot_w) + batch_multid_dot(w_l_1, w_2_neg, **kwargs_dot_w) + b_l = batch_multid_dot(b_l_1, w_2_pos, **kwargs_dot_b) + batch_multid_dot(b_u_1, w_2_neg, **kwargs_dot_b) + b_2 + b_u = batch_multid_dot(b_u_1, w_2_pos, **kwargs_dot_b) + batch_multid_dot(b_l_1, w_2_neg, **kwargs_dot_b) + b_2 + + return w_l, b_l, w_u, b_u + + +def _combine_affine_bounds_left_from_linear( + affine_bounds_1: list[Tensor], + affine_bounds_2: list[Tensor], + diagonal: tuple[bool, bool], +) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Combine affine bounds + + Args: + affine_bounds_1: [w_1, b_1, w_1, b_1] first affine bounds, with lower=upper + no batchsize + affine_bounds_2: [w_l_2, b_l_2, w_u_2, b_u_2] second affine bounds + diagonal: specify if weights of each affine bounds are in diagonal representation or not + + Returns: + w_l, b_l, w_u, b_u: combined affine bounds + + If x, y, z satisfy + y = w_1 * x + b_1 + w_l_2 * y + b_l_2 <= z <= w_u_2 * x + b_u_2 + + Then + w_l * x + b_l <= z <= w_u * x + b_u + + """ + w_1, b_1 = affine_bounds_1[:2] + w_l_2, b_l_2, w_u_2, b_u_2 = affine_bounds_2 + nb_axes_wo_batchsize_y = len(b_1.shape) + missing_batchsize = (True, False) + + #   NB: bias is never a diagonal representation! => we split kwargs_dot_w and kwargs_dot_b + kwargs_dot_w: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + missing_batchsize=missing_batchsize, + diagonal=diagonal, + ) + kwargs_dot_b: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + missing_batchsize=missing_batchsize, + diagonal=(False, diagonal[1]), + ) + + w_l = batch_multid_dot(w_1, w_l_2, **kwargs_dot_w) + w_u = batch_multid_dot(w_1, w_u_2, **kwargs_dot_w) + b_l = batch_multid_dot(b_1, w_l_2, **kwargs_dot_b) + b_l_2 + b_u = batch_multid_dot(b_1, w_u_2, **kwargs_dot_b) + b_u_2 + + return w_l, b_l, w_u, b_u + + +def _combine_affine_bounds_both_from_linear( + affine_bounds_1: list[Tensor], + affine_bounds_2: list[Tensor], + diagonal: tuple[bool, bool], +) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Combine affine bounds + + Args: + affine_bounds_1: [w_1, b_1, w_1, b_1] first affine bounds, with lower=upper + no batchsize + affine_bounds_2: [w_2, b_2, w_2, b_2] second affine bounds, with lower=upper + no batchsize + diagonal: specify if weights of each affine bounds are in diagonal representation or not + + Returns: + w, b, w, b: combined affine bounds + + If x, y, z satisfy + y = w_1 * x + b_1 + z = w_2 * x + b_2 + + Then + z = w * x + b + + """ + w_1, b_1 = affine_bounds_1[:2] + w_2, b_2 = affine_bounds_2[:2] + nb_axes_wo_batchsize_y = len(b_1.shape) + missing_batchsize = (True, True) + + #   NB: bias is never a diagonal representation! => we split kwargs_dot_w and kwargs_dot_b + kwargs_dot_w: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + missing_batchsize=missing_batchsize, + diagonal=diagonal, + ) + kwargs_dot_b: dict[str, Any] = dict( + nb_merging_axes=nb_axes_wo_batchsize_y, + missing_batchsize=missing_batchsize, + diagonal=(False, diagonal[1]), + ) + + w = batch_multid_dot(w_1, w_2, **kwargs_dot_w) + b = batch_multid_dot(b_1, w_2, **kwargs_dot_b) + b_2 + + return w, b, w, b + + +def combine_affine_bound_with_constant_bound( + lower: Tensor, + upper: Tensor, + affine_bounds: list[Tensor], + missing_batchsize: bool = False, +) -> tuple[Tensor, Tensor]: + if len(affine_bounds) == 0: + # identity affine bounds + return lower, upper + + w_l, b_l, w_u, b_u = affine_bounds + lower_fused = get_lower_box(x_min=lower, x_max=upper, w=w_l, b=b_l, missing_batchsize=missing_batchsize) + upper_fused = get_upper_box(x_min=lower, x_max=upper, w=w_u, b=b_u, missing_batchsize=missing_batchsize) + return lower_fused, upper_fused diff --git a/src/decomon/layers/input.py b/src/decomon/layers/input.py new file mode 100644 index 00000000..a2e79921 --- /dev/null +++ b/src/decomon/layers/input.py @@ -0,0 +1,380 @@ +"""Generate decomon inputs from perturbation domain input.""" + + +from typing import Any, Optional, Union + +import keras +import keras.ops as K +from keras.layers import Layer +from keras.utils import serialize_keras_object + +from decomon.constants import Propagation +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.perturbation_domain import PerturbationDomain +from decomon.types import BackendTensor + + +class ForwardInput(Layer): + """Layer generating the input of the first forward layer of a decomon model.""" + + def __init__( + self, + perturbation_domain: PerturbationDomain, + ibp: bool, + affine: bool, + **kwargs: Any, + ): + """ + Args: + perturbation_domain: default to a box domain + ibp: if True, forward propagate constant bounds + affine: if True, forward propagate affine bounds + **kwargs: + + """ + super().__init__(**kwargs) + + self.perturbation_domain = perturbation_domain + self.ibp = ibp + self.affine = affine + self.inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=Propagation.FORWARD, + model_input_shape=tuple(), + layer_input_shape=tuple(), + ) + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "ibp": self.ibp, + "affine": self.affine, + "perturbation_domain": serialize_keras_object(self.perturbation_domain), + } + ) + return config + + def call(self, inputs: BackendTensor) -> list[BackendTensor]: + """Generate ibp and affine bounds to propagate by the first forward layer. + + Args: + inputs: the perturbation domain input + + Returns: + affine_bounds + constant_bounds: the affine and constant bounds concatenated + + - affine_bounds: [w, 0, w, 0], with w the identity tensor of the proper shape + (without batchsize, in diagonal representation) + - constant_bounds: deduced from perturbation domain type and input + + """ + if self.affine: + keras_input_like_tensor_wo_batchsize = self.perturbation_domain.get_kerasinputlike_from_x(x=inputs)[0] + # identity: diag representation + w/o batchisze + w = K.ones_like(keras_input_like_tensor_wo_batchsize) + b = K.zeros_like(keras_input_like_tensor_wo_batchsize) + affine_bounds = [w, b, w, b] + else: + affine_bounds = [] + if self.ibp: + constant_bounds = [ + self.perturbation_domain.get_lower_x(x=inputs), + self.perturbation_domain.get_upper_x(x=inputs), + ] + else: + constant_bounds = [] + return self.inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds, + constant_oracle_bounds=constant_bounds, + perturbation_domain_inputs=[], + ) + + def compute_output_shape( + self, + input_shape: tuple[Optional[int], ...], + ) -> list[tuple[Optional[int], ...]]: + perturbation_domain_input_shape_wo_batchsize: tuple[int, ...] = input_shape[1:] # type: ignore + keras_input_shape_wo_batchsize = self.perturbation_domain.get_keras_input_shape_wo_batchsize( + x_shape=perturbation_domain_input_shape_wo_batchsize + ) + + if self.affine: + w_shape = keras_input_shape_wo_batchsize + b_shape = keras_input_shape_wo_batchsize + affine_bounds_shape = [w_shape, b_shape, w_shape, b_shape] + else: + affine_bounds_shape = [] + if self.ibp: + lower_shape = (None,) + keras_input_shape_wo_batchsize + constant_bounds_shape = [lower_shape, lower_shape] + else: + constant_bounds_shape = [] + return self.inputs_outputs_spec.flatten_inputs_shape( + affine_bounds_to_propagate_shape=affine_bounds_shape, # type: ignore + constant_oracle_bounds_shape=constant_bounds_shape, + perturbation_domain_inputs_shape=[], + ) + + +class IdentityInput(Layer): + """Layer generating identity affine bounds.""" + + def __init__( + self, + perturbation_domain: PerturbationDomain, + **kwargs: Any, + ): + """ + Args: + perturbation_domain: + **kwargs: + + """ + super().__init__(**kwargs) + + self.perturbation_domain = perturbation_domain + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "perturbation_domain": serialize_keras_object(self.perturbation_domain), + } + ) + return config + + def call(self, inputs: BackendTensor) -> list[BackendTensor]: + """Generate ibp and affine bounds to propagate by the first forward layer. + + Args: + inputs: the perturbation domain input + + Returns: + affine_bounds: [w, 0, w, 0], with w the identity tensor of the proper shape + (without batchsize, in diagonal representation) + + """ + keras_input_like_tensor_wo_batchsize = self.perturbation_domain.get_kerasinputlike_from_x(x=inputs)[0] + w = K.ones_like(keras_input_like_tensor_wo_batchsize) + b = K.zeros_like(keras_input_like_tensor_wo_batchsize) + return [w, b, w, b] + + def compute_output_shape( + self, + input_shape: tuple[Optional[int], ...], + ) -> list[tuple[Optional[int], ...]]: + perturbation_domain_input_shape_wo_batchsize: tuple[int, ...] = input_shape[1:] # type: ignore + keras_input_shape_wo_batchsize = self.perturbation_domain.get_keras_input_shape_wo_batchsize( + x_shape=perturbation_domain_input_shape_wo_batchsize + ) + w_shape = keras_input_shape_wo_batchsize + b_shape = keras_input_shape_wo_batchsize + return [w_shape, b_shape, w_shape, b_shape] + + +class BackwardInput(Layer): + """Layer preprocessing backward bounds to be used as input of the first backward layer of a decomon model. + + The backward bounds are supposed to be already flattened via `flatten_backward_bounds()`. + This layer ensure having 4* nb_model_outputs tensors in backward_bounds so that it is the concatenation of + backward bounds to be propagated for each keras model output. + + If this is already the case, this layer does nothing. Else there are 3 cases: + + - single tensor w: same bounds for each output, given by [w, 0, w, 0] + - 2 tensors w, b: same bounds for each output, lower==upper, given by [w, b, w, b] + - 4 tensors: same bounds for each output + + we concatenate the same bounds nb_model_outputs times. + + """ + + single_tensor = False + """A single tensor is given as entry.""" + lower_equal_upper = False + """Lower and upper bounds are equal.""" + same_bounds_per_output = False + """The same bounds are used for each keras model output.""" + + def __init__( + self, + model_output_shapes: list[tuple[int, ...]], + from_linear: list[bool], + **kwargs: Any, + ): + """ + Args: + model_output_shapes: shape of each output of the keras model (w/o batchsize) + from_linear: specify if each backward bound corresponding to each keras model output is from a linear model + (i.e. would be w/o batchsize and with lower == upper) + **kwargs: passed to Layer.__init__() + + """ + super().__init__(**kwargs) + + self.model_output_shapes = model_output_shapes + self.nb_model_outputs = len(model_output_shapes) + self.from_linear = from_linear + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "model_output_shapes": self.model_output_shapes, + "from_linear": self.from_linear, + } + ) + return config + + def build(self, input_shape: list[tuple[Optional[int], ...]]) -> None: + # list of tensors + if len(input_shape) == 1: + # single tensor + self.single_tensor = True + self.lower_equal_upper = True + self.same_bounds_per_output = True + elif len(input_shape) == 2: + # upper == lower, same for all model outputs + self.lower_equal_upper = True + self.same_bounds_per_output = True + elif len(input_shape) == 4: + # same bounds for all model outputs + self.same_bounds_per_output = True + elif len(input_shape) != 4 * self.nb_model_outputs: + raise ValueError("backward_bounds should be given as a list of 1, 2, 4, or 4*nb_model_outputs tensors.") + if self.same_bounds_per_output: + for model_output_shape in self.model_output_shapes[1:]: + if model_output_shape != self.model_output_shapes[0]: + raise ValueError( + "backward_bounds should be given for all model outputs " + "(i.e. as a list of 4*nb_model_outputs tensors) " + "if model outputs does not share the same shape" + ) + + self.built = True + + def call(self, inputs: BackendTensor) -> list[BackendTensor]: + """Reconstruct backward bounds at format needed by decomon conversion. + + Args: + inputs: the flattened backward bounds given to `clone` + + Returns: + backward_bounds as a list of 4 * nb_model_outputs tensors, + concatenation of backward bounds on each keras model output + + """ + if self.same_bounds_per_output: + if self.lower_equal_upper: + if self.single_tensor: + w = inputs[0] + model_output_shape = self.model_output_shapes[0] + from_linear = self.from_linear[0] + if from_linear: + w_shape_wo_batchsize = w.shape + else: + w_shape_wo_batchsize = w.shape[1:] + is_diag = w_shape_wo_batchsize == model_output_shape + if is_diag: + m2_output_shape = model_output_shape + else: + m2_output_shape = w_shape_wo_batchsize[len(model_output_shape) :] + b_shape_wo_batchsize = m2_output_shape + if from_linear: + b_shape = b_shape_wo_batchsize + else: + batchsize = w.shape[0] + b_shape = (batchsize,) + b_shape_wo_batchsize + b = K.zeros(b_shape) + else: + w, b = inputs + bounds_per_output = [w, b, w, b] + else: + bounds_per_output = inputs + return bounds_per_output * self.nb_model_outputs + else: + return inputs + + def compute_output_shape( + self, + input_shape: list[tuple[Optional[int], ...]], + ) -> list[tuple[Optional[int], ...]]: + if self.same_bounds_per_output: + if self.lower_equal_upper: + if self.single_tensor: + w_shape = input_shape[0] + model_output_shape = self.model_output_shapes[0] + from_linear = self.from_linear[0] + if from_linear: + w_shape_wo_batchsize = w_shape + else: + w_shape_wo_batchsize = w_shape[1:] + is_diag = w_shape_wo_batchsize == model_output_shape + m2_output_shape: tuple[Optional[int], ...] + if is_diag: + m2_output_shape = model_output_shape + else: + m2_output_shape = w_shape_wo_batchsize[len(model_output_shape) :] + b_shape_wo_batchsize = m2_output_shape + b_shape: tuple[Optional[int], ...] + if from_linear: + b_shape = b_shape_wo_batchsize + else: + batchsize = w_shape[0] + b_shape = (batchsize,) + b_shape_wo_batchsize + else: + w_shape, b_shape = input_shape + bounds_per_output_shape = [w_shape, b_shape, w_shape, b_shape] + else: + bounds_per_output_shape = input_shape + return bounds_per_output_shape * self.nb_model_outputs + else: + return input_shape + + +def flatten_backward_bounds( + backward_bounds: Union[keras.KerasTensor, list[keras.KerasTensor], list[list[keras.KerasTensor]]] +) -> list[keras.KerasTensor]: + """Flatten backward bounds given to `clone` + + Args: + backward_bounds: + + Returns: + backward_bounds_flattened, computed from backward_bounds as follows: + - single tensor -> [backward_bounds] + - list of tensors -> backward_bounds + - list of list of tensors -> flatten: [t for sublist in backward_bounds for t in sublist] + + """ + if isinstance(backward_bounds, keras.KerasTensor): + return [backward_bounds] + elif len(backward_bounds) == 0 or isinstance(backward_bounds[0], keras.KerasTensor): + return backward_bounds + else: + return [t for sublist in backward_bounds for t in sublist] + + +def has_no_backward_bounds( + backward_bounds: Optional[Union[keras.KerasTensor, list[keras.KerasTensor], list[list[keras.KerasTensor]]]], +) -> bool: + """Check whether some backward bounds are to be propagated or not. + + Args: + backward_bounds: + + Returns: + + """ + return backward_bounds is None or ( + not isinstance(backward_bounds, keras.KerasTensor) + and ( + len(backward_bounds) == 0 + or all( + not isinstance(backward_bounds_i, keras.KerasTensor) and len(backward_bounds_i) == 0 + for backward_bounds_i in backward_bounds + ) + ) + ) diff --git a/src/decomon/layers/inputs_outputs_specs.py b/src/decomon/layers/inputs_outputs_specs.py new file mode 100644 index 00000000..3c6cde63 --- /dev/null +++ b/src/decomon/layers/inputs_outputs_specs.py @@ -0,0 +1,605 @@ +from typing import Optional, Union, overload + +from decomon.constants import Propagation +from decomon.types import Tensor + + +class InputsOutputsSpec: + """Storing specifications for inputs and outputs of decomon/backward layer/model.""" + + layer_input_shape: Union[tuple[int, ...], list[tuple[int, ...]]] + model_input_shape: tuple[int, ...] + model_output_shape: tuple[int, ...] + + def __init__( + self, + ibp: bool = True, + affine: bool = True, + propagation: Propagation = Propagation.FORWARD, + layer_input_shape: Optional[Union[tuple[int, ...], list[tuple[int, ...]]]] = None, + model_input_shape: Optional[tuple[int, ...]] = None, + model_output_shape: Optional[tuple[int, ...]] = None, + is_merging_layer: bool = False, + linear: bool = False, + ): + """ + Args: + ibp: if True, forward propagate constant bounds + affine: if True, forward propagate affine bounds + propagation: direction of bounds propagation + - forward: from input to output + - backward: from output to input + layer_input_shape: shape of the underlying keras layer input (w/o the batch axis) + model_input_shape: shape of the underlying keras model input (w/o the batch axis) + model_output_shape: shape of the underlying keras model output (w/o the batch axis) + is_merging_layer: whether the underlying keras layer is a merging layer (i.e. with several inputs) + linear: whether the underlying keras layer is linear (thus do not need oracle bounds for instance) + + """ + # checks + if not ibp and not affine: + raise ValueError("ibp and affine cannot be both False.") + if propagation == Propagation.BACKWARD and model_output_shape is None: + raise ValueError("model_output_shape must be set in backward propagation.") + if propagation == Propagation.FORWARD or is_merging_layer: + if layer_input_shape is None: + raise ValueError("layer_input_shape must be set in forward propagation or for mergine layer.") + elif is_merging_layer: + if len(layer_input_shape) == 0 or not isinstance(layer_input_shape[0], tuple): + raise ValueError( + "layer_input_shape should be a non-empty list of shapes (tuple of int) for a merging layer." + ) + elif not isinstance(layer_input_shape, tuple) or ( + len(layer_input_shape) > 0 and not isinstance(layer_input_shape[0], int) + ): + raise ValueError("layer_input_shape should be a tuple of int for a unary layer.") + + self.propagation = propagation + self.affine = affine + self.ibp = ibp + self.is_merging_layer = is_merging_layer + self.linear = linear + + if model_output_shape is None: + self.model_output_shape = tuple() + else: + self.model_output_shape = model_output_shape + if model_input_shape is None: + self.model_input_shape = tuple() + else: + self.model_input_shape = model_input_shape + if layer_input_shape is None: + if self.is_merging_layer: + self.layer_input_shape = [tuple()] + else: + self.layer_input_shape = tuple() + else: + self.layer_input_shape = layer_input_shape + + def needs_perturbation_domain_inputs(self) -> bool: + """Specify if decomon inputs should integrate keras model inputs.""" + return self.propagation == Propagation.FORWARD and self.affine + + def needs_oracle_bounds(self) -> bool: + """Specify if decomon layer needs oracle bounds on keras layer inputs.""" + return not self.linear and (self.propagation == Propagation.BACKWARD or self.affine) + + def needs_constant_bounds_inputs(self) -> bool: + """Specify if decomon inputs should integrate constant bounds.""" + return (self.propagation == Propagation.FORWARD and self.ibp) or ( + self.propagation == Propagation.BACKWARD and self.needs_oracle_bounds() + ) + + def needs_affine_bounds_inputs(self) -> bool: + """Specify if decomon inputs should integrate affine bounds.""" + return (self.propagation == Propagation.FORWARD and self.affine) or (self.propagation == Propagation.BACKWARD) + + def cannot_have_empty_affine_inputs(self) -> bool: + """Specify that it is not allowed to have empty affine bounds. + + Indeed, in merging case + forward propagation, it would be impossible to split decomon inputs properly. + + """ + return self.is_merging_layer and self.propagation == Propagation.FORWARD and self.affine + + @property + def nb_keras_inputs(self) -> int: + if self.is_merging_layer: + return len(self.layer_input_shape) + else: + return 1 + + @property + def nb_input_tensors(self) -> int: + nb = 0 + if self.propagation == Propagation.BACKWARD: + # oracle bounds + if self.needs_oracle_bounds(): + nb += 2 * self.nb_keras_inputs + # affine + nb += 4 + # model inputs + if self.needs_perturbation_domain_inputs(): + nb += 1 + else: # forward + # ibp + if self.ibp: + nb += 2 * self.nb_keras_inputs + # affine + if self.affine: + nb += 4 * self.nb_keras_inputs + # model inputs + if self.needs_perturbation_domain_inputs(): + nb += 1 + return nb + + @property + def nb_output_tensors(self) -> int: + nb = 0 + if self.propagation == Propagation.BACKWARD: + nb += 4 * self.nb_keras_inputs + else: # forward + if self.ibp: + nb += 2 + if self.affine: + nb += 4 + return nb + + @overload + def split_constant_bounds(self, constant_bounds: list[Tensor]) -> tuple[Tensor, Tensor]: + """Split constant bounds, non-merging layer version.""" + ... + + @overload + def split_constant_bounds(self, constant_bounds: list[list[Tensor]]) -> tuple[list[Tensor], list[Tensor]]: + """Split constant bounds, merging layer version.""" + ... + + def split_constant_bounds( + self, constant_bounds: Union[list[Tensor], list[list[Tensor]]] + ) -> Union[tuple[Tensor, Tensor], tuple[list[Tensor], list[Tensor]]]: + """Split constant bounds into lower, upper bound. + + Args: + constant_bounds: + if merging layer: list of constant (lower and upper) bounds for each keras layer inputs; + else: list containing lower and upper bounds for the keras layer input. + + Returns: + if merging_layer: 2 lists containing lower and upper bounds for each keras layer inputs; + else: 2 tensors being the lower and upper bounds for the keras layer input. + + """ + if self.is_merging_layer: + lowers, uppers = zip(*constant_bounds) + return list(lowers), list(uppers) + else: + lower, upper = constant_bounds + return lower, upper + + def split_inputs( + self, inputs: list[Tensor] + ) -> Union[ + tuple[list[Tensor], list[Tensor], list[Tensor]], + tuple[list[list[Tensor]], list[list[Tensor]], list[Tensor]], + tuple[list[Tensor], list[list[Tensor]], list[Tensor]], + ]: + """Split decomon inputs. + + Split them according to propagation mode and whether the underlying keras layer is merging or not. + + Args: + inputs: flattened decomon inputs, as seen by `DecomonLayer.call()`. + + Returns: + affine_bounds_to_propagate, constant_oracle_bounds, perturbation_domain_inputs: + each one can be empty if not relevant, + moreover, according to propagation mode and merging status, + it will be list of tensors or list of lists of tensors. + + More details: + + - non-merging case: + inputs = affine_bounds_to_propagate + constant_oracle_bounds + perturbation_domain_inputs + + - merging case: + - forward: k affine bounds to propagate w.r.t. each keras layer input + k constant bounds + + inputs = ( + affine_bounds_to_propagate_0 + constant_oracle_bounds_0 + ... + + affine_bounds_to_propagate_k + constant_oracle_bounds_k + + perturbation_domain_inputs + ) + + - backward: only 1 affine bounds to propagate w.r.t keras layer output + + k constant bounds w.r.t each keras layer input (empty if layer not linear) + + inputs = ( + affine_bounds_to_propagate + + constant_oracle_bounds_0 + ... + constant_oracle_bounds_k + + perturbation_domain_inputs + ) + Note: in case of merging layer + forward, we should not have empty affine bounds + as it will be impossible to split properly the inputs. + + """ + # Remove keras model input + if self.needs_perturbation_domain_inputs(): + x = inputs[-1] + inputs = inputs[:-1] + perturbation_domain_inputs = [x] + else: + perturbation_domain_inputs = [] + if self.is_merging_layer: + if self.propagation == Propagation.BACKWARD: + # expected number of constant bounds + nb_constant_bounds_by_keras_input = 2 if self.needs_oracle_bounds() else 0 + nb_constant_bounds = self.nb_keras_inputs * nb_constant_bounds_by_keras_input + # remove affine bounds (could be empty to express identity bounds) + affine_bounds_to_propagate = inputs[: len(inputs) - nb_constant_bounds] + inputs = inputs[len(inputs) - nb_constant_bounds :] + # split constant bounds by keras input + if nb_constant_bounds > 0: + constant_oracle_bounds = [ + [inputs[i], inputs[i + 1]] for i in range(0, len(inputs), nb_constant_bounds_by_keras_input) + ] + else: + constant_oracle_bounds = [] + else: # forward + # split bounds by keras input + nb_affine_bounds_by_keras_input = 4 if self.affine else 0 + nb_constant_bounds_by_keras_input = 2 if self.ibp else 0 + nb_bounds_by_keras_input = nb_affine_bounds_by_keras_input + nb_constant_bounds_by_keras_input + affine_bounds_to_propagate = [ + [inputs[start_input + j_bound] for j_bound in range(nb_affine_bounds_by_keras_input)] + for start_input in range(0, len(inputs), nb_bounds_by_keras_input) + ] + constant_oracle_bounds = [ + [ + inputs[start_input + nb_affine_bounds_by_keras_input + j_bound] + for j_bound in range(nb_constant_bounds_by_keras_input) + ] + for start_input in range(0, len(inputs), nb_bounds_by_keras_input) + ] + else: + # Remove constant bounds + if self.needs_constant_bounds_inputs(): + constant_oracle_bounds = inputs[-2:] + inputs = inputs[:-2] + else: + constant_oracle_bounds = [] + # The remaining tensors are affine bounds + # (potentially empty if: not backward or not affine or identity affine bounds) + affine_bounds_to_propagate = inputs + + return affine_bounds_to_propagate, constant_oracle_bounds, perturbation_domain_inputs + + def split_input_shape( + self, input_shape: list[tuple[Optional[int], ...]] + ) -> Union[ + tuple[list[tuple[Optional[int], ...]], list[tuple[Optional[int], ...]], list[tuple[Optional[int], ...]]], + tuple[ + list[list[tuple[Optional[int], ...]]], + list[list[tuple[Optional[int], ...]]], + list[tuple[Optional[int], ...]], + ], + tuple[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]], list[tuple[Optional[int], ...]]], + ]: + """Split decomon inputs. + + Split them according to propagation mode and whether the underlying keras layer is merging or not. + + Args: + input_shape: flattened decomon inputs, as seen by `DecomonLayer.call()`. + + Returns: + affine_bounds_to_propagate_shape, constant_oracle_bounds_shape, perturbation_domain_inputs_shape: + each one can be empty if not relevant, and according to propagation mode and merging status, + it will be list of shapes or list of lists of shapes. + + """ + return self.split_inputs(inputs=input_shape) # type: ignore + + def flatten_inputs( + self, + affine_bounds_to_propagate: Union[list[Tensor], list[list[Tensor]]], + constant_oracle_bounds: Union[list[Tensor], list[list[Tensor]]], + perturbation_domain_inputs: list[Tensor], + ) -> list[Tensor]: + """Flatten decomon inputs. + + Reverse `self.split_inputs()`. + + Args: + affine_bounds_to_propagate: + - forward + affine: affine bounds on each keras layer input w.r.t. model input + -> list of lists of tensors in merging case; + -> list of tensors else. + - backward: affine bounds on model output w.r.t keras layer output + -> list of tensors + - else: empty + constant_oracle_bounds: + - forward + ibp: ibp bounds on keras layer inputs + - backward + not linear: oracle bounds on keras layer inputs + - else: empty + perturbation_domain_inputs: + - forward + affine: perturbation domain input wrapped in a list + - else: empty + + Returns: + flattened inputs + - non-merging case: + inputs = affine_bounds_to_propagate + constant_oracle_bounds + perturbation_domain_inputs + + - merging case: + - forward: k affine bounds to propagate w.r.t. each keras layer input + k constant bounds + + inputs = ( + affine_bounds_to_propagate_0 + constant_oracle_bounds_0 + ... + + affine_bounds_to_propagate_k + constant_oracle_bounds_k + + perturbation_domain_inputs + ) + + - backward: only 1 affine bounds to propagate w.r.t keras layer output + + k constant bounds w.r.t each keras layer input (empty of linear layer) + + inputs = ( + affine_bounds_to_propagate + + constant_oracle_bounds_0 + ... + constant_oracle_bounds_k + + perturbation_domain_inputs + ) + + """ + if self.is_merging_layer: + if self.propagation == Propagation.BACKWARD: + if self.needs_oracle_bounds(): + flattened_constant_oracle_bounds = [ + t for constant_oracle_bounds_i in constant_oracle_bounds for t in constant_oracle_bounds_i + ] + else: + flattened_constant_oracle_bounds = [] + return affine_bounds_to_propagate + flattened_constant_oracle_bounds + perturbation_domain_inputs + else: # forward + bounds_by_keras_input = [ + affine_bounds_to_propagate_i + constant_oracle_bounds_i + for affine_bounds_to_propagate_i, constant_oracle_bounds_i in zip( + affine_bounds_to_propagate, constant_oracle_bounds + ) + ] + flattened_bounds_by_keras_input = [ + t for bounds_by_keras_input_i in bounds_by_keras_input for t in bounds_by_keras_input_i + ] + return flattened_bounds_by_keras_input + perturbation_domain_inputs + else: + return affine_bounds_to_propagate + constant_oracle_bounds + perturbation_domain_inputs + + def flatten_inputs_shape( + self, + affine_bounds_to_propagate_shape: Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]], + constant_oracle_bounds_shape: Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]], + perturbation_domain_inputs_shape: list[tuple[Optional[int], ...]], + ) -> list[tuple[Optional[int], ...]]: + """Flatten inputs shape + + Same operation as `flatten_inputs` but on tensor shapes. + + Args: + affine_bounds_to_propagate_shape: + constant_oracle_bounds_shape: + perturbation_domain_inputs_shape: + + Returns: + + """ + return self.flatten_inputs( # type: ignore + affine_bounds_to_propagate=affine_bounds_to_propagate_shape, + constant_oracle_bounds=constant_oracle_bounds_shape, + perturbation_domain_inputs=perturbation_domain_inputs_shape, + ) # type: ignore + + def split_outputs(self, outputs: list[Tensor]) -> tuple[Union[list[Tensor], list[list[Tensor]]], list[Tensor]]: + """Split decomon inputs. + + Reverse operation of `self.flatten_outputs()` + + Args: + outputs: flattened decomon outputs, as returned by `DecomonLayer.call()`. + + Returns: + affine_bounds_propagated, constant_bounds_propagated: + each one can be empty if not relevant and can be list of tensors or a list of lists of tensors + according to propagation and merging status. + + More details: + + - forward: affine_bounds_propagated, constant_bounds_propagated: both simple lists of tensors corresponding to + affine and constant bounds on keras layer output. + - backward: constant_bounds_propagated is empty (not relevant) and + - merging layer: affine_bounds_propagated is a list of lists of tensors corresponding + to partial affine bounds on model output w.r.t each keras input + - else: affine_bounds_propagated is a simple list of tensors + + """ + # Remove constant bounds + if self.propagation == Propagation.FORWARD and self.ibp: + constant_bounds_propagated = outputs[-2:] + outputs = outputs[:-2] + else: + constant_bounds_propagated = [] + # It remains affine bounds (can be empty if forward + not affine, or identity layer (e.g. DecomonLinear) on identity bounds + affine_bounds_propagated = outputs + if self.propagation == Propagation.BACKWARD and self.is_merging_layer: + nb_affine_bounds_by_keras_input = 4 + affine_bounds_propagated = [ + affine_bounds_propagated[i : i + nb_affine_bounds_by_keras_input] + for i in range(0, len(affine_bounds_propagated), nb_affine_bounds_by_keras_input) + ] + + return affine_bounds_propagated, constant_bounds_propagated + + def split_output_shape( + self, output_shape: list[tuple[Optional[int], ...]] + ) -> tuple[ + Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]], list[tuple[Optional[int], ...]] + ]: + """Split decomon output shape.""" + return self.split_outputs(outputs=output_shape) # type: ignore + + def flatten_outputs( + self, + affine_bounds_propagated: Union[list[Tensor], list[list[Tensor]]], + constant_bounds_propagated: Optional[list[Tensor]] = None, + ) -> list[Tensor]: + """Flatten decomon outputs. + + Args: + affine_bounds_propagated: + - forward + affine: affine bounds on keras layer output w.r.t. model input + - backward: affine bounds on model output w.r.t each keras layer input + -> list of lists of tensors in merging case; + -> list of tensors else. + - else: empty + constant_bounds_propagated: + - forward + ibp: ibp bounds on keras layer output + - else: empty or None + + Returns: + flattened outputs + - forward: affine_bounds_propagated + constant_bounds_propagated + - backward: + - merging layer (k keras layer inputs): affine_bounds_propagated_0 + ... + affine_bounds_propagated_k + - else: affine_bounds_propagated + + """ + if constant_bounds_propagated is None or self.propagation == Propagation.BACKWARD: + if self.is_merging_layer and self.propagation == Propagation.BACKWARD: + return [ + t for affine_bounds_propagated_i in affine_bounds_propagated for t in affine_bounds_propagated_i + ] + else: + return affine_bounds_propagated + else: + return affine_bounds_propagated + constant_bounds_propagated + + def flatten_outputs_shape( + self, + affine_bounds_propagated_shape: Union[ + list[tuple[Optional[int], ...]], + list[list[tuple[Optional[int], ...]]], + ], + constant_bounds_propagated_shape: Optional[list[tuple[Optional[int], ...]]] = None, + ) -> list[tuple[Optional[int], ...]]: + """Flatten decomon outputs shape.""" + return self.flatten_outputs(affine_bounds_propagated=affine_bounds_propagated_shape, constant_bounds_propagated=constant_bounds_propagated_shape) # type: ignore + + def has_multiple_bounds_inputs(self) -> bool: + return self.propagation == Propagation.FORWARD and self.is_merging_layer + + @overload + def extract_shapes_from_affine_bounds( # type:ignore + self, affine_bounds: list[Tensor], i: int = -1 + ) -> list[tuple[Optional[int], ...]]: + ... + + @overload + def extract_shapes_from_affine_bounds( + self, affine_bounds: list[list[Tensor]], i: int = -1 + ) -> list[list[tuple[Optional[int], ...]]]: + ... + + def extract_shapes_from_affine_bounds( + self, affine_bounds: Union[list[Tensor], list[list[Tensor]]], i: int = -1 + ) -> Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]]: + if self.has_multiple_bounds_inputs() and i == -1: + return [[t.shape for t in sub_bounds] for sub_bounds in affine_bounds] + else: + return [t.shape for t in affine_bounds] # type: ignore + + def is_identity_bounds(self, affine_bounds: Union[list[Tensor], list[list[Tensor]]], i: int = -1) -> bool: + return self.is_identity_bounds_shape( + affine_bounds_shape=self.extract_shapes_from_affine_bounds(affine_bounds=affine_bounds, i=i), i=i + ) + + def is_identity_bounds_shape( + self, + affine_bounds_shape: Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]], + i: int = -1, + ) -> bool: + if self.has_multiple_bounds_inputs() and i == -1: + return all( + self.is_identity_bounds_shape(affine_bounds_shape=affine_bounds_shape[i], i=i) # type: ignore + for i in range(self.nb_keras_inputs) + ) + else: + return len(affine_bounds_shape) == 0 + + def is_diagonal_bounds(self, affine_bounds: Union[list[Tensor], list[list[Tensor]]], i: int = -1) -> bool: + return self.is_diagonal_bounds_shape( + affine_bounds_shape=self.extract_shapes_from_affine_bounds(affine_bounds=affine_bounds, i=i), i=i + ) + + def is_diagonal_bounds_shape( + self, + affine_bounds_shape: Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]], + i: int = -1, + ) -> bool: + if self.has_multiple_bounds_inputs() and i == -1: + return all( + self.is_diagonal_bounds_shape(affine_bounds_shape=affine_bounds_shape[i], i=i) # type: ignore + for i in range(self.nb_keras_inputs) + ) + else: + if self.is_identity_bounds_shape(affine_bounds_shape, i=i): + return True + w_shape, b_shape = affine_bounds_shape[:2] + return w_shape == b_shape + + def is_wo_batch_bounds(self, affine_bounds: Union[list[Tensor], list[list[Tensor]]], i: int = -1) -> bool: + return self.is_wo_batch_bounds_shape( + affine_bounds_shape=self.extract_shapes_from_affine_bounds(affine_bounds=affine_bounds, i=i), i=i + ) + + def is_wo_batch_bounds_shape( + self, + affine_bounds_shape: Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]], + i: int = -1, + ) -> bool: + if self.has_multiple_bounds_inputs() and i == -1: + return all( + self.is_wo_batch_bounds_shape(affine_bounds_shape=affine_bounds_shape[i], i=i) # type: ignore + for i in range(self.nb_keras_inputs) + ) + else: + if self.is_identity_bounds_shape(affine_bounds_shape, i=i): + return True + b_shape = affine_bounds_shape[1] + if self.propagation == Propagation.FORWARD: + if i > -1: + return len(b_shape) == len(self.layer_input_shape[i]) # type: ignore + else: + return len(b_shape) == len(self.layer_input_shape) + else: + return len(b_shape) == len(self.model_output_shape) + + @overload + def is_wo_batch_bounds_by_keras_input( # type: ignore + self, + affine_bounds: list[Tensor], + ) -> bool: + ... + + @overload + def is_wo_batch_bounds_by_keras_input( + self, + affine_bounds: list[list[Tensor]], + ) -> list[bool]: + ... + + def is_wo_batch_bounds_by_keras_input( + self, + affine_bounds: Union[list[Tensor], list[list[Tensor]]], + ) -> Union[bool, list[bool]]: + if self.has_multiple_bounds_inputs(): + return [self.is_wo_batch_bounds(affine_bounds_i, i=i) for i, affine_bounds_i in enumerate(affine_bounds)] + else: + return self.is_wo_batch_bounds(affine_bounds) diff --git a/src/decomon/layers/layer.py b/src/decomon/layers/layer.py new file mode 100644 index 00000000..cbfcfc9b --- /dev/null +++ b/src/decomon/layers/layer.py @@ -0,0 +1,682 @@ +from inspect import Parameter, signature +from typing import Any, Optional, Union + +import keras +import keras.ops as K +from keras.layers import Layer, Wrapper +from keras.utils import serialize_keras_object + +from decomon.constants import Propagation +from decomon.layers.fuse import ( + combine_affine_bound_with_constant_bound, + combine_affine_bounds, +) +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.layers.oracle import get_forward_oracle +from decomon.perturbation_domain import BoxDomain, PerturbationDomain +from decomon.types import Tensor + +_keras_base_layer_keyword_parameters = [ + name for name, param in signature(Layer.__init__).parameters.items() if param.kind == Parameter.KEYWORD_ONLY +] + ["input_shape", "input_dim"] + + +class DecomonLayer(Wrapper): + """Base class for decomon layers. + + To enable LiRPA on a custom layer, one should implement a corresponding decomon layer by: + - deriving from DecomonLayer + - override/implement at least: + - linear case: + - set class attribute `linear` to True + - `get_affine_representation()` + - generic case: + - `get_affine_bounds()`: affine bounds on layer output w.r.t. layer input + - `forward_ibp_propagate()`: ibp bounds on layer ouput knowing ibp bounds on layer inpu + - set class attribute `diagonal` to True if the affine relaxation is representing w as a diagonal + (see explanation for `diagonal`). + + Other possibilities exist like overriding directly + - `forward_affine_propagate()` + - `backward_affine_propagate()` + - `forward_ibp_propagate()` (still needed) + + """ + + linear: bool = False + """Flag telling that the layer is linear. + + Set it to True in child classes to explicit that the corresponding keras layer is linear. + Else will be considered as non-linear. + + When linear is set to True, some computations can be simplified, + even though equivalent with the ones made if linear is set to False. + + """ + + diagonal: bool = False + """Flag telling that the layer affine relaxation can be represented in a diagonal manner. + + This is useful only to compute properly output shapes without making actual computations, during call on symbolic tensors. + During a call on actual tensors, this is not used. + + If diagonal is set to True, this means that if the affine relaxation of the layer is w_l, b_l, w_u, b_u then + weights and bias will all have the same shape, and that the full representation can be retrieved + + - either in linear case with no batch axis by + w_full = K.reshape(K.diag(K.flatten(w))) + - or in generic case with a batch axis by the same computation, batch element by batch element, i.e. + w_full = K.concatenate([K.reshape(K.diag(K.flatten(w[i])), w.shape + w.shape)[None] for i in range(len(w))], axis=0) + + In this case, the computations when merging affine bounds can be simplified. + + """ + + _is_merging_layer: bool = False # set to True in child class DecomonMerge + + def __init__( + self, + layer: Layer, + perturbation_domain: Optional[PerturbationDomain] = None, + ibp: bool = True, + affine: bool = True, + propagation: Propagation = Propagation.FORWARD, + model_input_shape: Optional[tuple[int, ...]] = None, + model_output_shape: Optional[tuple[int, ...]] = None, + **kwargs: Any, + ): + """ + Args: + layer: underlying keras layer + perturbation_domain: default to a box domain + ibp: if True, forward propagate constant bounds + affine: if True, forward propagate affine bounds + propagation: direction of bounds propagation + - forward: from input to output + - backward: from output to input + model_output_shape: shape of the underlying model output (omitting batch axis). + It allows determining if the backward bounds are with a batch axis or not. + model_input_shape: shape of the underlying keras model input (omitting batch axis). + **kwargs: + + """ + # Layer init: + # all options not expected by Layer are removed here + # (`to_decomon` pass options potentially not used by all decomon layers) + keys_to_pop = [k for k in kwargs if k not in _keras_base_layer_keyword_parameters] + for k in keys_to_pop: + kwargs.pop(k) + super().__init__(layer=layer, **kwargs) + + # default args + if perturbation_domain is None: + perturbation_domain = BoxDomain() + + # checks + if not layer.built: + raise ValueError(f"The underlying keras layer {layer.name} is not built.") + if not ibp and not affine: + raise ValueError("ibp and affine cannot be both False.") + + # attributes + self.ibp = ibp + self.affine = affine + self.perturbation_domain = perturbation_domain + self.propagation = propagation + + # input-output-manager + self.inputs_outputs_spec = self.create_inputs_outputs_spec( + layer=layer, + ibp=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + ) + + def create_inputs_outputs_spec( + self, + layer: Layer, + ibp: bool, + affine: bool, + propagation: Propagation, + model_input_shape: Optional[tuple[int, ...]], + model_output_shape: Optional[tuple[int, ...]], + ) -> InputsOutputsSpec: + if self._is_merging_layer: + if isinstance(layer.input, keras.KerasTensor): + # special case: merging a single input -> self.layer.input is already flattened + layer_input_shape = [layer.input.shape[1:]] + else: + layer_input_shape = [t.shape[1:] for t in layer.input] + else: + layer_input_shape = layer.input.shape[1:] + return InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + is_merging_layer=self._is_merging_layer, + linear=self.linear, + ) + + @property + def is_merging_layer(self) -> bool: + """Flag telling if the underlying keras layer is a merging layer or not (i.e. ~ with several inputs).""" + return self._is_merging_layer + + @property + def layer_input_shape(self) -> tuple[int, ...]: + return self.inputs_outputs_spec.layer_input_shape # type: ignore + + @property + def model_input_shape(self) -> tuple[int, ...]: + return self.inputs_outputs_spec.model_input_shape + + @property + def model_output_shape(self) -> tuple[int, ...]: + return self.inputs_outputs_spec.model_output_shape + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "ibp": self.ibp, + "affine": self.affine, + "perturbation_domain": serialize_keras_object(self.perturbation_domain), + "propagation": self.propagation, + "model_input_shape": self.model_input_shape, + "model_output_shape": self.model_output_shape, + } + ) + return config + + def get_affine_representation(self) -> tuple[Tensor, Tensor]: + """Get affine representation of the layer + + This computes the affine representation of the layer, when this is meaningful, + i.e. `self.linear` is True. + + If implemented, it will be used for backward and forward propagation of affine bounds through the layer. + For non-linear layers, one should implement `get_affine_bounds()` instead. + + Args: + + Returns: + w, b: affine representation of the layer satisfying + + layer(z) = w * z + b + + More precisely, we have + ``` + layer(z) = batch_multid_dot(z, w, missing_batchsize=(False, True)) + b + ``` + + If w can be represented as a diagonal tensor, which means that the full version of w is retrieved by + w_full = K.reshape(K.diag(K.flatten(w)), w.shape + w.shape) + then + - class attribute `diagonal` should be set to True (in order to have a correct `compute_output_shape()`) + - we got + ``` + layer(z) = batch_multid_dot(z, w, missing_batchsize=(False, True), diagonal=(False, True)) + b + ``` + + Shapes: !no batchsize! + if diagonal is False: + w ~ self.layer.input.shape[1:] + self.layer.output.shape[1:] + b ~ self.layer.output.shape[1:] + if diagonal is True: + w ~ self.layer.output.shape[1:] + b ~ self.layer.output.shape[1:] + + + """ + if not self.linear: + raise RuntimeError("You should not call `get_affine_representation()` when `self.linear` is False.") + else: + raise NotImplementedError( + "`get_affine_representation()` needs to be implemented to get the forward and backward propagation of affine bounds. " + "Alternatively, you can also directly override " + "`forward_ibp_propagate()`, `forward_affine_propagate()` and `backward_affine_propagate()`." + ) + + def get_affine_bounds(self, lower: Tensor, upper: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Get affine bounds on layer outputs from layer inputs + + This compute the affine relaxation of the layer, given the oracle constant bounds on the inputs. + + If implemented, it will be used for backward and forward propagation of affine bounds through the layer. + For linear layers, one can implement `get_affine_representation()` instead. + + Args: + lower: lower constant oracle bound on the layer input. + upper: upper constant oracle bound on the layer input. + + Returns: + w_l, b_l, w_u, b_u: affine relaxation of the layer satisfying + + w_l * z + b_l <= layer(z) <= w_u * z + b_u + with lower <= z <= upper + + If w(_l or_u) can be represented as a diagonal tensor, which means that the full version of w is retrieved by + w_full = K.concatenate([K.reshape(K.diag(K.flatten(w[i])), w.shape + w.shape)[None] for i in range(len(w))], axis=0) + then the class attribute `diagonal` should be set to True (in order to have a correct `compute_output_shape()`) + + Shapes: + if diagonal is False: + lower, upper ~ (batchsize,) + self.layer.input.shape[1:] + w_l, w_u ~ (batchsize,) + self.layer.input.shape[1:] + self.layer.output.shape[1:] + b_l, b_u ~ (batchsize,) + self.layer.output.shape[1:] + if diagonal is True: + lower, upper ~ (batchsize,) + self.layer.input.shape[1:] + w_l, w_u ~ (batchsize,) + self.layer.output.shape[1:] + b_l, b_u ~ (batchsize,) + self.layer.output.shape[1:] + + Note: + `w * z` means here `batch_multid_dot(z, w)`. + + """ + if self.linear: + w, b = self.get_affine_representation() + batchsize = lower.shape[0] + w_with_batchsize = K.repeat(w[None], batchsize, axis=0) + b_with_batchsize = K.repeat(b[None], batchsize, axis=0) + return w_with_batchsize, b_with_batchsize, w_with_batchsize, b_with_batchsize + else: + raise NotImplementedError( + "`get_affine_bounds()` needs to be implemented to get the forward and backward propagation of affine bounds. " + "Alternatively, you can also directly override `forward_affine_propagate()` and `backward_affine_propagate()`" + ) + + def forward_ibp_propagate(self, lower: Tensor, upper: Tensor) -> tuple[Tensor, Tensor]: + """Propagate ibp bounds through the layer. + + If the underlying keras layer is linear, it will be deduced from its affine representation. + Else, this needs to be implemented to forward propagate ibp (constant) bounds. + + Args: + lower: lower constant oracle bound on the keras layer input. + upper: upper constant oracle bound on the keras layer input. + + Returns: + l_c, u_c: constant relaxation of the layer satisfying + l_c <= layer(z) <= u_c + with lower <= z <= upper + + Shapes: + lower, upper ~ (batchsize,) + self.layer.input.shape[1:] + l_c, u_c ~ (batchsize,) + self.layer.output.shape[1:] + + """ + if self.linear: + w, b = self.get_affine_representation() + affine_bounds = [w, b, w, b] + return combine_affine_bound_with_constant_bound( + lower=lower, upper=upper, affine_bounds=affine_bounds, missing_batchsize=self.linear + ) + else: + raise NotImplementedError( + "`forward_ibp_propagate()` needs to be implemented to get the forward propagation of constant bounds." + ) + + def forward_affine_propagate( + self, input_affine_bounds: list[Tensor], input_constant_bounds: list[Tensor] + ) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Propagate model affine bounds in forward direction. + + By default, this is deduced from `get_affine_bounds()` (or `get_affine_representation()` if `self.linear is True). + But this could be overridden for better performance. See `DecomonConv2D` for an example. + + Args: + input_affine_bounds: [w_l_in, b_l_in, w_u_in, b_u_in] + affine bounds on underlying keras layer input w.r.t. model input + input_constant_bounds: [l_c_in, u_c_in] + constant oracle bounds on underlying keras layer input (already deduced from affine ones if necessary) + + Returns: + w_l, b_l, w_u, b_u: affine bounds on underlying keras layer *output* w.r.t. model input + + If we denote by + - x: keras model input + - z: underlying keras layer input + - h(x) = layer(z): output of the underlying keras layer + + The following inequations are satisfied + w_l * x + b_l <= h(x) <= w_u * x + b_u + l_c_in <= z <= u_c_in + w_l_in * x + b_l_in <= z <= w_u_in * x + b_u_in + + """ + if self.linear: + w, b = self.get_affine_representation() + layer_affine_bounds = [w, b, w, b] + else: + lower, upper = self.inputs_outputs_spec.split_constant_bounds(constant_bounds=input_constant_bounds) + w_l, b_l, w_u, b_u = self.get_affine_bounds(lower=lower, upper=upper) + layer_affine_bounds = [w_l, b_l, w_u, b_u] + + from_linear_layer = (self.inputs_outputs_spec.is_wo_batch_bounds(input_affine_bounds), self.linear) + diagonal = ( + self.inputs_outputs_spec.is_diagonal_bounds(input_affine_bounds), + self.inputs_outputs_spec.is_diagonal_bounds(layer_affine_bounds), + ) + return combine_affine_bounds( + affine_bounds_1=input_affine_bounds, + affine_bounds_2=layer_affine_bounds, + from_linear_layer=from_linear_layer, + diagonal=diagonal, + ) + + def backward_affine_propagate( + self, output_affine_bounds: list[Tensor], input_constant_bounds: list[Tensor] + ) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Propagate model affine bounds in backward direction. + + By default, this is deduced from `get_affine_bounds()` (or `get_affine_representation()` if `self.linear is True). + But this could be overridden for better performance. See `DecomonConv2D` for an example. + + Args: + output_affine_bounds: [w_l, b_l, w_u, b_u] + partial affine bounds on model output w.r.t underlying keras layer output + input_constant_bounds: [l_c_in, u_c_in] + constant oracle bounds on underlying keras layer input + + Returns: + w_l_new, b_l_new, w_u_new, b_u_new: partial affine bounds on model output w.r.t. underlying keras layer *input* + + If we denote by + - x: keras model input + - m(x): keras model output + - z: underlying keras layer input + - h(x) = layer(z): output of the underlying keras layer + - h_i(x) output of the i-th layer + - w_l_i, b_l_i, w_u_i, b_u_i: current partial linear bounds on model output w.r.t to h_i(x) + + The following inequations are satisfied + + l_c_in <= z <= u_c_in + + Sum_{others layers i}(w_l_i * h_i(x) + b_l_i) + w_l * h(x) + b_l + <= m(x) + <= Sum_{others layers i}(w_u_i * h_i(x) + b_u_i) + w_u * h(x) + b_u + + Sum_{others layers i}(w_l_i * h_i(x) + b_l_i) + w_l_new * z + b_l_new + <= m(x) + <= Sum_{others layers i}(w_u_i * h_i(x) + b_u_i) + w_u_new * z + b_u_new + + """ + if self.linear: + w, b = self.get_affine_representation() + layer_affine_bounds = [w, b, w, b] + else: + lower, upper = self.inputs_outputs_spec.split_constant_bounds(constant_bounds=input_constant_bounds) + w_l, b_l, w_u, b_u = self.get_affine_bounds(lower=lower, upper=upper) + layer_affine_bounds = [w_l, b_l, w_u, b_u] + + from_linear_layer = (self.linear, self.inputs_outputs_spec.is_wo_batch_bounds((output_affine_bounds))) + diagonal = ( + self.inputs_outputs_spec.is_diagonal_bounds(layer_affine_bounds), + self.inputs_outputs_spec.is_diagonal_bounds(output_affine_bounds), + ) + return combine_affine_bounds( + affine_bounds_1=layer_affine_bounds, + affine_bounds_2=output_affine_bounds, + from_linear_layer=from_linear_layer, + diagonal=diagonal, + ) + + def get_forward_oracle( + self, + input_affine_bounds: list[Tensor], + input_constant_bounds: list[Tensor], + perturbation_domain_inputs: list[Tensor], + ) -> list[Tensor]: + """Get constant oracle bounds on underlying keras layer input from forward input bounds. + + Args: + input_affine_bounds: affine bounds on keras layer input w.r.t model input . Can be empty if not in affine mode. + input_constant_bounds: ibp constant bounds on keras layer input. Can be empty if not in ibp mode. + perturbation_domain_inputs: perturbation domain input, wrapped in a list. Necessary only in affine mode, else empty. + + Returns: + constant bounds on keras layer input deduced from forward input bounds + + `input_affine_bounds, input_constant_bounds` are the forward bounds to be propagate through the layer. + `input_affine_bounds` (resp. `input_constant_bounds`) will be empty if `self.affine` (resp. `self.ibp) is False. + + In hybrid case (ibp+affine), the constant bounds are assumed to be already tight, which means the previous + forward layer should already have took the tighter constant bounds between the ibp ones and the ones deduced + from the affine bounds given the considered perturbation domain. + + """ + from_linear = self.inputs_outputs_spec.is_wo_batch_bounds_by_keras_input(input_affine_bounds) + return get_forward_oracle( + affine_bounds=input_affine_bounds, + ibp_bounds=input_constant_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + perturbation_domain=self.perturbation_domain, + ibp=self.ibp, + affine=self.affine, + is_merging_layer=self.is_merging_layer, + from_linear=from_linear, + ) + + def call_forward( + self, + affine_bounds_to_propagate: list[Tensor], + input_bounds_to_propagate: list[Tensor], + perturbation_domain_inputs: list[Tensor], + ) -> tuple[list[Tensor], list[Tensor]]: + """Propagate forward affine and constant bounds through the layer. + + Args: + affine_bounds_to_propagate: affine bounds on keras layer input w.r.t model input. + Can be empty if not in affine mode. + Can also be empty in case of identity affine bounds => we simply return layer affine bounds. + input_bounds_to_propagate: ibp constant bounds on keras layer input. Can be empty if not in ibp mode. + perturbation_domain_inputs: perturbation domain input, wrapped in a list. Necessary only in affine mode, else empty. + + Returns: + output_affine_bounds, output_constant_bounds: affine and constant bounds on the underlying keras layer output + + Note: + In hybrid case (ibp+affine), the constant bounds are assumed to be already tight in input, and we will return + the tighter constant bounds in output. This means that + - for the output: we take the tighter constant bounds between the ibp ones and the ones deduced + from the affine bounds given the considered perturbation domain, on the output. + - for the input: we do not need it, as it should already have been taken care of in the previous layer + + """ + # IBP: interval bounds propragation + if self.ibp: + lower, upper = self.inputs_outputs_spec.split_constant_bounds(constant_bounds=input_bounds_to_propagate) + output_constant_bounds = list(self.forward_ibp_propagate(lower=lower, upper=upper)) + else: + output_constant_bounds = [] + + # Affine bounds propagation + if self.affine: + if not self.linear: + # get oracle input bounds (because input_bounds_to_propagate could be empty at this point) + input_constant_bounds = self.get_forward_oracle( + input_affine_bounds=affine_bounds_to_propagate, + input_constant_bounds=input_bounds_to_propagate, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + else: + input_constant_bounds = [] + # forward propagation + output_affine_bounds = list( + self.forward_affine_propagate( + input_affine_bounds=affine_bounds_to_propagate, input_constant_bounds=input_constant_bounds + ) + ) + else: + output_affine_bounds = [] + + # Tighten constant bounds in hybrid mode (ibp+affine) + if self.ibp and self.affine: + if len(perturbation_domain_inputs) == 0: + raise RuntimeError("keras model input is necessary for call_forward() in affine mode.") + x = perturbation_domain_inputs[0] + l_ibp, u_ibp = output_constant_bounds + w_l, b_l, w_u, b_u = output_affine_bounds + from_linear = self.linear and self.inputs_outputs_spec.is_wo_batch_bounds( + affine_bounds=affine_bounds_to_propagate + ) + l_affine = self.perturbation_domain.get_lower(x, w_l, b_l, missing_batchsize=from_linear) + u_affine = self.perturbation_domain.get_upper(x, w_u, b_u, missing_batchsize=from_linear) + u = K.minimum(u_ibp, u_affine) + l = K.maximum(l_ibp, l_affine) + output_constant_bounds = [l, u] + + return output_affine_bounds, output_constant_bounds + + def call_backward( + self, affine_bounds_to_propagate: list[Tensor], constant_oracle_bounds: list[Tensor] + ) -> list[Tensor]: + return list( + self.backward_affine_propagate( + output_affine_bounds=affine_bounds_to_propagate, input_constant_bounds=constant_oracle_bounds + ) + ) + + def call(self, inputs: list[Tensor]) -> list[Tensor]: + """Propagate bounds in the specified direction `self.propagation`. + + Args: + inputs: concatenation of affine_bounds_to_propagate + constant_oracle_bounds + perturbation_domain_inputs with + - affine_bounds_to_propagate: affine bounds to propagate. + Can be empty in forward direction if self.affine is False. + Can also be empty in case of identity affine bounds => we simply return layer affine bounds. + - constant_oracle_bounds: + - in forward direction, the ibp bounds (empty if self.ibp is False); + - in backward direction, the oracle constant bounds on keras inputs (never empty) + - perturbation_domain_inputs: the tensor defining the underlying keras model input perturbation, wrapped in a list. + - in forward direction when self.affine is True: a list with a single tensor x whose shape is given by `self.perturbation_domain.get_x_input_shape_wo_batchsize(model_input_shape)` + with `model_input_shape=model.input.shape[1:]` if `model` is the underlying keras model to analyse + - else: empty list + + Returns: + the propagated bounds. + - in forward direction: affine_bounds_propagated + constant_bounds_propagated, each one being empty if self.affine or self.ibp is False + - in backward direction: affine_bounds_propagated + + """ + ( + affine_bounds_to_propagate, + constant_oracle_bounds, + perturbation_domain_inputs, + ) = self.inputs_outputs_spec.split_inputs(inputs=inputs) + if self.propagation == Propagation.FORWARD: # forward + affine_bounds_propagated, constant_bounds_propagated = self.call_forward( + affine_bounds_to_propagate=affine_bounds_to_propagate, + input_bounds_to_propagate=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + return self.inputs_outputs_spec.flatten_outputs(affine_bounds_propagated, constant_bounds_propagated) + + else: # backward + affine_bounds_propagated = self.call_backward( + affine_bounds_to_propagate=affine_bounds_to_propagate, constant_oracle_bounds=constant_oracle_bounds + ) + return self.inputs_outputs_spec.flatten_outputs(affine_bounds_propagated) + + def build(self, input_shape: list[tuple[Optional[int], ...]]) -> None: + self.built = True + + def compute_output_shape( + self, + input_shape: list[tuple[Optional[int], ...]], + ) -> list[tuple[Optional[int], ...]]: + ( + affine_bounds_to_propagate_shape, + constant_oracle_bounds_shape, + perturbation_domain_inputs_shape, + ) = self.inputs_outputs_spec.split_input_shape(input_shape=input_shape) + if self.propagation == Propagation.FORWARD: + if self.ibp: + constant_bounds_propagated_shape = [self.layer.output.shape] * 2 + else: + constant_bounds_propagated_shape = [] + if self.affine: + # layer output shape + keras_layer_output_shape_wo_batchsize = self.layer.output.shape[1:] + # model input shape + model_input_shape_wo_batchsize = ( + self.inputs_outputs_spec.model_input_shape + ) # should be set to get accurate compute_output_shape() + + # outputs shape depends on layer and inputs being diagonal / linear (w/o batch) + b_out_shape_wo_batchsize = keras_layer_output_shape_wo_batchsize + if self.diagonal and self.inputs_outputs_spec.is_diagonal_bounds_shape( + affine_bounds_to_propagate_shape + ): + # propagated bounds still diagonal + w_out_shape_wo_batchsize = b_out_shape_wo_batchsize + else: + w_out_shape_wo_batchsize = model_input_shape_wo_batchsize + keras_layer_output_shape_wo_batchsize + if self.linear and self.inputs_outputs_spec.is_wo_batch_bounds_shape(affine_bounds_to_propagate_shape): + # no batch in propagated bounds + w_out_shape = w_out_shape_wo_batchsize + b_out_shape = b_out_shape_wo_batchsize + else: + w_out_shape = (None,) + w_out_shape_wo_batchsize + b_out_shape = (None,) + b_out_shape_wo_batchsize + affine_bounds_propagated_shape = [w_out_shape, b_out_shape, w_out_shape, b_out_shape] + else: + affine_bounds_propagated_shape = [] + + return self.inputs_outputs_spec.flatten_outputs_shape( + affine_bounds_propagated_shape=affine_bounds_propagated_shape, + constant_bounds_propagated_shape=constant_bounds_propagated_shape, + ) + + else: # backward + # model output shape + model_output_shape_wo_batchsize = self.inputs_outputs_spec.model_output_shape + # outputs shape depends if layer and inputs are diagonal / linear (w/o batch) + b_shape_wo_batchisze = model_output_shape_wo_batchsize + if self.diagonal and self.inputs_outputs_spec.is_diagonal_bounds_shape(affine_bounds_to_propagate_shape): + w_shape_wo_batchsize: Union[tuple[int, ...], list[tuple[int, ...]]] + if self._is_merging_layer: + w_shape_wo_batchsize = [model_output_shape_wo_batchsize] * self.inputs_outputs_spec.nb_keras_inputs + else: + w_shape_wo_batchsize = model_output_shape_wo_batchsize + else: + if self._is_merging_layer: + w_shape_wo_batchsize = [ + self.layer.input[i].shape[1:] + model_output_shape_wo_batchsize + for i in range(self.inputs_outputs_spec.nb_keras_inputs) + ] + else: + w_shape_wo_batchsize = self.layer.input.shape[1:] + model_output_shape_wo_batchsize + b_shape: tuple[Optional[int], ...] + w_shape: Union[tuple[Optional[int], ...], list[tuple[Optional[int], ...]]] + if self.linear and self.inputs_outputs_spec.is_wo_batch_bounds_shape(affine_bounds_to_propagate_shape): + b_shape = b_shape_wo_batchisze + w_shape = w_shape_wo_batchsize # type: ignore + else: + b_shape = (None,) + b_shape_wo_batchisze + if self._is_merging_layer: + w_shape = [(None,) + sub_w_shape_wo_batchsize for sub_w_shape_wo_batchsize in w_shape_wo_batchsize] # type: ignore + else: + w_shape = (None,) + w_shape_wo_batchsize # type: ignore + if self._is_merging_layer: + affine_bounds_propagated_shape = [ + [ + w_shape_i, + b_shape, + w_shape_i, + b_shape, + ] + for w_shape_i in w_shape + ] + else: + affine_bounds_propagated_shape = [w_shape, b_shape, w_shape, b_shape] + + return self.inputs_outputs_spec.flatten_outputs_shape( + affine_bounds_propagated_shape=affine_bounds_propagated_shape + ) diff --git a/src/decomon/layers/maxpooling.py b/src/decomon/layers/maxpooling.py deleted file mode 100644 index 759e0efb..00000000 --- a/src/decomon/layers/maxpooling.py +++ /dev/null @@ -1,241 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras.ops as K -import numpy as np -from keras.layers import InputSpec, MaxPooling2D - -from decomon.core import ForwardMode, PerturbationDomain -from decomon.layers.core import DecomonLayer -from decomon.layers.utils import max_ -from decomon.types import BackendTensor - - -# step 1: compute the maximum -class DecomonMaxPooling2D(DecomonLayer, MaxPooling2D): - """LiRPA implementation of MaxPooling2D layers. - See Keras official documentation for further details on the MaxPooling2D operator - - """ - - original_keras_layer_class = MaxPooling2D - - pool_size: Tuple[int, int] - strides: Tuple[int, int] - padding: str - data_format: str - - def __init__( - self, - pool_size: Union[int, Tuple[int, int]] = (2, 2), - strides: Optional[Union[int, Tuple[int, int]]] = None, - padding: str = "valid", - data_format: Optional[str] = None, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - pool_size=pool_size, - strides=strides, - padding=padding, - data_format=data_format, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - if self.mode == ForwardMode.IBP: - self.input_spec = [ - InputSpec(ndim=4), # u - InputSpec(ndim=4), # l - ] - elif self.mode == ForwardMode.AFFINE: - self.input_spec = [ - InputSpec(min_ndim=2), # x - InputSpec(ndim=5), # w_u - InputSpec(ndim=4), # b_u - InputSpec(ndim=5), # w_l - InputSpec(ndim=4), - ] # b_l - elif self.mode == ForwardMode.HYBRID: - self.input_spec = [ - InputSpec(min_ndim=2), # x - InputSpec(ndim=4), # u - InputSpec(ndim=5), # w_u - InputSpec(ndim=4), # b_u - InputSpec(ndim=4), # l - InputSpec(ndim=5), # w_l - InputSpec(ndim=4), - ] # b_l - else: - raise ValueError(f"Unknown mode {self.mode}.") - - if self.dc_decomp: - self.input_spec += [InputSpec(ndim=4), InputSpec(ndim=4)] - - # express maxpooling with convolutions - self.filters = np.zeros( - (self.pool_size[0], self.pool_size[1], 1, int(np.prod(self.pool_size))), dtype=self.dtype - ) - for i in range(self.pool_size[0]): - for j in range(self.pool_size[1]): - self.filters[i, j, 0, i * self.pool_size[0] + j] = 1 - - self.filters_w = self.filters[None] - self.strides_w = (1,) + self.strides - - def conv_(x: BackendTensor) -> BackendTensor: - if self.data_format in [None, "channels_last"]: - return K.cast( - K.expand_dims( - K.conv( - x, - self.filters, - strides=list(self.strides), - padding=self.padding, - data_format=self.data_format, - ), - -2, - ), - self.dtype, - ) - else: - return K.cast( - K.expand_dims( - K.conv( - x, - self.filters, - strides=list(self.strides), - padding=self.padding, - data_format=self.data_format, - ), - 1, - ), - self.dtype, - ) - - def conv_w_(x: BackendTensor) -> BackendTensor: - if self.data_format in [None, "channels_last"]: - return K.cast( - K.expand_dims( - K.conv( - x, - self.filters_w, - strides=list(self.strides_w), - padding=self.padding, - data_format=self.data_format, - ), - -2, - ), - self.dtype, - ) - else: - return K.cast( - K.expand_dims( - K.conv( - x, - self.filters_w, - strides=list(self.strides_w), - padding=self.padding, - data_format=self.data_format, - ), - 1, - ), - self.dtype, - ) - - self.internal_op = conv_ - self.internal_op_w = conv_w_ - - def _pooling_function_fast( - self, - inputs: List[BackendTensor], - ) -> List[BackendTensor]: - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - l_c_out = K.max_pool(l_c, self.pool_size, self.strides, self.padding, self.data_format) - u_c_out = K.max_pool(u_c, self.pool_size, self.strides, self.padding, self.data_format) - - if self.affine: - n_in = x.shape[-1] - w_u_out = K.concatenate([0 * K.expand_dims(u_c_out, 1)] * n_in, 1) - w_l_out = w_u_out - b_u_out = u_c_out - b_l_out = l_c_out - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if self.dc_decomp: - raise NotImplementedError() - else: - h_out, g_out = empty_tensor, empty_tensor - - return self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - def _pooling_function_not_fast( - self, - inputs: List[BackendTensor], - ) -> List[BackendTensor]: - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = self.inputs_outputs_spec.get_empty_tensor(dtype=dtype) - input_shape = self.inputs_outputs_spec.get_kerasinputshape(inputs) - n_split = input_shape[-1] - - if self.dc_decomp: - h_out = K.concatenate( - [self.internal_op(elem) for elem in K.split(h, n_split, -1)], - -2, - ) - g_out = K.concatenate( - [self.internal_op(elem) for elem in K.split(g, n_split, -1)], - -2, - ) - else: - h_out, g_out = empty_tensor, empty_tensor - - if self.ibp: - u_c_out = K.concatenate([self.internal_op(elem) for elem in K.split(u_c, n_split, -1)], -2) - l_c_out = K.concatenate([self.internal_op(elem) for elem in K.split(l_c, n_split, -1)], -2) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.affine: - b_u_out = K.concatenate([self.internal_op(elem) for elem in K.split(b_u, n_split, -1)], -2) - b_l_out = K.concatenate([self.internal_op(elem) for elem in K.split(b_l, n_split, -1)], -2) - w_u_out = K.concatenate([self.internal_op_w(elem) for elem in K.split(w_u, n_split, -1)], -2) - w_l_out = K.concatenate([self.internal_op_w(elem) for elem in K.split(w_l, n_split, -1)], -2) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - outputs = self.inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - return max_( - outputs, axis=-1, dc_decomp=self.dc_decomp, mode=self.mode, perturbation_domain=self.perturbation_domain - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - if self.fast: - return self._pooling_function_fast(inputs) - else: - return self._pooling_function_not_fast(inputs) - - -# Aliases -DecomonMaxPool2d = DecomonMaxPooling2D diff --git a/src/decomon/layers/merging/__init__.py b/src/decomon/layers/merging/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/merging/add.py b/src/decomon/layers/merging/add.py new file mode 100644 index 00000000..7811f87f --- /dev/null +++ b/src/decomon/layers/merging/add.py @@ -0,0 +1,17 @@ +import keras.ops as K +from keras.layers import Add + +from decomon.layers.merging.base_merge import DecomonMerge +from decomon.types import Tensor + + +class DecomonAdd(DecomonMerge): + layer: Add + linear = True + diagonal = True + + def get_affine_representation(self) -> tuple[list[Tensor], Tensor]: + w = [K.ones(input_i.shape[1:]) for input_i in self.keras_layer_input] + b = K.zeros(self.layer.output.shape[1:]) + + return w, b diff --git a/src/decomon/layers/merging/average.py b/src/decomon/layers/merging/average.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/merging/base_merge.py b/src/decomon/layers/merging/base_merge.py new file mode 100644 index 00000000..fd966e66 --- /dev/null +++ b/src/decomon/layers/merging/base_merge.py @@ -0,0 +1,452 @@ +from typing import Any + +import keras +import keras.ops as K + +from decomon.keras_utils import add_tensors, batch_multid_dot +from decomon.layers.fuse import combine_affine_bounds +from decomon.layers.layer import DecomonLayer +from decomon.types import Tensor + + +class DecomonMerge(DecomonLayer): + _is_merging_layer = True + + @property + def keras_layer_input(self) -> list[keras.KerasTensor]: + """self.layer.input returned as a list. + + In the degenerate case where only 1 input is merged, self.layer.input is a single keras tensor. + We want here to have always a list, even with a single element, for consistency purpose. + + """ + if isinstance(self.layer.input, list): + return self.layer.input + else: + return [self.layer.input] + + @property + def nb_keras_inputs(self) -> int: + """Number of inputs merged by the underlying layer.""" + return len(self.keras_layer_input) + + def get_affine_representation(self) -> tuple[list[Tensor], Tensor]: + """Get affine representation of the layer + + This computes the affine representation of the layer, when this is meaningful, + i.e. `self.linear` is True. + + If implemented, it will be used for backward and forward propagation of affine bounds through the layer. + For non-linear layers, one should implement `get_affine_bounds()` instead. + + Args: + + Returns: + [w_i]_i, b: affine representation of the layer satisfying + + layer(z) = Sum_i{w_i * z_i} + b + + More precisely, if the keras layer is merging inputs represented by z = (z_i)_i, we have + ``` + layer(z) = sum(batch_multid_dot(z[i], w[i], missing_batchsize=(False, True)) for i in range(len(z))) + b + ``` + + If w can be represented as a diagonal tensor, which means that the full version of w is retrieved by + w_full = K.reshape(K.diag(K.flatten(w)), w.shape + w.shape) + then + - class attribute `diagonal` should be set to True (in order to have a correct `compute_output_shape()`) + - we got + ``` + layer(z) = sum(batch_multid_dot(z[i], w[i], missing_batchsize=(False, True), diagonal=(False, True)) for i in range(len(z)))+ b + ``` + + Shapes: !no batchsize! + if diagonal is False: + w[i] ~ self.layer.input[i].shape[1:] + self.layer.output.shape[1:] + b ~ self.layer.output.shape[1:] + if diagonal is True: + w[i] ~ self.layer.output.shape[1:] + b ~ self.layer.output.shape[1:] + + + """ + if not self.linear: + raise RuntimeError("You should not call `get_affine_representation()` when `self.linear` is False.") + else: + raise NotImplementedError( + "`get_affine_representation()` needs to be implemented to get the forward and backward propagation of affine bounds. " + "Alternatively, you can also directly override " + "`forward_ibp_propagate()`, `forward_affine_propagate()` and `backward_affine_propagate()`." + ) + + def get_affine_bounds( + self, lower: list[Tensor], upper: list[Tensor] + ) -> tuple[list[Tensor], Tensor, list[Tensor], Tensor]: + """Get affine bounds on layer outputs from layer inputs + + This compute the affine relaxation of the layer, given the oracle constant bounds on the inputs. + + If implemented, it will be used for backward and forward propagation of affine bounds through the layer. + For linear layers, one can implement `get_affine_representation()` instead. + + Args: + lower: lower constant oracle bounds on the layer inputs. + upper: upper constant oracle bounds on the layer inputs. + + Returns: + w_l, b_l, w_u, b_u: affine relaxation of the layer satisfying + + Sum_i{w_l[i] * z[i]} + b_l <= layer(z) <= Sum_i{w_u[i] * z[i]} + b_u + with lower[i] <= z[i] <= upper[i] for all i + + If w(_l or_u)[i] can be represented as a diagonal tensor, which means that the full version of w is retrieved by + w_full = K.concatenate([K.reshape(K.diag(K.flatten(w[i])), w.shape + w.shape)[None] for i in range(len(w))], axis=0) + then the class attribute `diagonal` should be set to True (in order to have a correct `compute_output_shape()`) + + Shapes: + if diagonal is False: + lowers[i], uppers[i] ~ (batchsize,) + self.layer.input[i].shape[1:] + w_l[i], w_u[i] ~ (batchsize,) + self.layer.input[i].shape[1:] + self.layer.output.shape[1:] + b_l, b_u ~ (batchsize,) + self.layer.output.shape[1:] + if diagonal is True: + lower[i], upper[i] ~ (batchsize,) + self.layer.input[i].shape[1:] + w_l[i], w_u[i] ~ (batchsize,) + self.layer.output.shape[1:] + b_l, b_u ~ (batchsize,) + self.layer.output.shape[1:] + + Note: + `w * z` means here `batch_multid_dot(z, w)`. + + """ + if self.linear: + w, b = self.get_affine_representation() + batchsize = lower[0].shape[0] + w_with_batchsize = [K.repeat(w_i[None], batchsize, axis=0) for w_i in w] + b_with_batchsize = K.repeat(b[None], batchsize, axis=0) + return w_with_batchsize, b_with_batchsize, w_with_batchsize, b_with_batchsize + else: + raise NotImplementedError( + "`get_affine_bounds()` needs to be implemented to get the forward and backward propagation of affine bounds. " + "Alternatively, you can also directly override `forward_affine_propagate()` and `backward_affine_propagate()`" + ) + + def forward_ibp_propagate(self, lower: list[Tensor], upper: list[Tensor]) -> tuple[Tensor, Tensor]: + """Propagate ibp bounds through the layer. + + If the underlying keras layer is linear, it will be deduced from its affine representation. + Else, this needs to be implemented to forward propagate ibp (constant) bounds. + + Args: + lower: lower constant oracle bounds on the keras layer inputs. + upper: upper constant oracle bounds on the keras layer inputs. + + Returns: + l_c, u_c: constant relaxation of the layer satisfying + l_c <= layer(z) <= u_c + with lower[i] <= z[i] <= upper[i] for all i + + Shapes: + lower[i], upper[i] ~ (batchsize,) + self.layer.input[i].shape[1:] + l_c, u_c ~ (batchsize,) + self.layer.output.shape[1:] + + """ + if self.linear: + w, b = self.get_affine_representation() + z_value = K.cast(0.0, dtype=b.dtype) + + l_c = b + u_c = b + + for w_i, lower_i, upper_i in zip(w, lower, upper): + is_diag = w_i.shape == b.shape + kwargs_dot: dict[str, Any] = dict(missing_batchsize=(False, True), diagonal=(False, is_diag)) + + w_i_pos = K.maximum(w_i, z_value) + w_i_neg = K.minimum(w_i, z_value) + + # NB: += does not work well with broadcasting on pytorch backend => we use l_c = l_c + ... + l_c = ( + l_c + + batch_multid_dot(lower_i, w_i_pos, **kwargs_dot) + + batch_multid_dot(upper_i, w_i_neg, **kwargs_dot) + ) + u_c = ( + u_c + + batch_multid_dot(upper_i, w_i_pos, **kwargs_dot) + + batch_multid_dot(lower_i, w_i_neg, **kwargs_dot) + ) + + return l_c, u_c + else: + raise NotImplementedError( + "`forward_ibp_propagate()` needs to be implemented to get the forward propagation of constant bounds." + ) + + def forward_affine_propagate( + self, input_affine_bounds: list[list[Tensor]], input_constant_bounds: list[list[Tensor]] + ) -> tuple[Tensor, Tensor, Tensor, Tensor]: + """Propagate model affine bounds in forward direction. + + By default, this is deduced from `get_affine_bounds()` (or `get_affine_representation()` if `self.linear is True). + But this could be overridden for better performance. See `DecomonConv2D` for an example. + + Args: + input_affine_bounds[i]: [w_l_in[i], b_l_in[i], w_u_in[i], b_u_in[i]] + affine bounds on underlying keras layer i-th input w.r.t. model input + input_constant_bounds[i]: [l_c_in[i], u_c_in[i]] + constant oracle bounds on underlying keras layer i-th input (already deduced from affine ones if necessary) + + Returns: + w_l_new, b_l_new, w_u_new, b_u_new: affine bounds on underlying keras layer *output* w.r.t. model input + + If we denote by + - x: keras model input + - z[i]: underlying keras layer i-th input + - h(x) = layer(z): output of the underlying keras layer + + The following inequations are satisfied + w_l * x + b_l <= h(x) <= w_u * x + b_u + l_c_in[i] <= z[i] <= u_c_in[i] for all i + w_l_in[i] * x + b_l_in[i] <= z[i] <= w_u_in[i] * x + b_u_in[i] for all i + + """ + if self.linear: + w, b = self.get_affine_representation() + w_l, b_l, w_u, b_u = w, b, w, b + else: + lower, upper = self.inputs_outputs_spec.split_constant_bounds(constant_bounds=input_constant_bounds) + w_l, b_l, w_u, b_u = self.get_affine_bounds(lower=lower, upper=upper) + + b_l_new, b_u_new = b_l, b_u + from_linear_layer_new = self.linear + diagonal_w_new = True + first_iteration = True + for i in range(len(input_affine_bounds)): + input_affine_bounds_i = input_affine_bounds[i] + partial_layer_affine_bounds_i = [w_l[i], 0.0, w_u[i], 0.0] + partial_layer_affine_bounds_true_shape_i = [w_l[i].shape, b_l.shape, w_u[i].shape, b_u.shape] + from_linear_layer_combine = ( + self.inputs_outputs_spec.is_wo_batch_bounds(input_affine_bounds_i, i=i), + self.linear, + ) + diagonal_combine = ( + self.inputs_outputs_spec.is_diagonal_bounds(input_affine_bounds_i, i=i), + self.inputs_outputs_spec.is_diagonal_bounds_shape(partial_layer_affine_bounds_true_shape_i, i=i), + ) + delta_w_l_new, delta_b_l_new, delta_w_u_new, delta_b_u_new = combine_affine_bounds( + affine_bounds_1=input_affine_bounds_i, + affine_bounds_2=partial_layer_affine_bounds_i, + from_linear_layer=from_linear_layer_combine, + diagonal=diagonal_combine, + ) + # add delta_b and delta_w, taking into account format (diagonal & from_linear_layer) + from_linear_layer_delta = all(from_linear_layer_combine) + diagonal_delta = all(diagonal_combine) + from_linear_add = (from_linear_layer_new, from_linear_layer_delta) + if first_iteration: + w_l_new = delta_w_l_new + w_u_new = delta_w_u_new + first_iteration = False + diagonal_w_new = diagonal_delta + else: + diagonal_add_w = (diagonal_w_new, diagonal_delta) + w_l_new = add_tensors( + w_l_new, + delta_w_l_new, + missing_batchsize=from_linear_add, + diagonal=diagonal_add_w, + ) + w_u_new = add_tensors( + w_u_new, + delta_w_u_new, + missing_batchsize=from_linear_add, + diagonal=diagonal_add_w, + ) + diagonal_w_new = all(diagonal_add_w) + b_l_new = add_tensors( + b_l_new, + delta_b_l_new, + missing_batchsize=from_linear_add, + ) + b_u_new = add_tensors( + b_u_new, + delta_b_u_new, + missing_batchsize=from_linear_add, + ) + from_linear_layer_new = all(from_linear_add) + return w_l_new, b_l_new, w_u_new, b_u_new + + def backward_affine_propagate( # type: ignore + self, output_affine_bounds: list[Tensor], input_constant_bounds: list[list[Tensor]] + ) -> list[tuple[Tensor, Tensor, Tensor, Tensor]]: + """Propagate model affine bounds in backward direction. + + By default, this is deduced from `get_affine_bounds()` (or `get_affine_representation()` if `self.linear is True). + But this could be overridden for better performance. See `DecomonConv2D` for an example. + + Args: + output_affine_bounds: [w_l, b_l, w_u, b_u] + partial affine bounds on model output w.r.t underlying keras layer output + input_constant_bounds: [[l_c_in[i], u_c_in[i]]]_i + constant oracle bounds on underlying keras layer inputs + + Returns: + [w_l_new[i], b_l_new[i], w_u_new[i], b_u_new[i]]_i: partial affine bounds on model output w.r.t. underlying keras layer *inputs* + + If we denote by + - x: keras model input + - m(x): keras model output + - z[i]: underlying keras layer i-th input + - h(x) = layer(z): output of the underlying keras layer + - h_j(x) output of the j-th layer + - w_l_j, b_l_j, w_u_j, b_u_j: current partial linear bounds on model output w.r.t to h_j(x) + + The following inequations are satisfied + + l_c_in[i] <= z[i] <= u_c_in[i] for all i + + Sum_{others layers j}{w_l_j * h_j(x) + b_l_j} + w_l * h(x) + b_l + <= m(x) + <= Sum_{others layers j}{w_u_j * h_j(x) + b_u_j} + w_u * h(x) + b_u + + Sum_{others layers j}{w_l_j * h_j(x) + b_l_j} + Sum_i{w_l_new[i] * z[i] + b_l_new[i]} + <= m(x) + <= Sum_{others layers j}{w_u_j * h_j(x) + b_u_j} + Sum_i[w_u_new[i] * z[i] + b_u_new[i]} + + """ + if self.linear: + w, b = self.get_affine_representation() + w_l_layer, b_l_layer, w_u_layer, b_u_layer = w, b, w, b + else: + lower, upper = self.inputs_outputs_spec.split_constant_bounds(constant_bounds=input_constant_bounds) + w_l_layer, b_l_layer, w_u_layer, b_u_layer = self.get_affine_bounds(lower=lower, upper=upper) + + from_linear_output_affine_bounds = self.inputs_outputs_spec.is_wo_batch_bounds(output_affine_bounds) + diagonal_output_affine_bounds = self.inputs_outputs_spec.is_diagonal_bounds(output_affine_bounds) + + first_iteration = True + propagated_affine_bounds = [] + output_affine_bounds_for_i = list(output_affine_bounds) + for i in range(len(w_l_layer)): + w_l_layer_i = w_l_layer[i] + w_u_layer_i = w_u_layer[i] + if first_iteration: + # we mix the biases for first input + b_l_layer_i = b_l_layer + b_u_layer_i = b_u_layer + else: + # we skip the biases for the other inputs as already taken into account + b_l_layer_i = K.zeros_like(b_l_layer) + b_u_layer_i = K.zeros_like(b_u_layer) + output_affine_bounds_for_i[1] = 0.0 + output_affine_bounds_for_i[3] = 0.0 + partial_layer_affine_bounds_i = [w_l_layer_i, b_l_layer_i, w_u_layer_i, b_u_layer_i] + from_linear_layer = (self.linear, from_linear_output_affine_bounds) + diagonal = ( + self.inputs_outputs_spec.is_diagonal_bounds(partial_layer_affine_bounds_i), + diagonal_output_affine_bounds, + ) + propagated_affine_bounds.append( + combine_affine_bounds( + affine_bounds_1=partial_layer_affine_bounds_i, + affine_bounds_2=output_affine_bounds_for_i, + from_linear_layer=from_linear_layer, + diagonal=diagonal, + ) + ) + return propagated_affine_bounds + + def get_forward_oracle( + self, + input_affine_bounds: list[list[Tensor]], + input_constant_bounds: list[list[Tensor]], + perturbation_domain_inputs: list[Tensor], + ) -> list[list[Tensor]]: # type: ignore + """Get constant oracle bounds on underlying keras layer input from forward input bounds. + + Args: + input_affine_bounds: affine bounds on each keras layer input w.r.t model input . Can be empty if not in affine mode. + input_constant_bounds: ibp constant bounds on each keras layer input. Can be empty if not in ibp mode. + perturbation_domain_inputs: perturbation domain input, wrapped in a list. Necessary only in affine mode, else empty. + + Returns: + constant bounds on each keras layer input deduced from forward input bounds + + `input_affine_bounds, input_constant_bounds` are the forward bounds to be propagate through the layer. + `input_affine_bounds` (resp. `input_constant_bounds`) will be empty if `self.affine` (resp. `self.ibp) is False. + + In hybrid case (ibp+affine), the constant bounds are assumed to be already tight, which means the previous + forward layer should already have took the tighter constant bounds between the ibp ones and the ones deduced + from the affine bounds given the considered perturbation domain. + + """ + return super().get_forward_oracle(input_affine_bounds=input_affine_bounds, input_constant_bounds=input_constant_bounds, perturbation_domain_inputs=perturbation_domain_inputs) # type: ignore + + def call_forward( + self, + affine_bounds_to_propagate: list[list[Tensor]], + input_bounds_to_propagate: list[list[Tensor]], + perturbation_domain_inputs: list[Tensor], + ) -> tuple[list[Tensor], list[Tensor]]: + """Propagate forward affine and constant bounds through the layer. + + Args: + affine_bounds_to_propagate: affine bounds on each keras layer input w.r.t model input. + Can be empty if not in affine mode. + input_bounds_to_propagate: ibp constant bounds on each keras layer input. Can be empty if not in ibp mode. + perturbation_domain_inputs: perturbation domain input, wrapped in a list. Necessary only in affine mode, else empty. + + Returns: + output_affine_bounds, output_constant_bounds: affine and constant bounds on the underlying keras layer output + + """ + return super().call_forward(affine_bounds_to_propagate, input_bounds_to_propagate, perturbation_domain_inputs) + + def call_backward( + self, affine_bounds_to_propagate: list[Tensor], constant_oracle_bounds: list[list[Tensor]] + ) -> list[list[Tensor]]: + return [ + list(partial_affine_bounds) + for partial_affine_bounds in self.backward_affine_propagate( + output_affine_bounds=affine_bounds_to_propagate, input_constant_bounds=constant_oracle_bounds + ) + ] + + def call(self, inputs: list[Tensor]) -> list[Tensor]: + """Propagate bounds in the specified direction `self.propagation`. + + Args: + inputs: flattened decomon inputs + - forward propagation: k affine bounds to propagate w.r.t. each keras layer input + k constant bounds + + inputs = ( + affine_bounds_to_propagate_0 + constant_oracle_bounds_0 + ... + + affine_bounds_to_propagate_k + constant_oracle_bounds_k + + perturbation_domain_inputs + ) + + with + - affine_bounds_to_propagate_* empty when affine is False; + (never to express identity bounds, as it would be impossible to separate bounds in flattened inputs) + - constant_oracle_bounds_* empty when ibp is False; + - perturbation_domain_inputs: + - if affine: the tensor defining the underlying keras model input perturbation, wrapped in a list; + - else: empty + + - backward propagation: only 1 affine bounds to propagate w.r.t keras layer output + + k constant bounds w.r.t each keras layer input + + inputs = ( + affine_bounds_to_propagate + + constant_oracle_bounds_0 + ... + constant_oracle_bounds_k + ) + + with affine_bounds_to_propagate potentially empty to express identity affine bounds. + + Returns: + the propagated bounds, in a flattened list. + + - in forward direction: affine_bounds_propagated + constant_bounds_propagated, each one being empty if self.affine or self.ibp is False + - in backward direction: affine_bounds_propagated_0 + ... + affine_bounds_propagated_k, affine bounds w.r.t to each keras layer input + + """ + return super().call(inputs=inputs) diff --git a/src/decomon/layers/merging/concatenate.py b/src/decomon/layers/merging/concatenate.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/merging/dot.py b/src/decomon/layers/merging/dot.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/merging/maximum.py b/src/decomon/layers/merging/maximum.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/merging/minimum.py b/src/decomon/layers/merging/minimum.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/merging/multiply.py b/src/decomon/layers/merging/multiply.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/merging/subtract.py b/src/decomon/layers/merging/subtract.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/normalization/__init__.py b/src/decomon/layers/normalization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/normalization/batch_normalization.py b/src/decomon/layers/normalization/batch_normalization.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/oracle.py b/src/decomon/layers/oracle.py new file mode 100644 index 00000000..a2f211d7 --- /dev/null +++ b/src/decomon/layers/oracle.py @@ -0,0 +1,251 @@ +"""Layers specifying constant oracle bounds on keras layer input.""" + + +from typing import Any, Optional, Union, overload + +from keras.layers import Layer +from keras.utils import serialize_keras_object + +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.perturbation_domain import PerturbationDomain +from decomon.types import BackendTensor + + +class BaseOracle(Layer): + """Base class for oracle layers.""" + + ... + + +class DecomonOracle(BaseOracle): + """Layer deducing oracle bounds from decomon model outputs (forward or crown). + + The decomon model is supposed to give bounds for the inputs of the keras layer of interest. + + - In case of forward ibp or hybrid decomon model, + the ibp bounds in decomon outputs are already the oracle bounds. + - In case of forward pure affine or crown decomon model, + the outputs are affine bounds on the inputs of the keras layer of interest w.r.t the keras model input. + So we combine them with the perturbation domain input. + + Merging keras layer case: if the keras layer needing oracle is a merging layer, + - the decomon model outputs are the concatenation of bounds on each keras layer input, + - the oracle will be the concatenation of oracle bounds on each keras layer input + + """ + + def __init__( + self, + perturbation_domain: PerturbationDomain, + ibp: bool, + affine: bool, + layer_input_shape: Union[tuple[int, ...], list[tuple[int, ...]]], + is_merging_layer: bool, + **kwargs: Any, + ): + """ + Args: + perturbation_domain: default to a box domain + ibp: True for forward ibp or forward hybrid decomon model. + In that case, the ibp bounds in the outputs are already the oracle bounds + affine: True for forward affine (and hybrid) model and crown model. + In that case (except for hybrid case, see above), the oracle bounds are deduce from the affine bounds. + layer_input_shape: input shape of the underlying keras layer, without batchsize + is_merging_layer: whether the underlying keras layer is a merging layer (i.e. with several inputs) + **kwargs: + + """ + super().__init__(**kwargs) + + self.perturbation_domain = perturbation_domain + self.ibp = ibp + self.affine = affine + self.is_merging_layer = is_merging_layer + self.layer_input_shape = layer_input_shape + self.inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + layer_input_shape=layer_input_shape, + is_merging_layer=is_merging_layer, + ) + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "ibp": self.ibp, + "affine": self.affine, + "perturbation_domain": serialize_keras_object(self.perturbation_domain), + "layer_input_shape": self.layer_input_shape, + "is_merging_layer": self.is_merging_layer, + } + ) + return config + + def call(self, inputs: list[BackendTensor]) -> Union[list[BackendTensor], list[list[BackendTensor]]]: + """Deduce ibp and affine bounds to propagate by the first forward layer. + + Args: + inputs: the outputs of a decomon model + perturbation domain input if necessary + + Returns: + concatenation of constant bounds on each keras layer input deduced from ibp and/or affine bounds + + According to the sense of propagation of the decomon model, we have + + - forward: inputs = affine_bounds + ibp_bounds + perturbation_domain_inputs with + - affine_bounds: empty if self.affine = False + - ibp_bounds: empty if self.ibp = False, already tight in hyb rid case (self.ibp=true and self.affine=True) + - perturbation_domain_inputs: if self.ibp=False this is the pertubation domain input wrapped in a list, else empty. + + outputs = ibp_bounds when available, else combine affine_bounds with perturbation_domain_inputs + + - backward: inputs = crown_bounds + perturbation_domain_inputs with + - affine_bounds: empty if self.affine = False + - ibp_bounds: empty if self.ibp = False, already tight in hyb rid case (self.ibp=true and self.affine=True) + - perturbation_domain_inputs: if self.ibp=False this is the pertubation domain input wrapped in a list, else empty. + + outputs = crown_bounds (affine) combined with perturbation domain input + + """ + ( + affine_bounds, + ibp_bounds, + perturbation_domain_inputs, + ) = self.inputs_outputs_spec.split_inputs(inputs=inputs) + + from_linear = self.inputs_outputs_spec.is_wo_batch_bounds_by_keras_input(affine_bounds) + + return get_forward_oracle( + affine_bounds=affine_bounds, + ibp_bounds=ibp_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + perturbation_domain=self.perturbation_domain, + ibp=self.ibp, + affine=self.affine, + is_merging_layer=self.is_merging_layer, + from_linear=from_linear, + ) + + def compute_output_shape( + self, + input_shape: list[tuple[Optional[int], ...]], + ) -> Union[list[tuple[Optional[int], ...]], list[list[tuple[Optional[int], ...]]]]: + """Compute output shape in case of symbolic call.""" + if self.is_merging_layer: + output_shape = [] + layer_input_shape_i: tuple[int, ...] + for layer_input_shape_i in self.layer_input_shape: # type: ignore + layer_input_shape_w_batchsize_i = (None,) + layer_input_shape_i + output_shape.append([layer_input_shape_w_batchsize_i, layer_input_shape_w_batchsize_i]) + return output_shape + else: + layer_input_shape_w_batchsize = (None,) + self.layer_input_shape # type: ignore + return [layer_input_shape_w_batchsize, layer_input_shape_w_batchsize] + + +@overload +def get_forward_oracle( + affine_bounds: list[BackendTensor], + ibp_bounds: list[BackendTensor], + perturbation_domain_inputs: list[BackendTensor], + perturbation_domain: PerturbationDomain, + ibp: bool, + affine: bool, + is_merging_layer: bool, + from_linear: bool, +) -> list[BackendTensor]: + """Get constant oracle bounds on keras layer inputs from forward input bounds. + + Non-merging layer version. + + """ + ... + + +@overload +def get_forward_oracle( + affine_bounds: list[list[BackendTensor]], + ibp_bounds: list[list[BackendTensor]], + perturbation_domain_inputs: list[BackendTensor], + perturbation_domain: PerturbationDomain, + ibp: bool, + affine: bool, + is_merging_layer: bool, + from_linear: list[bool], +) -> list[list[BackendTensor]]: + """Get constant oracle bounds on keras layer inputs from forward input bounds. + + Merging layer version. + + """ + ... + + +def get_forward_oracle( + affine_bounds: Union[list[BackendTensor], list[list[BackendTensor]]], + ibp_bounds: Union[list[BackendTensor], list[list[BackendTensor]]], + perturbation_domain_inputs: list[BackendTensor], + perturbation_domain: PerturbationDomain, + ibp: bool, + affine: bool, + is_merging_layer: bool, + from_linear: Union[bool, list[bool]], +) -> Union[list[BackendTensor], list[list[BackendTensor]]]: + """Get constant oracle bounds on keras layer inputs from forward input bounds. + + Args: + affine_bounds: affine bounds on keras layer inputs w.r.t model input . Can be empty if not in affine mode. + ibp_bounds: ibp constant bounds on keras layer input. Can be empty if not in ibp mode. + perturbation_domain_inputs: perturbation domain input, wrapped in a list. Necessary only in affine mode, else empty. + perturbation_domain: perturbation domain spec. + ibp: ibp bounds exist? + affine: affine bounds exist? + is_merging_layer: keras layer is a merging layer? + from_linear: affine bounds from linear layer/model? (ie no batchsize + upper affine bound==lower affine bound) + + Returns: + constant bounds on keras layer input deduced from forward layer input bounds or crown output + perturbation_domain_input + + In hybrid case (ibp+affine), the constant bounds are assumed to be already tight, which means the previous + forward layer should already have took the tighter constant bounds between the ibp ones and the ones deduced + from the affine bounds given the considered perturbation domain. + + """ + if ibp: + # Hyp: in hybrid mode, the constant bounds are already tight + # (affine and ibp mixed in forward layer output to get the tightest constant bounds) + return ibp_bounds + + elif affine: + if len(perturbation_domain_inputs) == 0: + raise RuntimeError("Perturbation domain input is necessary for get_forward_oracle() in affine mode.") + x = perturbation_domain_inputs[0] + if is_merging_layer: + constant_bounds = [] + for affine_bounds_i, from_linear_i in zip(affine_bounds, from_linear): # type: ignore + if len(affine_bounds_i) == 0: + # special case: empty affine bounds => identity bounds + l_affine = perturbation_domain.get_lower_x(x) + u_affine = perturbation_domain.get_upper_x(x) + else: + w_l, b_l, w_u, b_u = affine_bounds_i + l_affine = perturbation_domain.get_lower(x, w_l, b_l, missing_batchsize=from_linear_i) + u_affine = perturbation_domain.get_upper(x, w_u, b_u, missing_batchsize=from_linear_i) + constant_bounds.append([l_affine, u_affine]) + return constant_bounds + else: + if len(affine_bounds) == 0: + # special case: empty affine bounds => identity bounds + l_affine = perturbation_domain.get_lower_x(x) + u_affine = perturbation_domain.get_upper_x(x) + else: + if not isinstance(from_linear, bool): + raise ValueError("from_linear must be a boolean for a non-merging layer") + w_l, b_l, w_u, b_u = affine_bounds + l_affine = perturbation_domain.get_lower(x, w_l, b_l, missing_batchsize=from_linear) + u_affine = perturbation_domain.get_upper(x, w_u, b_u, missing_batchsize=from_linear) + return [l_affine, u_affine] + + else: + raise RuntimeError("ibp and affine cannot be both False") diff --git a/src/decomon/layers/output.py b/src/decomon/layers/output.py new file mode 100644 index 00000000..74e1147d --- /dev/null +++ b/src/decomon/layers/output.py @@ -0,0 +1,201 @@ +"""Convert decomon outputs to the specified format.""" + + +from typing import Any, Optional + +import keras.ops as K +from keras.layers import Layer +from keras.utils import serialize_keras_object + +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.layers.oracle import get_forward_oracle +from decomon.perturbation_domain import PerturbationDomain +from decomon.types import BackendTensor + + +class ConvertOutput(Layer): + """Layer converting output of decomon model to desired final format.""" + + def __init__( + self, + perturbation_domain: PerturbationDomain, + ibp_from: bool, + affine_from: bool, + ibp_to: bool, + affine_to: bool, + model_output_shapes: list[tuple[int, ...]], + **kwargs: Any, + ): + """ + Args: + perturbation_domain: perturbation domain considered on keras model input + ibp_from: ibp bounds present in current outputs? + affine_from: affine bounds present in current outputs? + ibp_to: ibp bounds to be present in new outputs? + affine_to: affine bounds to be present in new outputs? + model_output_shapes: shape of each output of the keras model (w/o batchsize) + **kwargs: passed to Layer.__init__() + + """ + super().__init__(**kwargs) + + self.model_output_shapes = model_output_shapes + self.affine_to = affine_to + self.ibp_to = ibp_to + self.affine_from = affine_from + self.ibp_from = ibp_from + self.perturbation_domain = perturbation_domain + self.nb_outputs_keras_model = len(model_output_shapes) + self.inputs_outputs_spec = InputsOutputsSpecForConvertOutput( + ibp=ibp_from, + affine=affine_from, + ibp_to=ibp_to, + affine_to=affine_to, + model_output_shapes=model_output_shapes, + ) + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "ibp_from": self.ibp_from, + "affine_from": self.affine_from, + "ibp_to": self.ibp_to, + "affine_to": self.affine_to, + "model_output_shapes": self.model_output_shapes, + "perturbation_domain": serialize_keras_object(self.perturbation_domain), + } + ) + return config + + def needs_perturbation_domain_inputs(self) -> bool: + return self.inputs_outputs_spec.needs_perturbation_domain_inputs() + + def call(self, inputs: BackendTensor) -> list[BackendTensor]: + """Compute ibp and affine bounds according to desired format from current decomon outputs. + + Args: + inputs: sum_{i} (affine_bounds_from[i] + constant_bounds_from[i]) + perturbation_domain_inputs + being the affine and constant bounds for each output of the keras model, with + - i: the indice of the model output considered + - sum_{i}: the concatenation of subsequent lists over i + - affine_bounds_from[i]: empty if `self.affine_from` is False + - constant_bounds_from[i]: empty if `self.ibp_from` is False + - perturbation_domain_inputs: perturbation domain input wrapped in a list if self.ibp_from is False + and self.ibp_to is True, else empty + + Returns: + sum_{i} (affine_bounds_to[i] + constant_bounds_to[i]): the affine and constant bounds computed + - affine_bounds_to[i]: empty if `self.affine_to` is False + - constant_bounds_to[i]: empty if `self.ibp_to` is False + + Computation: + - ibp: if ibp_to is True and ibp_from is False, we use `decomon.layers.oracle.get_forward_oracle()` + - affine: if affine_to is True and affine_from is False, we construct trivial bounds from ibp bounds + w_l = w_u = 0 and b_l=lower, b_u=upper + + """ + affine_bounds_from, constant_bounds_from, perturbation_domain_inputs = self.inputs_outputs_spec.split_inputs( + inputs + ) + + if self.ibp_to: + if self.ibp_from: + constant_bounds_to = constant_bounds_from + else: + from_linear = self.inputs_outputs_spec.is_wo_batch_bounds_by_keras_input( + affine_bounds=affine_bounds_from + ) + constant_bounds_to = get_forward_oracle( + affine_bounds=affine_bounds_from, + ibp_bounds=constant_bounds_from, + perturbation_domain_inputs=perturbation_domain_inputs, + perturbation_domain=self.perturbation_domain, + ibp=self.ibp_from, + affine=self.affine_from, + is_merging_layer=True, + from_linear=from_linear, + ) + else: + constant_bounds_to = [[]] * self.nb_outputs_keras_model + + if self.affine_to: + if self.affine_from: + affine_bounds_to = affine_bounds_from + else: + x = perturbation_domain_inputs[0] + keras_input_shape_with_batchsize = self.perturbation_domain.get_kerasinputlike_from_x(x).shape + affine_bounds_to = [] + for constant_bounds_from_i in constant_bounds_from: + lower, upper = constant_bounds_from_i + w = K.zeros(keras_input_shape_with_batchsize + lower.shape[1:], dtype=x.dtype) + affine_bounds_to.append([w, lower, w, upper]) + else: + affine_bounds_to = [[]] * self.nb_outputs_keras_model + + return self.inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to, + constant_oracle_bounds=constant_bounds_to, + perturbation_domain_inputs=[], + ) + + def compute_output_shape( + self, + input_shape: list[tuple[Optional[int], ...]], + ) -> list[tuple[Optional[int], ...]]: + affine_bounds_from_shape: list[list[tuple[Optional[int], ...]]] + constant_bounds_from_shape: list[list[tuple[Optional[int], ...]]] + perturbation_domain_inputs_shape: list[tuple[Optional[int], ...]] + ( + affine_bounds_from_shape, + constant_bounds_from_shape, + perturbation_domain_inputs_shape, + ) = self.inputs_outputs_spec.split_input_shape( # type: ignore + input_shape + ) + constant_bounds_to_shape: list[list[tuple[Optional[int], ...]]] + affine_bounds_to_shape: list[list[tuple[Optional[int], ...]]] + + if self.ibp_to: + if self.ibp_from: + constant_bounds_to_shape = constant_bounds_from_shape + else: + constant_bounds_to_shape = [] + for model_output_shape in self.model_output_shapes: + lower_shape = (None,) + model_output_shape + constant_bounds_to_shape.append([lower_shape, lower_shape]) + else: + constant_bounds_to_shape = [[]] * self.nb_outputs_keras_model + + if self.affine_to: + if self.affine_from: + affine_bounds_to_shape = affine_bounds_from_shape + else: + x_shape = perturbation_domain_inputs_shape[0] + x_shape_wo_batchsize: tuple[int, ...] = x_shape[1:] # type: ignore + keras_input_shape = self.perturbation_domain.get_keras_input_shape_wo_batchsize(x_shape_wo_batchsize) + affine_bounds_to_shape = [] + for model_output_shape in self.model_output_shapes: + b_shape = (None,) + model_output_shape + w_shape = (None,) + keras_input_shape + model_output_shape + affine_bounds_to_shape.append([w_shape, b_shape, w_shape, b_shape]) + else: + affine_bounds_to_shape = [[]] * self.nb_outputs_keras_model + + return self.inputs_outputs_spec.flatten_inputs_shape( + affine_bounds_to_propagate_shape=affine_bounds_to_shape, + constant_oracle_bounds_shape=constant_bounds_to_shape, + perturbation_domain_inputs_shape=[], + ) + + +class InputsOutputsSpecForConvertOutput(InputsOutputsSpec): + def __init__( + self, ibp: bool, affine: bool, ibp_to: bool, affine_to: bool, model_output_shapes: list[tuple[int, ...]] + ): + super().__init__(ibp=ibp, affine=affine, layer_input_shape=model_output_shapes, is_merging_layer=True) + self.affine_to = affine_to + self.ibp_to = ibp_to + + def needs_perturbation_domain_inputs(self) -> bool: + return (self.ibp_to and not self.ibp) or (self.affine_to and not self.affine) diff --git a/src/decomon/layers/pooling/__init__.py b/src/decomon/layers/pooling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/pooling/max_pooling2d.py b/src/decomon/layers/pooling/max_pooling2d.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/regularization/__init__.py b/src/decomon/layers/regularization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/regularization/dropout.py b/src/decomon/layers/regularization/dropout.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/reshaping/__init__.py b/src/decomon/layers/reshaping/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/reshaping/flatten.py b/src/decomon/layers/reshaping/flatten.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/reshaping/permute.py b/src/decomon/layers/reshaping/permute.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/reshaping/reshape.py b/src/decomon/layers/reshaping/reshape.py new file mode 100644 index 00000000..e69de29b diff --git a/src/decomon/layers/utils.py b/src/decomon/layers/utils.py deleted file mode 100644 index b15bfa4f..00000000 --- a/src/decomon/layers/utils.py +++ /dev/null @@ -1,1170 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras -import keras.ops as K -import numpy as np -from keras.config import epsilon -from keras.constraints import Constraint -from keras.initializers import Initializer -from keras.layers import Layer - -from decomon.core import ( - BallDomain, - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - Slope, - get_affine, - get_ibp, -) -from decomon.types import BackendTensor, Tensor -from decomon.utils import add, get_linear_softplus_hull, maximum, minimum, minus, relu_ - - -def is_a_merge_layer(layer: Layer) -> bool: - return hasattr(layer, "_merge_function") - - -class NonPos(Constraint): - """Constrains the weights to be non-negative.""" - - def __call__(self, w: BackendTensor) -> BackendTensor: - return K.minimum(w, K.cast(0.0, dtype=w.dtype)) - - -class NonNeg(Constraint): - """Constrains the weights to be non-negative.""" - - def __call__(self, w: BackendTensor) -> BackendTensor: - return K.maximum(w, K.cast(0.0, dtype=w.dtype)) - - -class ClipAlpha(Constraint): - """Cosntraints the weights to be between 0 and 1.""" - - def __call__(self, w: BackendTensor) -> BackendTensor: - return K.clip(w, 0.0, 1.0) - - -class ClipAlphaGrid(Constraint): - """Cosntraints the weights to be between 0 and 1.""" - - def __call__(self, w: BackendTensor) -> BackendTensor: - w = K.clip(w, 0.0, 1.0) - w /= K.maximum(K.sum(w, 0), K.cast(1.0, dtype=w.dtype))[None] - return w - - -class ClipAlphaAndSumtoOne(Constraint): - """Cosntraints the weights to be between 0 and 1.""" - - def __call__(self, w: BackendTensor) -> BackendTensor: - w = K.clip(w, 0.0, 1.0) - # normalize the first colum to 1 - w_scale = K.maximum(K.sum(w, 0), K.cast(epsilon(), dtype=w.dtype)) - return w / w_scale[:, None, None, None] - - -class MultipleConstraint(Constraint): - """stacking multiple constraints""" - - def __init__(self, constraint_0: Optional[Constraint], constraint_1: Constraint, **kwargs: Any): - super().__init__(**kwargs) - if constraint_0: - self.constraints = [constraint_0, constraint_1] - else: - self.constraints = [constraint_1] - - def __call__(self, w: BackendTensor) -> BackendTensor: - for c in self.constraints: - w = c.__call__(w) - - return w - - -class Project_initializer_pos(Initializer): - """Initializer that generates tensors initialized to 1.""" - - def __init__(self, initializer: Initializer, **kwargs: Any): - super().__init__(**kwargs) - self.initializer = initializer - - def __call__(self, shape: Tuple[Optional[int], ...], dtype: Optional[str] = None, **kwargs: Any) -> BackendTensor: - w = self.initializer.__call__(shape, dtype) - return K.maximum(K.cast(0.0, dtype=dtype), w) - - -class Project_initializer_neg(Initializer): - """Initializer that generates tensors initialized to 1.""" - - def __init__(self, initializer: Initializer, **kwargs: Any): - super().__init__(**kwargs) - self.initializer = initializer - - def __call__(self, shape: Tuple[Optional[int], ...], dtype: Optional[str] = None, **kwargs: Any) -> BackendTensor: - w = self.initializer.__call__(shape, dtype) - return K.minimum(K.cast(0.0, dtype=dtype), w) - - -def softplus_( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - u_c_out = K.softplus(u_c) - l_c_out = K.softplus(l_c) - - if mode in [ForwardMode.AFFINE, ForwardMode.HYBRID]: - w_u_out, b_u_out, w_l_out, b_l_out = get_linear_softplus_hull(upper=u_c, lower=l_c, slope=slope, **kwargs) - b_u_out = w_u_out * b_u + b_u_out - b_l_out = w_l_out * b_l + b_l_out - w_u_out = K.expand_dims(w_u_out, 1) * w_u - w_l_out = K.expand_dims(w_l_out, 1) * w_l - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def sum( - inputs: List[Tensor], - axis: int = -1, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - **kwargs: Any, -) -> List[Tensor]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - ibp = get_ibp(mode) - affine = get_affine(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if ibp: - u_c_out = K.sum(u_c, axis=axis) - l_c_out = K.sum(l_c, axis=axis) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if affine: - if axis == -1: - axis_w = -1 - else: - axis_w = axis + 1 - w_u_out = K.sum(w_u, axis=axis_w) - w_l_out = K.sum(w_l, axis=axis_w) - b_u_out = K.sum(b_u, axis=axis) - b_l_out = K.sum(b_l, axis=axis) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def frac_pos( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - u_c_out = K.cast(1.0, dtype=l_c.dtype) / l_c - l_c_out = K.cast(1.0, dtype=u_c.dtype) / u_c - - if affine: - w_u_0 = (u_c_out - l_c_out) / K.maximum(u_c - l_c, K.cast(epsilon(), dtype=u_c.dtype)) - b_u_0 = l_c_out - w_u_0 * l_c - - y = (u_c + l_c) / 2.0 - b_l_0 = K.cast(2.0, dtype=y.dtype) / y - w_l_0 = -K.cast(1.0, dtype=y.dtype) / y**2 - - w_u_out = w_u_0[:, None] * w_l - b_u_out = b_u_0 * b_l + b_u_0 - w_l_out = w_l_0[:, None] * w_u - b_l_out = b_l_0 * b_u + b_l_0 - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -# convex hull of the maximum between two functions -def max_( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - axis: int = -1, - finetune: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA implementation of max(x, axis) - - Args: - inputs: list of tensors - dc_decomp: boolean that indicates - perturbation_domain: the type of perturbation domain - axis: axis to perform the maximum - whether we return a difference of convex decomposition of our layer - - Returns: - max operation along an axis - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - inputs_outputs_spec_no_dc = InputsOutputsSpec(dc_decomp=False, mode=mode, perturbation_domain=perturbation_domain) - - input_shape = inputs[-1].shape - max_dim = input_shape[axis] - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - empty_tensors_list = [empty_tensor] * max_dim - - if mode == ForwardMode.IBP and not dc_decomp: - u_c_out = K.max(u_c, axis=axis) - l_c_out = K.max(l_c, axis=axis) - - return [u_c_out, l_c_out] - - # do some transpose so that the last axis is also at the end - - if ibp: - u_c_list = K.split(u_c, max_dim, axis) - l_c_list = K.split(l_c, max_dim, axis) - u_c_tmp = u_c_list[0] + 0 * (u_c_list[0]) - l_c_tmp = l_c_list[0] + 0 * (l_c_list[0]) - else: - u_c_tmp, l_c_tmp = empty_tensor, empty_tensor - u_c_list, l_c_list = empty_tensors_list, empty_tensors_list - - if affine: - b_u_list = K.split(b_u, max_dim, axis) - b_l_list = K.split(b_l, max_dim, axis) - b_u_tmp = b_u_list[0] + 0 * (b_u_list[0]) - b_l_tmp = b_l_list[0] + 0 * (b_l_list[0]) - - if axis == -1: - w_u_list = K.split(w_u, max_dim, axis) - w_l_list = K.split(w_l, max_dim, axis) - else: - w_u_list = K.split(w_u, max_dim, axis + 1) - w_l_list = K.split(w_l, max_dim, axis + 1) - w_u_tmp = w_u_list[0] + 0 * (w_u_list[0]) - w_l_tmp = w_l_list[0] + 0 * (w_l_list[0]) - - if finetune: - params = kwargs["finetune_params"] - params_split = [e[0] for e in K.split(params[None], max_dim, axis)] - else: - params_split = [empty_tensor] * max_dim - - else: - w_u_tmp, b_u_tmp, w_l_tmp, b_l_tmp = empty_tensor, empty_tensor, empty_tensor, empty_tensor - w_u_list, b_u_list, w_l_list, b_l_list = ( - empty_tensors_list, - empty_tensors_list, - empty_tensors_list, - empty_tensors_list, - ) - params_split = [False] * max_dim - - h_tmp, g_tmp = None, None - output_tmp = inputs_outputs_spec_no_dc.extract_outputsformode_from_fulloutputs( - [ - x, - u_c_tmp, - w_u_tmp, - b_u_tmp, - l_c_tmp, - w_l_tmp, - b_l_tmp, - h_tmp, - g_tmp, - ] - ) - for i in range(1, max_dim): - output_i = inputs_outputs_spec_no_dc.extract_outputsformode_from_fulloutputs( - [x, u_c_list[i], w_u_list[i], b_u_list[i], l_c_list[i], w_l_list[i], b_l_list[i], None, None] - ) - output_tmp = maximum( - output_tmp, - output_i, - dc_decomp=False, - mode=mode, - finetune=finetune, - finetune_params=params_split[i], - perturbation_domain=perturbation_domain, - ) - - # reduce the dimension - tight = not (mode == ForwardMode.AFFINE) # no need to compute u_c, l_c for pure affine mode - ( - _, - u_c_out, - w_u_out, - b_u_out, - l_c_out, - w_l_out, - b_l_out, - _, - _, - ) = inputs_outputs_spec_no_dc.get_fullinputs_from_inputsformode(output_tmp, tight=tight) - - if mode in [ForwardMode.IBP, ForwardMode.HYBRID]: - u_c_out = K.squeeze(u_c_out, axis) - l_c_out = K.squeeze(l_c_out, axis) - if mode in [ForwardMode.HYBRID, ForwardMode.AFFINE]: - b_u_out = K.squeeze(b_u_out, axis) - b_l_out = K.squeeze(b_l_out, axis) - if axis == -1: - w_u_out = K.squeeze(w_u_out, axis) - w_l_out = K.squeeze(w_l_out, axis) - else: - w_u_out = K.squeeze(w_u_out, axis + 1) - w_l_out = K.squeeze(w_l_out, axis + 1) - - if dc_decomp: - g_out = K.sum(g, axis=axis) - h_out = K.max(h + g, axis=axis) - g_out - else: - g_out, h_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def softmax_to_linear(model: keras.Model) -> Tuple[keras.Model, bool]: - """linearize the softmax layer for verification - - Args: - model: Keras Model - - Returns: - model without the softmax - """ - layer = model.layers[-1] - # check that layer is not an instance of the object Softmax - if isinstance(layer, keras.layers.Softmax): - model_normalize = keras.models.Model(model.get_input_at(0), keras.layers.Activation("linear")(layer.input)) - - return model_normalize, True - - if hasattr(layer, "activation"): - if not layer.get_config()["activation"] == "softmax": - return model, False - layer.get_config()["activation"] = "linear" - layer.activation = keras.activations.get("linear") - return model, True - - return model, False - - -def linear_to_softmax(model: keras.Model) -> Tuple[keras.Model, bool]: - model.layers[-1].activation = keras.activations.get("softmax") - return model - - -def multiply( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, -) -> List[Tensor]: - """LiRPA implementation of (element-wise) multiply(x,y)=-x*y. - - Args: - inputs_0: list of tensors - inputs_1: list of tensors - dc_decomp: boolean that indicates - perturbation_domain: the type of perturbation domain - mode: type of Forward propagation (ibp, affine, or hybrid) - whether we return a difference of convex decomposition of our layer - whether we propagate upper and lower bounds on the values of the gradient - - Returns: - maximum(inputs_0, inputs_1) - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x_0, u_c_0, w_u_0, b_u_0, l_c_0, w_l_0, b_l_0, h_0, g_0 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_0 - ) - x_1, u_c_1, w_u_1, b_u_1, l_c_1, w_l_1, b_l_1, h_1, g_1 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_1 - ) - dtype = x_0.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - # using McCormick's inequalities to derive bounds - # xy<= x_u*y + x*y_L - xU*y_L - # xy<= x*y_u + x_L*y - x_L*y_U - - # xy >=x_L*y + x*y_L -x_L*y_L - # xy >= x_U*y + x*y_U - x_U*y_U - - if ibp: - u_c_0_out = K.maximum(u_c_0 * u_c_1, u_c_0 * l_c_1) + K.maximum(u_c_0 * l_c_1, l_c_0 * l_c_1) - u_c_0 * l_c_1 - u_c_1_out = K.maximum(u_c_1 * u_c_0, u_c_1 * l_c_0) + K.maximum(u_c_1 * l_c_0, l_c_1 * l_c_0) - u_c_1 * l_c_0 - u_c_out = K.minimum(u_c_0_out, u_c_1_out) - l_c_out = K.minimum(l_c_0 * l_c_1, l_c_0 * u_c_1) + K.minimum(l_c_0 * l_c_1, u_c_0 * l_c_1) - l_c_0 * l_c_1 - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if affine: - z_value = K.cast(0.0, dtype=u_c_0.dtype) - # xy <= x_u * y + x * y_L - xU * y_L - cx_u_pos = K.maximum(u_c_0, z_value) - cx_u_neg = K.minimum(u_c_0, z_value) - - cy_l_pos = K.maximum(l_c_1, z_value) - cy_l_neg = K.minimum(l_c_1, z_value) - w_u_out = ( - cx_u_pos[:, None] * w_u_1 - + cx_u_neg[:, None] * w_l_1 - + cy_l_pos[:, None] * w_u_0 - + cy_l_neg[:, None] * w_l_0 - ) - b_u_out = cx_u_pos * b_u_1 + cx_u_neg * b_l_1 + cy_l_pos * b_u_0 + cy_l_neg * b_l_0 - u_c_0 * l_c_1 - - # xy >= x_U*y + x*y_U - x_U*y_U - cx_l_pos = K.maximum(l_c_0, z_value) - cx_l_neg = K.minimum(l_c_0, z_value) - - w_l_out = ( - cx_l_pos[:, None] * w_l_1 - + cx_l_neg[:, None] * w_u_1 - + cy_l_pos[:, None] * w_l_0 - + cy_l_neg[:, None] * w_u_0 - ) - b_l_out = cx_l_pos * b_l_1 + cx_l_neg * b_u_1 + cy_l_pos * b_l_0 + cy_l_neg * b_u_0 - l_c_0 * l_c_1 - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x_0, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def permute_dimensions( - inputs: List[Tensor], - axis: int, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - axis_perm: int = 1, - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, -) -> List[Tensor]: - """LiRPA implementation of (element-wise) permute(x,axis) - - Args: - inputs: list of input tensors - axis: axis on which we apply the permutation - mode: type of Forward propagation (ibp, affine, or hybrid) - axis_perm: see DecomonPermute operator - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - input_shape = inputs_outputs_spec.get_kerasinputshape(inputs) - if len(input_shape) <= 2: - # not enough dim to permute - return inputs - index_np = np.arange(len(input_shape)) - index = tuple(np.insert(np.delete(index_np, axis), axis_perm, axis)) - index_w_np = np.arange(len(input_shape) + 1) - index_w = tuple(np.insert(np.delete(index_w_np, axis), axis_perm + 1, axis)) - - if ibp: - u_c_out = K.transpose(u_c, index) - l_c_out = K.transpose(l_c, index) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - if affine: - w_u_out = K.transpose(w_u, index_w) - b_u_out = K.transpose(b_u, index) - w_l_out = K.transpose(w_l, index_w) - b_l_out = K.transpose(b_l, index) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def broadcast( - inputs: List[Tensor], - n: int, - axis: int, - mode: Union[str, ForwardMode], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, -) -> List[Tensor]: - """LiRPA implementation of broadcasting - - Args: - inputs - n - axis - mode - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if ibp: - for _ in range(n): - u_c = K.expand_dims(u_c, axis) - l_c = K.expand_dims(l_c, axis) - - if axis != -1: - axis_w = axis + 1 - else: - axis_w = -1 - - if affine: - for _ in range(n): - b_u = K.expand_dims(b_u, axis) - b_l = K.expand_dims(b_l, axis) - w_u = K.expand_dims(w_u, axis_w) - w_l = K.expand_dims(w_l, axis_w) - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs([x, u_c, w_u, b_u, l_c, w_l, b_l, h, g]) - - -def split( - inputs: List[Tensor], - axis: int = -1, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, -) -> List[List[Tensor]]: - """LiRPA implementation of split - - Args: - inputs - axis - mode - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - input_shape = inputs_outputs_spec.get_kerasinputshape(inputs) - if axis == -1: - n = input_shape[-1] - axis = len(input_shape) - 1 - else: - n = input_shape[axis] - if n is None: - raise ValueError(f"Dimension {axis} corresponding to `axis` cannot be None") - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - empty_tensor_list = [empty_tensor] * n - - if ibp: - u_c_list = K.split(u_c, num_or_size_splits=n, axis=axis) - l_c_list = K.split(l_c, num_or_size_splits=n, axis=axis) - else: - u_c_list, l_c_list = empty_tensor_list, empty_tensor_list - - if affine: - b_u_list = K.split(b_u, num_or_size_splits=n, axis=axis) - b_l_list = K.split(b_l, num_or_size_splits=n, axis=axis) - - if axis != -1: - axis += 1 - w_u_list = K.split(w_u, num_or_size_splits=n, axis=axis) - w_l_list = K.split(w_l, num_or_size_splits=n, axis=axis) - else: - w_u_list, b_u_list, w_l_list, b_l_list = ( - empty_tensor_list, - empty_tensor_list, - empty_tensor_list, - empty_tensor_list, - ) - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_list, g_list = empty_tensor_list, empty_tensor_list - - return [ - inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_list[i], w_u_list[i], b_u_list[i], l_c_list[i], w_l_list[i], b_l_list[i], h_list[i], g_list[i]] - ) - for i in range(n) - ] - - -def sort( - inputs: List[Tensor], - axis: int = -1, - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, -) -> List[Tensor]: - """LiRPA implementation of sort by selection - - Args: - inputs - axis - dc_decomp - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - input_shape = inputs_outputs_spec.get_kerasinputshape(inputs) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if axis == -1: - n = input_shape[-1] - axis = len(input_shape) - 1 - else: - n = input_shape[axis] - if n is None: - raise ValueError(f"Dimension {axis} corresponding to `axis` cannot be None") - - empty_tensor_list = [empty_tensor] * n - - # what about splitting elements - op_split = lambda x: K.split(x, n, axis=axis) - if ibp: - u_c_list = op_split(u_c) - l_c_list = op_split(l_c) - else: - u_c_list, l_c_list = empty_tensor_list, empty_tensor_list - - if affine: - w_u_list = K.split(w_u, n, axis=axis + 1) - b_u_list = op_split(b_u) - w_l_list = K.split(w_l, n, axis=axis + 1) - b_l_list = op_split(b_l) - else: - w_u_list, b_u_list, w_l_list, b_l_list = ( - empty_tensor_list, - empty_tensor_list, - empty_tensor_list, - empty_tensor_list, - ) - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_list, g_list = empty_tensor_list, empty_tensor_list - - # use selection sort - for i in range(n - 1): - for j in range(i + 1, n): - input_i = inputs_outputs_spec.extract_inputsformode_from_fullinputs( - [x, u_c_list[i], w_u_list[i], b_u_list[i], l_c_list[i], w_l_list[i], b_l_list[i], h_list[i], g_list[i]] - ) - input_j = inputs_outputs_spec.extract_inputsformode_from_fullinputs( - [x, u_c_list[j], w_u_list[j], b_u_list[j], l_c_list[j], w_l_list[j], b_l_list[j], h_list[j], g_list[j]] - ) - - # max and min of splitted inputs - output_a = maximum( - input_i, input_j, mode=mode, perturbation_domain=perturbation_domain, dc_decomp=dc_decomp - ) - output_b = minimum( - input_i, input_j, mode=mode, perturbation_domain=perturbation_domain, dc_decomp=dc_decomp - ) - - # update lists - ( - x, - u_c_list[j], - w_u_list[j], - b_u_list[j], - l_c_list[j], - w_l_list[j], - b_l_list[j], - h_list[j], - g_list[j], - ) = inputs_outputs_spec.get_fullinputs_from_inputsformode(output_a) - ( - x, - u_c_list[i], - w_u_list[i], - b_u_list[i], - l_c_list[i], - w_l_list[i], - b_l_list[i], - h_list[i], - g_list[i], - ) = inputs_outputs_spec.get_fullinputs_from_inputsformode(output_b) - - op_concatenate = lambda x: K.concatenate(x, axis) - # update the inputs - if ibp: - u_c_out = op_concatenate(u_c_list) - l_c_out = op_concatenate(l_c_list) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - if affine: - w_u_out = K.concatenate(w_u_list, axis + 1) - w_l_out = K.concatenate(w_l_list, axis + 1) - b_u_out = op_concatenate(b_u_list) - b_l_out = op_concatenate(b_l_list) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def pow( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, -) -> List[Tensor]: - """LiRPA implementation of pow(x )=x**2 - - Args: - inputs - dc_decomp - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - return multiply(inputs, inputs, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode) - - -def abs( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, -) -> List[Tensor]: - """LiRPA implementation of |x| - - Args: - inputs - dc_decomp - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - inputs_0 = relu_(inputs, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode) - inputs_1 = minus( - relu_( - minus(inputs, mode=mode, perturbation_domain=perturbation_domain), - dc_decomp=dc_decomp, - perturbation_domain=perturbation_domain, - mode=mode, - ), - mode=mode, - perturbation_domain=perturbation_domain, - ) - - return add(inputs_0, inputs_1, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode) - - -def frac_pos_hull( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, -) -> List[Tensor]: - """LiRPA implementation of 1/x for x>0 - - Args: - inputs - dc_decomp - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - - l_c = K.maximum(l_c, K.cast(1.0, dtype=l_c.dtype)) - z = (u_c + l_c) / 2.0 - w_l = -K.cast(1.0, dtype=z.dtype) / K.power(z) - b_l = K.cast(2.0, dtype=z.dtype) / z - w_u = (K.cast(1.0, dtype=u_c.dtype) / u_c - K.cast(1.0, dtype=l_c.dtype) / l_c) / (u_c - l_c) - b_u = K.cast(1.0, dtype=u_c.dtype) / u_c - w_u * u_c - - return [w_u, b_u, w_l, b_l] - - -# convex hull for min -def min_( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - axis: int = -1, - finetune: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA implementation of min(x, axis=axis) - - Args: - inputs - dc_decomp - perturbation_domain - mode - axis - - Returns: - - """ - # return - max - x - if perturbation_domain is None: - perturbation_domain = BoxDomain() - return minus( - max_( - minus(inputs, mode=mode, perturbation_domain=perturbation_domain), - dc_decomp=dc_decomp, - perturbation_domain=perturbation_domain, - mode=mode, - axis=axis, - finetune=finetune, - **kwargs, - ), - mode=mode, - perturbation_domain=perturbation_domain, - ) - - -def expand_dims( - inputs: List[Tensor], - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - axis: int = -1, - perturbation_domain: Optional[PerturbationDomain] = None, - **kwargs: Any, -) -> List[Tensor]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if axis == -1: - axis_w = axis - else: - axis_w = axis + 1 - - op = lambda t: K.expand_dims(t, axis) - - if ibp: - u_c_out = op(u_c) - l_c_out = op(l_c) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if affine: - b_u_out = op(b_u) - b_l_out = op(b_l) - w_u_out = K.expand_dims(w_u, axis_w) - w_l_out = K.expand_dims(w_l, axis_w) - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def log( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - **kwargs: Any, -) -> List[Tensor]: - """Exponential activation function. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: the type of convex input domain - mode: type of Forward propagation (ibp, affine, or hybrid) - **kwargs: extra parameters - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if mode == ForwardMode.AFFINE: - l_c = K.maximum(K.cast(epsilon(), dtype=l_c.dtype), l_c) - - u_c_out = K.log(u_c) - l_c_out = K.log(l_c) - - if affine: - y = (u_c + l_c) / 2.0 - - w_l_0 = (u_c_out - l_c_out) / K.maximum(u_c - l_c, K.cast(epsilon(), dtype=u_c.dtype)) - b_l_0 = l_c_out - w_l_0 * l_c - - w_u_0 = K.cast(1, dtype=y.dtype) / y - b_u_0 = K.log(y) - 1 - - w_u_out = w_u_0[:, None] * w_u - b_u_out = w_u_0 * b_u + b_u_0 - w_l_out = w_l_0[:, None] * w_l - b_l_out = w_l_0 * b_l + b_l_0 - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def exp( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - """Exponential activation function. - - Args: - inputs: list of input tensors - dc_decomp: boolean that indicates - perturbation_domain: the type of convex input domain - mode: type of Forward propagation (ibp, affine, or hybrid) - slope: - **kwargs: extra parameters - whether we return a difference of convex decomposition of our layer - - Returns: - the updated list of tensors - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - u_c_out = K.exp(u_c) - l_c_out = K.exp(l_c) - - if affine: - y = (u_c + l_c) / 2.0 # do finetuneting - - w_u_0 = (u_c_out - l_c_out) / K.maximum(u_c - l_c, K.cast(epsilon(), dtype=u_c.dtype)) - b_u_0 = l_c_out - w_u_0 * l_c - - w_l_0 = K.exp(y) - b_l_0 = w_l_0 * (-y + 1) - - w_u_out = w_u_0[:, None] * w_u - b_u_out = w_u_0 * b_u + b_u_0 - w_l_out = w_l_0[:, None] * w_l - b_l_out = w_l_0 * b_l + b_l_0 - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - if dc_decomp: - raise NotImplementedError("Not yet implemented for dc_decomp=True") - else: - h_out, g_out = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) diff --git a/src/decomon/layers/utils/__init__.py b/src/decomon/layers/utils/__init__.py new file mode 100644 index 00000000..576238d8 --- /dev/null +++ b/src/decomon/layers/utils/__init__.py @@ -0,0 +1 @@ +"""Utilitary layers for decomon.""" diff --git a/src/decomon/layers/utils/batchsize.py b/src/decomon/layers/utils/batchsize.py new file mode 100644 index 00000000..1ab645f8 --- /dev/null +++ b/src/decomon/layers/utils/batchsize.py @@ -0,0 +1,52 @@ +"""Adding batchsize to batch-independent outputs.""" + + +from typing import Optional + +import keras.ops as K +from keras.layers import Layer + +from decomon.types import BackendTensor + + +class InsertBatchAxis(Layer): + """Layer adding a batch axis to its inputs. + + It accepts a list of tensors as its inputs. The convention is that: + - The first tensor contains the batch axis (this we can deduce from it the batchsize) + - The following tensors may miss or not this batch axis + + It returns the tensors, except the first ones, + with a new first axis repeated to the proper batchsize when it was missing. + + """ + + def call(self, inputs: list[BackendTensor]) -> list[BackendTensor]: + batchsize = inputs[0].shape[0] + + outputs = [ + K.repeat(input_i[None], batchsize, axis=0) if missing_batchsize_i else input_i + for input_i, missing_batchsize_i in zip(inputs[1:], self.missing_batchaxis[1:]) + ] + + return outputs + + def build(self, input_shape: list[tuple[Optional[int], ...]]) -> None: + # called on list? + if not isinstance(input_shape[0], (tuple, list)): + raise ValueError( + "An InsertBatchAxis layer should be called on a list of inputs. " + f"Received: input_shape={input_shape} (not a list of shapes)" + ) + # check first input has a batch axis + if None not in input_shape[0]: + raise ValueError("The first tensor in InsertBatchAxis inputs is supposed to have a batch axis.") + # store the input indices missing the batchaxis + self.missing_batchaxis = [None not in input_shape_i for input_shape_i in input_shape] + self.built = True + + def compute_output_shape(self, input_shape: list[tuple[Optional[int], ...]]) -> list[tuple[Optional[int], ...]]: + return [ + (None,) + input_shape_i if missing_bacthaxis_i else input_shape_i + for input_shape_i, missing_bacthaxis_i in zip(input_shape[1:], self.missing_batchaxis[1:]) + ] diff --git a/src/decomon/layers/utils/symbolify.py b/src/decomon/layers/utils/symbolify.py new file mode 100644 index 00000000..b33dda4b --- /dev/null +++ b/src/decomon/layers/utils/symbolify.py @@ -0,0 +1,28 @@ +"""Converting backend tensors to symbolic tensors.""" + +from typing import Optional + +from keras.layers import Layer + +from decomon.types import BackendTensor + + +class LinkToPerturbationDomainInput(Layer): + """Layer making its inputs artificially depend on the first one. + + It accepts a list of tensors as its inputs. The convention is that: + - The first tensor is the tensor we want the other to depend on, will be dropped in the output + - The following tensors will be returned as is + + It returns the tensors, except the first one. + + The usecase is to be able to create a DecomonModel even when the propagated bounds does not really + depend on the perturbation input. + + """ + + def call(self, inputs: list[BackendTensor]) -> list[BackendTensor]: + return inputs[1:] + + def compute_output_shape(self, input_shape: list[tuple[Optional[int], ...]]) -> list[tuple[Optional[int], ...]]: + return input_shape[1:] diff --git a/src/decomon/layers/utils_pooling.py b/src/decomon/layers/utils_pooling.py deleted file mode 100644 index c1e7cf9b..00000000 --- a/src/decomon/layers/utils_pooling.py +++ /dev/null @@ -1,195 +0,0 @@ -from typing import Any, Dict, List, Optional, Union - -import keras.ops as K -import numpy as np - -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - get_affine, -) -from decomon.keras_utils import LinalgSolve -from decomon.types import BackendTensor, Tensor - -# step 1: compute (x_i, y_i) such that x_i[j]=l_j if j==i else u_j -# dataset of size n+1 on which we can compute an affine bound - - -def get_upper_linear_hull_max( - inputs: List[Tensor], - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - axis: int = -1, - dc_decomp: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """Compute the linear hull that overapproximates max along the axis dimension - - Args: - inputs: list of input tensors - mode: type of Forward propagation (ibp, affine, or hybrid). Default to hybrid. - perturbation_domain (optional): type of perturbation domain that encompass the set of perturbations. Defaults to None. - axis (optional): Defaults to -1. See Keras offical documentation backend.max(., axis) - - Raises: - NotImplementedError: axis <0 and axis!=-1 - - Returns: - list of output tensors. The upper linear relaxation of max(., axis) in the mode format - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - input_shape = inputs_outputs_spec.get_kerasinputshape(inputs) - - # attention if axis=-1 or axis=n - dtype32 = "float32" - o_value = K.cast(1.0, dtype) - z_value = K.cast(0.0, dtype) - if axis == len(input_shape): - axis = -1 - if axis != -1 and axis < 0: - raise NotImplementedError() # to do - - # get the shape of the dimension - n_dim = input_shape[axis] - if n_dim is None: - raise ValueError(f"Dimension {axis} corresponding to `axis` cannot be None") - - # expand dim/broadcast - mask = K.identity(n_dim, dtype=dtype) # (n_dim, n_dim) - mask_shape = [1] * (len(u_c.shape) + 1) - mask_shape[-1] = n_dim - if axis != -1: - mask_shape[axis] = n_dim - else: - mask_shape[-2] = n_dim - mask = K.reshape(mask, mask_shape) # (1, shape, n_dim, n_dim) - - l_c_reshaped = K.expand_dims(l_c, -1) # (1, shape, n_dim, 1) - u_c_reshaped = K.expand_dims(u_c, -1) # (1, shape, n_dim, 1) - - # detect collapsed dimensions: lower[i]==upper[i] - index_collapse = o_value - K.sign(l_c_reshaped - u_c_reshaped) - - corners = mask * l_c_reshaped + (o_value - mask) * (u_c_reshaped) # (1, shape, n_dim, n_dim) - corners_collapse = mask * l_c_reshaped + (o_value - mask) * (u_c_reshaped + index_collapse) - # add the corners containing all the upper bounds - corners_collapse = K.concatenate( - [corners_collapse, u_c_reshaped + index_collapse], axis=-1 - ) # (None, shape, n_dim, n_dim+1) - corners = K.concatenate([corners, u_c_reshaped], axis=-1) - - if axis != -1: - corners_pred = K.max(corners, axis=axis) # (None, shape_, n_dim+1) - else: - corners_pred = K.max(corners, axis=-2) # (None, shape_, n_dim+1) - - # include bias in corners - if axis != -1: - bias_corner = o_value + K.sum(z_value * corners, axis, keepdims=True) - corners_collapse = K.concatenate([corners_collapse, bias_corner], axis=axis) # (None, shape_, n_dim+1, n_dim+1) - - else: - bias_corner = o_value + K.sum(z_value * corners, -2, keepdims=True) - corners_collapse = K.concatenate([corners_collapse, bias_corner], axis=-2) - - dimensions = list(range(len(corners.shape))) - if axis != -1: - dim_permutation = dimensions[:axis] + dimensions[axis + 1 :] + [dimensions[axis]] - else: - dim_permutation = dimensions[:-2] + dimensions[-1:] + [dimensions[-2]] - - corners_collapse = K.transpose(corners_collapse, dim_permutation) - # tf.linalg.solve works only for float32 - if dtype != dtype32: - corners_collapse = K.cast(corners_collapse, dtype32) - corners_pred = K.cast(corners_pred, dtype32) - w_hull = LinalgSolve()(matrix=corners_collapse, rhs=K.expand_dims(corners_pred, -1)) # (None, shape_, n_dim+1, 1) - - if dtype != dtype32: - w_hull = K.cast(w_hull, dtype=dtype) - - shape_prev = int(np.prod(inputs[-1].shape[1:axis])) - if axis == -1: - shape_after = 1 - else: - shape_after = int(np.prod(inputs[-1].shape[axis + 1 :])) - # (-1, shape_prev, axis, shape_after, n_dim+1) - flatten_dim = int(np.prod(w_hull.shape[1:-2])) - w_hull_flat = K.reshape(w_hull, (-1, flatten_dim, n_dim + 1)) - w_u = K.reshape(w_hull_flat[:, :, :-1], (-1, shape_prev, shape_after, n_dim)) # (-1, shape_, n_dim) - w_u = K.reshape(K.transpose(w_u, (0, 1, 3, 2)), [-1] + list(inputs[-1].shape[1:])) - # reshape w_u - shape_max = K.max(inputs[-1], axis).shape[1:] - b_u = K.reshape(w_hull_flat[:, :, -1], [-1] + list(shape_max)) # (-1, shape_) - - return [w_u, b_u] - - -def get_lower_linear_hull_max( - inputs: List[Tensor], - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, - axis: int = -1, - finetune_lower: Optional[BackendTensor] = None, - dc_decomp: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """Compute the linear hull that overapproximates max along the axis dimension - - Args: - inputs: list of input tensors - mode: type of Forward propagation (ibp, affine, or hybrid). Default to hybrid. - perturbation_domain (optional): type of perturbation domain that encompass the set of perturbations. Defaults to None. - axis (optional): Defaults to -1. See Keras offical documentation backend.max(., axis) - finetune_lower: If not None, should be a constant tensor used to fine tune the lower relaxation. - - Raises: - NotImplementedError: axis <0 and axis!=-1 - - Returns: - list of output tensors. The lower linear relaxation of max(., axis) in the mode format - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - affine = get_affine(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - input_shape = inputs_outputs_spec.get_kerasinputshape(inputs) - - o_value = K.cast(1.0, dtype) - z_value = K.cast(0.0, dtype) - if axis == len(input_shape): - axis = -1 - if axis != -1 and axis < 0: - raise NotImplementedError() # to do - - V = u_c + l_c - M = -u_c + K.expand_dims(K.max(l_c, axis), axis) - # M>=0 means that the associated coordinate cannot be an argmax - # we can use M to add a penalty to the index that cannot be an argmax - - V += K.expand_dims(K.max(K.abs(V), axis), axis) * (K.sign(M) + o_value) - - w_l = K.one_hot(K.argmin(V, axis), num_classes=V.shape[axis], axis=axis, dtype=dtype) - b_l = K.sum(z_value * w_l, axis) - # without finetuning: consider a one hot vector with value one at index=argmin(V) - - if finetune_lower is not None: - alpha = finetune_lower[None] - y = alpha * u_c + (o_value - alpha) * l_c - w_l_alpha = K.one_hot(K.argmax(y, axis), num_classes=V.shape[axis], axis=axis, dtype=dtype) - w_l = (o_value - alpha) * w_l + alpha * w_l_alpha - - return [w_l, b_l] diff --git a/src/decomon/metrics/__init__.py b/src/decomon/metrics/__init__.py deleted file mode 100644 index 4ad5389d..00000000 --- a/src/decomon/metrics/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .loss import ( - build_asymptotic_crossentropy_model, - build_crossentropy_model, - build_radius_robust_model, -) -from .metric import ( - build_formal_adv_check_model, - build_formal_adv_model, - build_formal_upper_model, -) diff --git a/src/decomon/metrics/complexity.py b/src/decomon/metrics/complexity.py deleted file mode 100644 index fcdeaf2d..00000000 --- a/src/decomon/metrics/complexity.py +++ /dev/null @@ -1,19 +0,0 @@ -import keras -from keras.layers import InputLayer, Lambda - - -def get_graph_complexity(model: keras.Model) -> int: - # do not consider Input nodes or Lambda nodes - # enumerate the number of - nb_nodes = 0 - depth_keys = list(model._nodes_by_depth.keys()) - - for depth in depth_keys: - nodes = model._nodes_by_depth[depth] - for node in nodes: - if isinstance(node.operation, (InputLayer, Lambda)): - continue - - nb_nodes += 1 - - return nb_nodes diff --git a/src/decomon/metrics/loss.py b/src/decomon/metrics/loss.py deleted file mode 100644 index aa4d00e6..00000000 --- a/src/decomon/metrics/loss.py +++ /dev/null @@ -1,638 +0,0 @@ -from typing import Any, Callable, Dict, List, Optional, Tuple, Union - -import keras -import keras.ops as K -import numpy as np -from keras.config import epsilon -from keras.layers import Lambda, Layer - -from decomon.core import ( - BallDomain, - BoxDomain, - ForwardMode, - PerturbationDomain, - get_mode, -) -from decomon.layers.activations import softmax as softmax_ -from decomon.layers.core import DecomonLayer -from decomon.models.models import DecomonModel -from decomon.models.utils import Convert2Mode -from decomon.types import BackendTensor, Tensor - - -def get_model(model: DecomonModel) -> DecomonModel: - ibp = model.ibp - affine = model.affine - - mode = get_mode(ibp, affine) - perturbation_domain = model.perturbation_domain - - inputs = model.inputs - outputs = model.outputs - new_output: keras.KerasTensor - - if mode == ForwardMode.IBP: - - def func(outputs: List[Tensor]) -> Tensor: - u_c, l_c = outputs - return K.concatenate([K.expand_dims(u_c, -1), K.expand_dims(l_c, -1)], -1) - - new_output = Lambda(func)(outputs) - - elif mode == ForwardMode.AFFINE: - - def func(outputs: List[Tensor]) -> Tensor: - x_0, w_u, b_u, w_l, b_l = outputs - if len(x_0.shape) == 2: - x_0_reshaped = x_0[:, :, None] - else: - x_0_reshaped = K.transpose(x_0, (0, 2, 1)) - x_fake = K.sum(x_0_reshaped, 1)[:, None] - x_0_reshaped = K.concatenate([x_0_reshaped, x_fake], 1) # (None, n_in+1, n_comp) - - w_b_u = K.concatenate([w_u, b_u[:, None]], 1) # (None, n_in+1, n_out) - w_b_l = K.concatenate([w_l, b_l[:, None]], 1) - - w_b = K.concatenate([w_b_u, w_b_l], -1) # (None, n_in+1, 2*n_out) - return K.concatenate([x_0_reshaped, w_b], -1) # (None, n_in+1, n_comp+2*n_out) - - new_output = Lambda(func)(outputs) - - elif mode == ForwardMode.HYBRID: - - def func(outputs: List[Tensor]) -> Tensor: - x_0, u_c, w_u, b_u, l_c, w_l, b_l = outputs - - if len(x_0.shape) == 2: - x_0_reshaped = x_0[:, :, None] - else: - x_0_reshaped = K.transpose(x_0, (0, 2, 1)) - x_fake = K.sum(x_0_reshaped, 1)[:, None] - x_0_reshaped = K.concatenate([x_0_reshaped, x_fake, x_fake], 1) # (None, n_in+2, n_comp) - - w_b_u = K.concatenate([w_u, b_u[:, None]], 1) # (None, n_in+1, n_out) - w_b_l = K.concatenate([w_l, b_l[:, None]], 1) - - w_b = K.concatenate([w_b_u, w_b_l], -1) # (None, n_in+1, 2*n_out) - - u_l = K.concatenate([u_c, l_c], 1)[:, None] # (None, 1, 2*n_out) - - w_b_u_l = K.concatenate([w_b, u_l], 1) # (None, n_in+2, 2*n_out) - - return K.concatenate([x_0_reshaped, w_b_u_l], -1) # (None, n_in+1, n_comp+2*n_out) - - new_output = Lambda(func)(outputs) - - else: - raise ValueError(f"Unknown mode {mode}") - - return DecomonModel( - inputs=inputs, - outputs=new_output, - perturbation_domain=model.perturbation_domain, - ibp=ibp, - affine=affine, - finetune=model.finetune, - ) - - -def get_upper_loss(model: DecomonModel) -> Callable[[Tensor, Tensor], Tensor]: - ibp = model.ibp - affine = model.affine - - mode = get_mode(ibp, affine) - perturbation_domain = model.perturbation_domain - - n_comp = perturbation_domain.get_nb_x_components() - n_out = np.prod(model.output[-1].shape[1:]) - - def upper_ibp(u_c: Tensor, u_ref: Tensor) -> Tensor: - # minimize the upper bound compared to the reference - return K.max(u_c - u_ref, -1) - - def upper_affine(x: Tensor, w_u: Tensor, b_u: Tensor, u_ref: Tensor) -> Tensor: - upper = perturbation_domain.get_upper(x, w_u, b_u) - - return K.max(upper - u_ref, -1) - - def loss_upper(y_true: Tensor, y_pred: Tensor) -> Tensor: - if mode == ForwardMode.IBP: - u_c = y_pred[:, :, 0] - - elif mode == ForwardMode.AFFINE: - if len(y_pred.shape) == 3: - x_0 = K.transpose(y_pred[:, :-1, :n_comp], (0, 2, 1)) - else: - x_0 = y_pred[:, :-1, 0] - - w_u = y_pred[:, :-1, n_comp : n_comp + n_out] - b_u = y_pred[:, -1, n_comp : n_comp + n_out] - - elif mode == ForwardMode.HYBRID: - if len(y_pred.shape) == 3: - x_0 = K.transpose(y_pred[:, :-2, :n_comp], (0, 2, 1)) - else: - x_0 = y_pred[:, :-2, 0] - - w_u = y_pred[:, :-2, n_comp : n_comp + n_out] - b_u = y_pred[:, -2, n_comp : n_comp + n_out] - u_c = y_pred[:, -1, n_comp : n_comp + n_out] - - else: - raise ValueError(f"Unknown mode {mode}") - - if ibp: - score_ibp = upper_ibp(u_c, y_true) - if affine: - score_affine = upper_affine(x_0, w_u, b_u, y_true) - - if mode == ForwardMode.IBP: - return K.mean(score_ibp) - elif mode == ForwardMode.AFFINE: - return K.mean(score_affine) - elif mode == ForwardMode.HYBRID: - return K.mean(K.minimum(score_ibp, score_affine)) - - raise NotImplementedError() - - return loss_upper - - -def get_lower_loss(model: DecomonModel) -> Callable[[Tensor, Tensor], Tensor]: - ibp = model.ibp - affine = model.affine - - mode = get_mode(ibp, affine) - perturbation_domain = model.perturbation_domain - - n_comp = perturbation_domain.get_nb_x_components() - n_out = np.prod(model.output[-1].shape[1:]) - - def lower_ibp(l_c: Tensor, l_ref: Tensor) -> Tensor: - # minimize the upper bound compared to the reference - return K.max(l_ref - l_c, -1) - - def lower_affine(x: Tensor, w_l: Tensor, b_l: Tensor, l_ref: Tensor) -> Tensor: - lower = perturbation_domain.get_lower(x, w_l, b_l) - - return K.max(l_ref - lower, -1) - - def loss_lower(y_true: Tensor, y_pred: Tensor) -> Tensor: - if mode == ForwardMode.IBP: - l_c = y_pred[:, :, 1] - - elif mode == ForwardMode.AFFINE: - if len(y_pred.shape) == 3: - x_0 = K.transpose(y_pred[:, :-1, :n_comp], (0, 2, 1)) - else: - x_0 = y_pred[:, :-1, 0] - - w_l = y_pred[:, :-1, n_comp + n_out :] - b_l = y_pred[:, -1, n_comp + n_out :] - - elif mode == ForwardMode.HYBRID: - if len(y_pred.shape) == 3: - x_0 = K.transpose(y_pred[:, :-2, :n_comp], (0, 2, 1)) - else: - x_0 = y_pred[:, :-2, 0] - - w_l = y_pred[:, :-2, n_comp + n_out :] - b_l = y_pred[:, -2, n_comp + n_out :] - l_c = y_pred[:, -1, n_comp + n_out :] - - else: - raise ValueError(f"Unknown mode {mode}") - - if ibp: - score_ibp = lower_ibp(l_c, y_true) - if affine: - score_affine = lower_affine(x_0, w_l, b_l, y_true) - - if mode == ForwardMode.IBP: - return K.mean(score_ibp) - elif mode == ForwardMode.AFFINE: - return K.mean(score_affine) - elif mode == ForwardMode.HYBRID: - return K.mean(K.minimum(score_ibp, score_affine)) - - raise NotImplementedError() - - return loss_lower - - -def get_adv_loss( - model: DecomonModel, sigmoid: bool = False, clip_value: Optional[float] = None, softmax: bool = False -) -> Callable[[Tensor, Tensor], Tensor]: - ibp = model.ibp - affine = model.affine - - mode = get_mode(ibp, affine) - perturbation_domain = model.perturbation_domain - - n_comp = perturbation_domain.get_nb_x_components() - n_out = np.prod(model.output[-1].shape[1:]) - - def adv_ibp(u_c: Tensor, l_c: Tensor, y_tensor: Tensor) -> Tensor: - t_tensor: Tensor = 1 - y_tensor - s_tensor = y_tensor - - t_tensor = t_tensor[:, :, None] - s_tensor = s_tensor[:, None, :] - M = t_tensor * s_tensor - upper = K.expand_dims(u_c, -1) - K.expand_dims(l_c, 1) - const = (K.max(upper, (-1, -2)) - K.min(upper, (-1, -2)))[:, None, None] - upper = upper - (const + K.cast(1, const.dtype)) * (1 - M) - return K.max(upper, (-1, -2)) - - def adv_affine( - x: Tensor, - w_u: Tensor, - b_u: Tensor, - w_l: Tensor, - b_l: Tensor, - y_tensor: Tensor, - ) -> Tensor: - w_u_reshaped = K.expand_dims(w_u, -1) - w_l_reshaped = K.expand_dims(w_l, -2) - - w_adv = w_u_reshaped - w_l_reshaped - b_adv = K.expand_dims(b_u, -1) - K.expand_dims(b_l, 1) - - upper = perturbation_domain.get_upper(x, w_adv, b_adv) - - t_tensor: Tensor = 1 - y_tensor - s_tensor = y_tensor - - t_tensor = t_tensor[:, :, None] - s_tensor = s_tensor[:, None, :] - M = t_tensor * s_tensor - - const = (K.max(upper, (-1, -2)) - K.min(upper, (-1, -2)))[:, None, None] - upper = upper - (const + K.cast(1, const.dtype)) * (1 - M) - return K.max(upper, (-1, -2)) - - def loss_adv(y_true: Tensor, y_pred: Tensor) -> Tensor: - if mode == ForwardMode.IBP: - u_c = y_pred[:, :, 0] - l_c = y_pred[:, :, 1] - - if softmax: - u_c, l_c = softmax_([u_c, l_c], mode=mode, perturbation_domain=perturbation_domain, clip=False) - - elif mode == ForwardMode.AFFINE: - if len(y_pred.shape) == 3: - x_0 = K.transpose(y_pred[:, :-1, :n_comp], (0, 2, 1)) - else: - x_0 = y_pred[:, :-1, 0] - - w_u = y_pred[:, :-1, n_comp : n_comp + n_out] - b_u = y_pred[:, -1, n_comp : n_comp + n_out] - w_l = y_pred[:, :-1, n_comp + n_out :] - b_l = y_pred[:, -1, n_comp + n_out :] - - if softmax: - _, w_u, b_u, w_l, b_l = softmax_( - [x_0, w_u, b_u, w_l, b_l], mode=mode, perturbation_domain=perturbation_domain, clip=False - ) - - elif mode == ForwardMode.HYBRID: - if len(y_pred.shape) == 3: - x_0 = K.transpose(y_pred[:, :-2, :n_comp], (0, 2, 1)) - else: - x_0 = y_pred[:, :-2, 0] - - w_u = y_pred[:, :-2, n_comp : n_comp + n_out] - b_u = y_pred[:, -2, n_comp : n_comp + n_out] - w_l = y_pred[:, :-2, n_comp + n_out :] - b_l = y_pred[:, -2, n_comp + n_out :] - u_c = y_pred[:, -1, n_comp : n_comp + n_out] - l_c = y_pred[:, -1, n_comp + n_out :] - - _, u_c, w_u, b_u, l_c, w_l, b_l = softmax_( - [x_0, u_c, w_u, b_u, l_c, w_l, b_l], mode=mode, perturbation_domain=perturbation_domain, clip=False - ) - - else: - raise ValueError(f"Unknown mode {mode}") - - if ibp: - score_ibp = adv_ibp(u_c, l_c, y_true) - if clip_value is not None: - score_ibp = K.maximum(score_ibp, K.cast(clip_value, dtype=score_ibp.dtype)) - if affine: - score_affine = adv_affine(x_0, w_u, b_u, w_l, b_l, y_true) - if clip_value is not None: - score_affine = K.maximum(score_affine, K.cast(clip_value, dtype=score_affine.dtype)) - - if mode == ForwardMode.IBP: - if sigmoid: - return K.mean(K.sigmoid(score_ibp)) - else: - return K.mean(score_ibp) - elif mode == ForwardMode.AFFINE: - if sigmoid: - return K.mean(K.sigmoid(score_affine)) - else: - return K.mean(score_affine) - elif mode == ForwardMode.HYBRID: - if sigmoid: - return K.mean(K.sigmoid(K.minimum(score_ibp, score_affine))) - else: - return K.mean(K.minimum(score_ibp, score_affine)) - - raise NotImplementedError() - - return loss_adv - - -def _create_identity_tensor_like(x: Tensor) -> BackendTensor: - identity_tensor = K.identity(x.shape[-1]) - n_repeat = int(np.prod(x.shape[:-1])) - return K.reshape(K.repeat(identity_tensor[None], n_repeat, axis=0), tuple(x.shape) + (-1,)) - - -# create a layer -class DecomonLossFusion(DecomonLayer): - original_keras_layer_class = Layer - - def __init__( - self, - asymptotic: bool = False, - backward: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - self.convert2mode_layer = Convert2Mode( - mode_from=mode, - mode_to=ForwardMode.IBP, - perturbation_domain=self.perturbation_domain, - ) - self.asymptotic = asymptotic - self.backward = backward - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "asymptotic": self.asymptotic, - "backward": self.backward, - } - ) - return config - - def call_no_backward(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - if not self.asymptotic: - u_c, l_c = self.convert2mode_layer(inputs) - - return -l_c + K.log(K.sum(K.exp(u_c - K.max(u_c, -1)[:, None]), -1))[:, None] + K.max(u_c, -1)[:, None] - - else: - u_c, l_c = self.convert2mode_layer(inputs) - shape = u_c.shape[-1] - - def adv_ibp(u_c: BackendTensor, l_c: BackendTensor, y_tensor: BackendTensor) -> BackendTensor: - t_tensor = 1 - y_tensor - s_tensor = y_tensor - - t_tensor = t_tensor[:, :, None] - s_tensor = s_tensor[:, None, :] - M = t_tensor * s_tensor - upper = K.expand_dims(u_c, -1) - K.expand_dims(l_c, 1) - const = (K.max(upper, (-1, -2)) - K.min(upper, (-1, -2)))[:, None, None] - upper = upper - (const + K.cast(1, const.dtype)) * (1 - M) - return K.max(upper, (-1, -2)) - - source_tensor = _create_identity_tensor_like(l_c) - - score = K.concatenate([adv_ibp(u_c, l_c, source_tensor[:, i])[:, None] for i in range(shape)], -1) - return K.maximum( - score, K.cast(-1, dtype=score.dtype) - ) # + 1e-3*K.maximum(K.max(K.abs(u_c), -1)[:,None], K.abs(l_c)) - - def call_backward(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - if not self.asymptotic: - u_c, l_c = self.convert2mode_layer(inputs) - return K.softmax(u_c) - - else: - raise NotImplementedError() - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - if self.backward: - return self.call_backward(inputs, **kwargs) - else: - return self.call_no_backward(inputs, **kwargs) - - def compute_output_shape(self, input_shape: List[Tuple[Optional[int], ...]]) -> Tuple[Optional[int], ...]: # type: ignore - return input_shape[-1] - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - return None - - -# new layer for new loss functions -class DecomonRadiusRobust(DecomonLayer): - original_keras_layer_class = Layer - - def __init__( - self, - backward: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - shared: bool = False, - fast: bool = True, - **kwargs: Any, - ): - super().__init__( - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - mode=mode, - finetune=finetune, - shared=shared, - fast=fast, - **kwargs, - ) - - if self.mode == ForwardMode.IBP: - raise NotImplementedError - - if not isinstance(self.perturbation_domain, BoxDomain): - raise NotImplementedError() - - self.backward = backward - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "backward": self.backward, - } - ) - return config - - def call_no_backward(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - if self.mode == ForwardMode.HYBRID: - x, _, w_u, b_u, _, w_l, b_l = inputs - else: - x, w_u, b_u, w_l, b_l = inputs - - # compute center - x_0 = (x[:, 0] + x[:, 1]) / 2.0 - radius = K.maximum((x[:, 1] - x[:, 0]) / 2.0, K.cast(epsilon(), dtype=x.dtype)) - source_tensor = _create_identity_tensor_like(b_l) - - shape = b_l.shape[-1] - - def radius_label(y_tensor: BackendTensor, backward: bool = False) -> BackendTensor: - t_tensor = 1 - y_tensor - s_tensor = y_tensor - - W_adv = ( - K.sum(-w_l * (s_tensor[:, None]), -1, keepdims=True) + w_u * t_tensor[:, None] + w_l * y_tensor[:, None] - ) # (None, n_in, n_out) - b_adv = K.sum(-b_l * s_tensor, -1, keepdims=True) + b_u * t_tensor + (b_l - 1e6) * y_tensor # (None, n_out) - - score = K.sum(W_adv * x_0[:, :, None], 1) + b_adv # (None, n_out) - - denum = K.maximum( - K.sum(K.abs(W_adv * radius[:, :, None]), 1), K.cast(epsilon(), dtype=W_adv.dtype) - ) # (None, n_out) - - eps_adv = K.minimum(-score / denum + y_tensor, K.cast(2.0, dtype=score.dtype)) - - adv_volume = 1.0 - eps_adv - - return K.max(adv_volume, -1, keepdims=True) - - return K.concatenate([radius_label(source_tensor[:, i]) for i in range(shape)], -1) - - def call_backward(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - if self.mode == ForwardMode.HYBRID: - x, _, w_u, b_u, _, w_l, b_l = inputs - else: - x, w_u, b_u, w_l, b_l = inputs - - # compute center - x_0 = (x[:, 0] + x[:, 1]) / 2.0 - radius = K.maximum((x[:, 1] - x[:, 0]) / 2.0, K.cast(epsilon(), dtype=x.dtype)) - source_tensor = _create_identity_tensor_like(b_l) - - shape = b_l.shape[-1] - - def radius_label(y_tensor: BackendTensor) -> BackendTensor: - W_adv = w_u - b_adv = b_u - 1e6 * y_tensor - - score = K.sum(W_adv * x_0[:, :, None], 1) + b_adv # (None, n_out) - denum = K.maximum( - K.sum(K.abs(W_adv * radius[:, :, None]), 1), K.cast(epsilon(), dtype=W_adv.dtype) - ) # (None, n_out) - - eps_adv = K.minimum(-score / denum + y_tensor, K.cast(2.0, dtype=score.dtype)) - - adv_volume = 1.0 - eps_adv - - return K.max(adv_volume, -1, keepdims=True) - - return K.concatenate([radius_label(source_tensor[:, i]) for i in range(shape)], -1) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - if self.backward: - return self.call_backward(inputs, **kwargs) - else: - return self.call_no_backward(inputs, **kwargs) - - def compute_output_shape(self, input_shape: Union[List[Tuple[Optional[int], ...]],]) -> Tuple[Optional[int], ...]: # type: ignore - return input_shape[-1] - - def build(self, input_shape: List[Tuple[Optional[int], ...]]) -> None: - return None - - -def build_radius_robust_model(model: DecomonModel) -> DecomonModel: - ibp = model.ibp - affine = model.affine - - mode = get_mode(ibp, affine) - perturbation_domain = model.perturbation_domain - - inputs = model.input - output = model.output - - layer_robust = DecomonRadiusRobust( - mode=mode, perturbation_domain=perturbation_domain, backward=model.backward_bounds - ) - output_robust = layer_robust(output) - - return DecomonModel( - inputs=inputs, - outputs=output_robust, - perturbation_domain=model.perturbation_domain, - ibp=ibp, - affine=affine, - finetune=model.finetune, - ) - - -##### DESIGN LOSS FUNCTIONS -def build_crossentropy_model(model: DecomonModel) -> DecomonModel: - ibp = model.ibp - affine = model.affine - - mode = get_mode(ibp, affine) - perturbation_domain = model.perturbation_domain - - inputs = model.input - output = model.output - - layer_fusion = DecomonLossFusion(mode=mode, backward=model.backward_bounds) - output_fusion = layer_fusion(output, mode=mode) - - return DecomonModel( - inputs=inputs, - outputs=output_fusion, - perturbation_domain=model.perturbation_domain, - ibp=ibp, - affine=affine, - finetune=model.finetune, - ) - - -##### DESIGN LOSS FUNCTIONS -def build_asymptotic_crossentropy_model(model: DecomonModel) -> DecomonModel: - ibp = model.ibp - affine = model.affine - - mode = get_mode(ibp, affine) - perturbation_domain = model.perturbation_domain - - inputs = model.input - output = model.output - - layer_fusion = DecomonLossFusion(mode=mode, asymptotic=True) - output_fusion = layer_fusion(output, mode=mode) - - return DecomonModel( - inputs=inputs, - outputs=output_fusion, - perturbation_domain=model.perturbation_domain, - ibp=ibp, - affine=affine, - finetune=model.finetune, - ) diff --git a/src/decomon/metrics/metric.py b/src/decomon/metrics/metric.py deleted file mode 100644 index 729eb9fa..00000000 --- a/src/decomon/metrics/metric.py +++ /dev/null @@ -1,454 +0,0 @@ -from abc import ABC, abstractmethod -from enum import Enum -from typing import Any, Dict, List, Optional, Union - -import keras -import keras.ops as K -import numpy as np -from keras.layers import Input, Layer -from keras.models import Model - -from decomon.core import BoxDomain, PerturbationDomain -from decomon.models.models import DecomonModel -from decomon.types import BackendTensor, Tensor - - -class MetricMode(str, Enum): - FORWARD = "forward" - BACKWARD = "backward" - - -class MetricLayer(ABC, Layer): - def __init__( - self, - ibp: bool, - affine: bool, - mode: Union[str, MetricMode], - perturbation_domain: Optional[PerturbationDomain], - **kwargs: Any, - ): - """ - Args: - ibp: boolean that indicates whether we propagate constant - bounds - affine: boolean that indicates whether we propagate affine - bounds - mode: str: 'backward' or 'forward' whether we doforward or - backward linear relaxation - perturbation_domain: the type of input perturbation domain for the - linear relaxation - **kwargs - """ - super().__init__(**kwargs) - self.ibp = ibp - self.affine = affine - self.mode = MetricMode(mode) - self.perturbation_domain: PerturbationDomain - if perturbation_domain is None: - self.perturbation_domain = BoxDomain() - else: - self.perturbation_domain = perturbation_domain - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "ibp": self.ibp, - "affine": self.affine, - "mode": self.mode, - "perturbation_domain": self.perturbation_domain, - } - ) - return config - - @abstractmethod - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - """ - Args: - inputs - - Returns: - - """ - pass - - -class AdversarialCheck(MetricLayer): - """Training with symbolic LiRPA bounds for promoting adversarial robustness""" - - def __init__( - self, - ibp: bool, - affine: bool, - mode: Union[str, MetricMode], - perturbation_domain: Optional[PerturbationDomain], - **kwargs: Any, - ): - """ - Args: - ibp: boolean that indicates whether we propagate constant - bounds - affine: boolean that indicates whether we propagate affine - bounds - mode: str: 'backward' or 'forward' whether we doforward or - backward linear relaxation - perturbation_domain: the type of input perturbation domain for the - linear relaxation - **kwargs - """ - super().__init__(ibp=ibp, affine=affine, mode=mode, perturbation_domain=perturbation_domain, **kwargs) - - def linear_adv( - self, - z_tensor: Tensor, - y_tensor: Tensor, - w_u: Tensor, - b_u: Tensor, - w_l: Tensor, - b_l: Tensor, - ) -> Tensor: - w_upper = w_u * (1 - y_tensor[:, None]) - K.expand_dims(K.sum(w_l * y_tensor[:, None], -1), -1) - b_upper = b_u * (1 - y_tensor) - b_l * y_tensor - - adv_score = self.perturbation_domain.get_upper(z_tensor, w_upper, b_upper) - 1e6 * y_tensor - - return K.max(adv_score, -1) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - """ - Args: - inputs - - Returns: - adv_score <0 if the predictionis robust on the input convex - domain - """ - - y_tensor = inputs[-1] - - if self.ibp and self.affine: - _, z, u_c, w_u_f, b_u_f, l_c, w_l_f, b_l_f = inputs[:8] - elif not self.ibp and self.affine: - _, z, w_u_f, b_u_f, w_l_f, b_l_f = inputs[:6] - elif self.ibp and not self.affine: - _, z, u_c, l_c = inputs[:4] - else: - raise NotImplementedError("not ibp and not affine not implemented") - - if self.ibp: - adv_ibp = _get_ibp_score(u_c, l_c, y_tensor) - if self.affine: - adv_f = _get_affine_score( - z, w_u_f, b_u_f, w_l_f, b_l_f, y_tensor, perturbation_domain=self.perturbation_domain - ) - - if self.ibp and not self.affine: - adv_score = adv_ibp - elif self.ibp and self.affine: - adv_score = K.minimum(adv_ibp, adv_f) - elif not self.ibp and self.affine: - adv_score = adv_f - else: - raise NotImplementedError("not ibp and not affine not implemented") - - if self.mode == MetricMode.BACKWARD: - w_u_b, b_u_b, w_l_b, b_l_b, _ = inputs[-5:] - adv_b = _get_backward_score( - z, w_u_b, b_u_b, w_l_b, b_l_b, y_tensor, perturbation_domain=self.perturbation_domain - ) - adv_score = K.minimum(adv_score, adv_b) - - return adv_score - - -class AdversarialScore(AdversarialCheck): - """Training with symbolic LiRPA bounds for promoting adversarial robustness""" - - def __init__( - self, - ibp: bool, - affine: bool, - mode: Union[str, MetricMode], - perturbation_domain: Optional[PerturbationDomain], - **kwargs: Any, - ): - """ - Args: - ibp: boolean that indicates whether we propagate constant - bounds - affine: boolean that indicates whether we propagate affine - bounds - mode: str: 'backward' or 'forward' whether we doforward or - backward linear relaxation - perturbation_domain: the type of input perturbation domain for the - linear relaxation - **kwargs - """ - super().__init__(ibp=ibp, affine=affine, mode=mode, perturbation_domain=perturbation_domain, **kwargs) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - """ - Args: - inputs - - Returns: - adv_score <0 if the predictionis robust on the input convex - domain - """ - - y_tensor = inputs[-1] - - if self.ibp and self.affine: - _, z, u_c, w_u_f, b_u_f, l_c, w_l_f, b_l_f = inputs[:8] - elif not self.ibp and self.affine: - _, z, w_u_f, b_u_f, w_l_f, b_l_f = inputs[:6] - elif self.ibp and not self.affine: - _, z, u_c, l_c = inputs[:4] - else: - raise NotImplementedError("not ibp and not affine not implemented") - - if self.ibp: - adv_ibp = _get_ibp_score(u_c=l_c, l_c=u_c, source_tensor=y_tensor) - if self.affine: - adv_f = _get_affine_score( - z, - w_u=w_l_f, - b_u=b_l_f, - w_l=w_u_f, - b_l=b_u_f, - source_tensor=y_tensor, - perturbation_domain=self.perturbation_domain, - ) - - if self.ibp and not self.affine: - adv_score = adv_ibp - elif self.ibp and self.affine: - adv_score = K.minimum(adv_ibp, adv_f) - elif not self.ibp and self.affine: - adv_score = adv_f - else: - raise NotImplementedError("not ibp and not affine not implemented") - - if self.mode == MetricMode.BACKWARD: - w_u_b, b_u_b, w_l_b, b_l_b, _ = inputs[-5:] - adv_b = _get_backward_score( - z, w_u_b, b_u_b, w_l_b, b_l_b, y_tensor, perturbation_domain=self.perturbation_domain - ) - adv_score = K.minimum(adv_score, adv_b) - - return adv_score - - -def build_formal_adv_check_model(decomon_model: DecomonModel) -> keras.Model: - """automatic design on a Keras model which predicts a certificate of adversarial robustness - - Args: - decomon_model - - Returns: - - """ - # check type and that backward pass is available - - perturbation_domain = decomon_model.perturbation_domain - layer = AdversarialCheck(decomon_model.ibp, decomon_model.affine, decomon_model.mode, perturbation_domain) - output = decomon_model.output - input = decomon_model.input - n_out = decomon_model.output[0].shape[1:] - y_out = Input(n_out) - - adv_score = layer(output + [y_out]) - adv_model = Model(input + [y_out], adv_score) - return adv_model - - -def build_formal_adv_model(decomon_model: DecomonModel) -> keras.Model: - """automatic design on a Keras model which predicts a certificate of adversarial robustness - - Args: - decomon_model - - Returns: - - """ - # check type and that backward pass is available - - perturbation_domain = decomon_model.perturbation_domain - layer = AdversarialScore(decomon_model.ibp, decomon_model.affine, decomon_model.mode, perturbation_domain) - output = decomon_model.output - input = decomon_model.input - n_out = decomon_model.output[0].shape[1:] - y_out = Input(n_out) - - adv_score = layer(output + [y_out]) - adv_model = Model(input + [y_out], adv_score) - return adv_model - - -class UpperScore(MetricLayer): - """Training with symbolic LiRPA bounds for limiting the local maximum of a neural network""" - - def __init__( - self, - ibp: bool, - affine: bool, - mode: Union[str, MetricMode], - perturbation_domain: Optional[PerturbationDomain], - **kwargs: Any, - ): - """ - Args: - ibp: boolean that indicates whether we propagate constant - bounds - affine: boolean that indicates whether we propagate affine - bounds - mode: str: 'backward' or 'forward' whether we doforward or - backward linear relaxation - perturbation_domain: the type of input perturbation domain for the - linear relaxation - **kwargs - """ - super().__init__(ibp=ibp, affine=affine, mode=mode, perturbation_domain=perturbation_domain, **kwargs) - - def linear_upper(self, z_tensor: Tensor, y_tensor: Tensor, w_u: Tensor, b_u: Tensor) -> Tensor: - w_upper = w_u * y_tensor[:, None] - b_upper = b_u * y_tensor - - upper_score = self.perturbation_domain.get_upper(z_tensor, w_upper, b_upper) - - return K.sum(upper_score, -1) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> BackendTensor: - """ - Args: - inputs - - Returns: - upper_score <=0 if the maximum of the neural network is - lower than the target - """ - - y_tensor = inputs[-1] - z_tensor = inputs[1] - - if self.ibp and self.affine: - _, _, u_c, w_u_f, b_u_f, _, _, _ = inputs[:8] - - upper_ibp = K.sum(u_c * y_tensor, -1) - upper_affine = self.linear_upper(z_tensor, y_tensor, w_u_f, b_u_f) - upper_score = K.minimum(upper_ibp, upper_affine) - elif not self.ibp and self.affine: - _, _, w_u_f, b_u_f = inputs[:6] - upper_score = self.linear_upper(z_tensor, y_tensor, w_u_f, b_u_f) - elif self.ibp and not self.affine: - _, _, u_c, l_c = inputs[:4] - upper_score = K.sum(u_c * y_tensor, -1) - else: - raise NotImplementedError("not ibp and not affine not implemented") - - if self.mode == MetricMode.BACKWARD: - w_u_b, b_u_b, _, _, _ = inputs[-5:] - upper_backward = self.linear_upper(z_tensor, y_tensor, w_u_b[:, 0], b_u_b[:, 0]) - upper_score = K.minimum(upper_score, upper_backward) - - return upper_score - - -def build_formal_upper_model(decomon_model: DecomonModel) -> keras.Model: - """automatic design on a Keras model which predicts a certificate on the local upper bound - - Args: - decomon_model - - Returns: - - """ - # check type and that backward pass is available - - perturbation_domain = decomon_model.perturbation_domain - layer = UpperScore(decomon_model.ibp, decomon_model.affine, decomon_model.mode, perturbation_domain) - output = decomon_model.output - input = decomon_model.input - n_out = decomon_model.output[0].shape[1:] - y_out = Input(n_out) - - upper_score = layer(output + [y_out]) - upper_model = Model(input + [y_out], upper_score) - return upper_model - - -def _get_ibp_score( - u_c: Tensor, - l_c: Tensor, - source_tensor: Tensor, - target_tensor: Optional[Tensor] = None, -) -> Tensor: - if target_tensor is None: - target_tensor = 1.0 - source_tensor - - shape = int(np.prod(u_c.shape[1:])) - u_c_reshaped = K.reshape(u_c, (-1, shape)) - l_c_reshaped = K.reshape(l_c, (-1, shape)) - - score_u = ( - l_c_reshaped * target_tensor - - K.expand_dims(K.min(u_c_reshaped * source_tensor, -1), -1) - - 1e6 * (1 - target_tensor) - ) - - return K.max(score_u, -1) - - -def _get_affine_score( - z_tensor: Tensor, - w_u: Tensor, - b_u: Tensor, - w_l: Tensor, - b_l: Tensor, - source_tensor: Tensor, - perturbation_domain: PerturbationDomain, - target_tensor: Optional[Tensor] = None, -) -> Tensor: - if target_tensor is None: - target_tensor = 1 - source_tensor - - n_dim = w_u.shape[1] - shape = int(np.prod(b_u.shape[1:])) - w_u_reshaped = K.reshape(w_u, (-1, n_dim, shape, 1)) - w_l_reshaped = K.reshape(w_l, (-1, n_dim, 1, shape)) - b_u_reshaped = K.reshape(b_u, (-1, shape, 1)) - b_l_reshaped = K.reshape(b_l, (-1, 1, shape)) - - w_u_f = w_l_reshaped - w_u_reshaped - b_u_f = b_l_reshaped - b_u_reshaped - - # add penalties on biases - oneminus_target_tensor: Tensor = 1 - target_tensor # for mypy - oneminus_source_tensor: Tensor = 1 - source_tensor # for mypy - b_u_f = b_u_f - 1e6 * oneminus_target_tensor[:, None, :] - b_u_f = b_u_f - 1e6 * oneminus_source_tensor[:, :, None] - - upper = perturbation_domain.get_upper(z_tensor, w_u_f, b_u_f) - return K.max(upper, (-1, -2)) - - -def _get_backward_score( - z_tensor: Tensor, - w_u: Tensor, - b_u: Tensor, - w_l: Tensor, - b_l: Tensor, - source_tensor: Tensor, - perturbation_domain: PerturbationDomain, - target_tensor: Optional[Tensor] = None, -) -> Tensor: - return _get_affine_score( - z_tensor, - w_u[:, 0], - b_u[:, 0], - w_l[:, 0], - b_l[:, 0], - source_tensor, - perturbation_domain=perturbation_domain, - target_tensor=target_tensor, - ) diff --git a/src/decomon/metrics/utils.py b/src/decomon/metrics/utils.py deleted file mode 100644 index 90f3c363..00000000 --- a/src/decomon/metrics/utils.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Any, Dict, List, Optional, Union - -from decomon.core import BoxDomain, ForwardMode, PerturbationDomain -from decomon.layers.utils import exp, expand_dims, log, sum -from decomon.types import Tensor -from decomon.utils import add, minus - -# compute categorical cross entropy - - -def categorical_cross_entropy( - inputs: List[Tensor], - dc_decomp: bool = False, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - perturbation_domain: Optional[PerturbationDomain] = None, -) -> List[Tensor]: - # step 1: exponential - if perturbation_domain is None: - perturbation_domain = BoxDomain() - outputs = exp(inputs, mode=mode, perturbation_domain=perturbation_domain, dc_decomp=dc_decomp) - # step 2: sum - outputs = sum(outputs, axis=-1, mode=mode) - # step 3: log - outputs = log(outputs, dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - outputs = expand_dims(outputs, dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain, axis=-1) - # step 4: - inputs - return add( - minus(inputs, mode=mode, perturbation_domain=perturbation_domain, dc_decomp=dc_decomp), - outputs, - mode=mode, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - ) diff --git a/src/decomon/models/__init__.py b/src/decomon/models/__init__.py index 88415a00..bd4607de 100644 --- a/src/decomon/models/__init__.py +++ b/src/decomon/models/__init__.py @@ -1,4 +1,2 @@ from .convert import clone from .models import DecomonModel - -# from .decomon_sequential import DecomonModel diff --git a/src/decomon/models/backward_cloning.py b/src/decomon/models/backward_cloning.py index bfc570e0..1e3e81c4 100644 --- a/src/decomon/models/backward_cloning.py +++ b/src/decomon/models/backward_cloning.py @@ -1,462 +1,545 @@ -from copy import deepcopy -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from collections.abc import Callable +from typing import Any, Optional, Union import keras -import keras.ops as K -from keras.config import floatx -from keras.layers import Concatenate, Lambda, Layer +from keras.layers import Layer from keras.models import Model from keras.src.ops.node import Node -from keras.src.utils.python_utils import to_list - -from decomon.backward_layers.backward_merge import BackwardMerge -from decomon.backward_layers.convert import to_backward -from decomon.backward_layers.core import BackwardLayer -from decomon.core import ( - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - Slope, - get_affine, - get_mode, -) -from decomon.keras_utils import BatchedIdentityLike -from decomon.layers.utils import softmax_to_linear as softmax_2_linear -from decomon.models.crown import Convert2BackwardMode, Fuse, MergeWithPrevious -from decomon.models.forward_cloning import OutputMapDict -from decomon.models.utils import ( - Convert2Mode, - ensure_functional_model, - get_depth_dict, - get_input_dim, -) -from decomon.types import BackendTensor, Tensor - - -def get_disconnected_input( - mode: Union[str, ForwardMode], - perturbation_domain: PerturbationDomain, - dtype: Optional[str] = None, -) -> Layer: - mode = ForwardMode(mode) - dc_decomp = False - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - affine = get_affine(mode) - if dtype is None: - dtype = floatx() - - def disco_priv(inputs: List[Tensor]) -> List[Tensor]: - x, u_c, w_f_u, b_f_u, l_c, w_f_l, b_f_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if affine: - x = K.concatenate([K.expand_dims(l_c, 1), K.expand_dims(u_c, 1)], 1) - w_u = BatchedIdentityLike()(u_c) - b_u = K.zeros_like(u_c) - else: - w_u, b_u = empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs([x, u_c, w_u, b_u, l_c, w_u, b_u]) - - return Lambda(disco_priv, dtype=dtype) - -def retrieve_layer( - node: Node, - layer_fn: Callable[[Layer], BackwardLayer], - backward_map: Dict[int, BackwardLayer], - joint: bool = True, -) -> BackwardLayer: - if id(node) in backward_map: - backward_layer = backward_map[id(node)] - else: - backward_layer = layer_fn(node.operation) - if joint: - backward_map[id(node)] = backward_layer - return backward_layer +from decomon.constants import Propagation, Slope +from decomon.layers import DecomonLayer +from decomon.layers.convert import to_decomon +from decomon.layers.crown import ReduceCrownBounds +from decomon.layers.merging.base_merge import DecomonMerge +from decomon.layers.oracle import DecomonOracle +from decomon.models.utils import ensure_functional_model, get_output_nodes +from decomon.perturbation_domain import BoxDomain, PerturbationDomain +from decomon.types import Tensor -def crown_( +def crown( node: Node, - ibp: bool, - affine: bool, + layer_fn: Callable[[Layer, tuple[int, ...]], DecomonLayer], + model_output_shape: tuple[int, ...], + backward_bounds: list[keras.KerasTensor], + backward_map: dict[int, DecomonLayer], + oracle_map: dict[int, Union[list[keras.KerasTensor], list[list[keras.KerasTensor]]]], + forward_output_map: dict[int, list[keras.KerasTensor]], + forward_layer_map: dict[int, DecomonLayer], + crown_output_map: dict[int, list[keras.KerasTensor]], + submodels_stack: list[Node], + perturbation_domain_input: keras.KerasTensor, perturbation_domain: PerturbationDomain, - input_map: Dict[int, List[keras.KerasTensor]], - layer_fn: Callable[[Layer], BackwardLayer], - backward_bounds: List[keras.KerasTensor], - backward_map: Optional[Dict[int, BackwardLayer]] = None, - joint: bool = True, - fuse: bool = True, - output_map: Optional[Dict[int, List[keras.KerasTensor]]] = None, - merge_layers: Optional[Layer] = None, - fuse_layer: Optional[Layer] = None, - **kwargs: Any, -) -> Tuple[List[keras.KerasTensor], Optional[Layer]]: +) -> list[keras.KerasTensor]: """ + Args: + node: node of the model until which the backward bounds have been propagated + layer_fn: function converting a keras layer into its backward version, + according to the proper (sub)model output shape + Will be also passed to `get_oracle()` for crown oracle deriving from sub-crowns + model_output_shape: model_output_shape of the current (sub)crown, + to be passed to `layer_fn`. + backward_bounds: backward bounds to propagate + oracle_map: oracle bounds on inputs of each keras layer, stored by layers id + oracle_map: already registered oracle bounds per node + To be used by `get_oracle()`. + forward_output_map: forward outputs per node from a previously performed forward conversion. + To be used by `get_oracle()`. + forward_layer_map: forward decomon layer per node from a previously performed forward conversion. + To be used by `get_oracle()`. + crown_output_map: output of subcrowns per output node. + Avoids relaunching a crown if several nodes share parents. + To be used by `get_oracle()`. + submodels_stack: not empty only if in a submodel. + A list of nodes corresponding to the successive embedded submodels, + from the outerest to the innerest submodel, the last one being the current submodel. + Used to carry on the propagation through the outer model when reaching the input of a submodel. + backward_map: stores converted layer by node for the current crown + (should depend on the proper model output and thus change for each sub-crown) + + Returns: + propagated backward bounds until model input for the proper output node - :param node: - :param ibp: - :param affine: - :param input_map: - :param layer_fn: - :param backward_bounds: - :param backward_map: - :param joint: - :param fuse: - :return: list of 4 tensors affine upper and lower bounds """ - if backward_map is None: - backward_map = {} - - if output_map is None: - output_map = {} - - inputs = input_map[id(node)] - - if perturbation_domain is None: - perturbation_domain = BoxDomain() + ## Special case: node == embedded submodel => crown on its output node if isinstance(node.operation, Model): - inputs_tensors = get_disconnected_input(get_mode(ibp, affine), perturbation_domain, dtype=inputs[0].dtype)( - inputs - ) - _, backward_bounds, _, _ = crown_model( - model=node.operation, - input_tensors=inputs_tensors, + submodel = node.operation + submodel = ensure_functional_model(submodel) + output_nodes = get_output_nodes(submodel) + if len(output_nodes) > 1: + raise NotImplementedError( + "Backward propagation not yet implemented for model whose embedded submodels have multiple outputs." + ) + output_node = output_nodes[0] + return crown( + node=output_node, + layer_fn=layer_fn, + model_output_shape=model_output_shape, backward_bounds=backward_bounds, - ibp=ibp, - affine=affine, - perturbation_domain=None, - finetune=False, - joint=joint, - fuse=False, - **kwargs, + backward_map=backward_map, + oracle_map=oracle_map, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + crown_output_map=crown_output_map, + submodels_stack=submodels_stack + [node], + perturbation_domain_input=perturbation_domain_input, + perturbation_domain=perturbation_domain, ) - else: - backward_layer = retrieve_layer(node=node, layer_fn=layer_fn, backward_map=backward_map, joint=joint) + parents = node.parent_nodes + is_merging_node = False - if id(node) not in output_map: - backward_bounds_new = backward_layer(inputs) - output_map[id(node)] = backward_bounds_new - else: - backward_bounds_new = output_map[id(node)] + ## 1. Propagation through the current node + if len(parents) == 0: + # input layer: no conversion, propagate output unchanged + ... - # import pdb; pdb.set_trace() - if len(backward_bounds): - if merge_layers is None: - merge_layers = MergeWithPrevious(backward_bounds_new[0].shape, backward_bounds[0].shape) - backward_bounds = merge_layers(backward_bounds_new + backward_bounds) + else: # generic case: a node with parents whose operation is a keras.Layer + # conversion to a backward DecomonLayer + if id(node) in backward_map: + backward_layer = backward_map[id(node)] else: - backward_bounds = backward_bounds_new - - parents = node.parent_nodes + backward_layer = layer_fn(node.operation, model_output_shape) + backward_map[id(node)] = backward_layer - if len(parents): - if len(parents) > 1: - if isinstance(backward_layer, BackwardMerge): - raise NotImplementedError() - crown_bound_list = [] - for backward_bound, parent in zip(backward_bounds, parents): - crown_bound_i, _ = crown_( - node=parent, - ibp=ibp, - affine=affine, - perturbation_domain=perturbation_domain, - input_map=input_map, - layer_fn=layer_fn, - backward_bounds=backward_bound, - backward_map=backward_map, - joint=joint, - fuse=fuse, - ) + # get oracle bounds if needed + if backward_layer.inputs_outputs_spec.needs_oracle_bounds(): + constant_oracle_bounds = get_oracle( + node=node, + perturbation_domain_input=perturbation_domain_input, + perturbation_domain=perturbation_domain, + oracle_map=oracle_map, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + backward_layer=backward_layer, + crown_output_map=crown_output_map, + submodels_stack=submodels_stack, + layer_fn=layer_fn, + ) + else: + constant_oracle_bounds = [] - crown_bound_list.append(crown_bound_i) + # propagate backward bounds through the decomon layer + backward_layer_inputs = backward_layer.inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=backward_bounds, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=[], + ) + backward_layer_outputs = backward_layer(backward_layer_inputs) + backward_bounds, _ = backward_layer.inputs_outputs_spec.split_outputs(backward_layer_outputs) - # avg_layer = Average(dtype=node.outbound_layer.dtype) - # crown_bound = [avg_layer([e[i] for e in crown_bound_list]) for i in range(4)] - crown_bound = crown_bound_list[0] + # merging layer? (to known later how to handle propagated backward_bounds) + is_merging_node = isinstance(backward_layer, DecomonMerge) - else: - raise NotImplementedError() + ## 2. Stopping criteria: is this a leaf node of the outer model? + if len(parents) == 0: + if len(submodels_stack) == 0: + # Input layer of outer model => we are done + return backward_bounds else: - crown_bound, fuse_layer_new = crown_( - node=parents[0], - ibp=ibp, - affine=affine, - perturbation_domain=perturbation_domain, - input_map=input_map, - layer_fn=layer_fn, - backward_bounds=backward_bounds, - backward_map=backward_map, - joint=joint, - fuse=fuse, - output_map=output_map, - merge_layers=None, # AKA merge_layers - fuse_layer=fuse_layer, + # Input layer of an embedded model => we propagate through the sumodel node parents + node = submodels_stack[-1] + submodels_stack = submodels_stack[:-1] + parents = node.parent_nodes + # check on parents + if len(parents) == 0: + raise RuntimeError("Submodel node should have parents.") + elif len(parents) > 1: + raise NotImplementedError( + "crown_model() not yet implemented for model whose embedded submodels have multiple inputs." + ) + + ## 3. Call crown recursively on parent nodes + if is_merging_node: + # merging layer (NB: could be merging only 1 parent, this is possible for Add layers) + crown_bounds_list: list[list[Tensor]] = [] + for backward_bounds_i, parent in zip(backward_bounds, parents): + crown_bounds_list.append( + crown( + node=parent, + layer_fn=layer_fn, + model_output_shape=model_output_shape, + backward_bounds=backward_bounds_i, + backward_map=backward_map, + oracle_map=oracle_map, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + crown_output_map=crown_output_map, + submodels_stack=submodels_stack, + perturbation_domain_input=perturbation_domain_input, + perturbation_domain=perturbation_domain, + ) ) - if fuse_layer is None: - fuse_layer = fuse_layer_new - return crown_bound, fuse_layer + # reduce by summing all bounds together + # (indeed all bounds are partial affine bounds on model output w.r.t the same model input + # under the hypotheses of a single model input) + reducing_layer = ReduceCrownBounds(model_output_shape=model_output_shape) + crown_bounds = reducing_layer(crown_bounds_list) + + elif len(parents) > 1: + raise RuntimeError("Node with multiple parents should have been converted to a DecomonMerge layer.") else: - # do something - if fuse: - if fuse_layer is None: - fuse_layer = Fuse(get_mode(ibp=ibp, affine=affine)) - result = fuse_layer(inputs + backward_bounds) + # unary layer + crown_bounds = crown( + node=parents[0], + layer_fn=layer_fn, + model_output_shape=model_output_shape, + backward_bounds=backward_bounds, + backward_map=backward_map, + oracle_map=oracle_map, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + crown_output_map=crown_output_map, + submodels_stack=submodels_stack, + perturbation_domain_input=perturbation_domain_input, + perturbation_domain=perturbation_domain, + ) + return crown_bounds - return result, fuse_layer +def get_oracle( + node: Node, + perturbation_domain_input: keras.KerasTensor, + perturbation_domain: PerturbationDomain, + oracle_map: dict[int, Union[list[keras.KerasTensor], list[list[keras.KerasTensor]]]], + forward_output_map: dict[int, list[keras.KerasTensor]], + forward_layer_map: dict[int, DecomonLayer], + backward_layer: DecomonLayer, + crown_output_map: dict[int, list[keras.KerasTensor]], + submodels_stack: list[Node], + layer_fn: Callable[[Layer, tuple[int, ...]], DecomonLayer], +) -> Union[list[keras.KerasTensor], list[list[keras.KerasTensor]]]: + """Get oracle bounds "on demand". + + When needed by a node, get oracle constant bounds on keras layer inputs either: + - from `oracle_map`, if already computed + - from forward oracle, when a first forward conversion has been done + - from crown oracle, by launching sub-crowns on parent nodes + + Args: + node: considered node whose operation (keras layer) needs oracle bounds + perturbation_domain_input: perturbation domain input + perturbation_domain: perturbation domain type on keras model input + oracle_map: already registered oracle bounds per node + forward_output_map: forward outputs per node from a previously performed forward conversion. + To be used for forward oracle. + forward_layer_map: forward decomon layer per node from a previously performed forward conversion. + To be used for forward oracle. + backward_layer: converted backward decomon layer for this node. + To be used for crown oracle. + crown_output_map: output of subcrowns per output node. + Avoids relaunching a crown if several nodes share parents. + To be used for crown oracle. + submodels_stack: not empty only if in a submodel. + A list of nodes corresponding to the successive embedded submodels, + from the outerest to the innerest submodel, the last one being the current submodel. + Will be used to get perturbation_domain_input for this submodel, to be used only by crown oracle. + (Forward oracle being precomputed from the full model, the original perturbation_domain_input is used for it) + To be used for crown oracle. + layer_fn: callable converting a layer and a model_output_shape into a (backward) decomon layer. + To be used for crown oracle. + + Returns: + oracle bounds on node inputs + + """ + # Do not recompute if already existing + if id(node) in oracle_map: + return oracle_map[id(node)] + + parents = node.parent_nodes + if not len(parents): # NB: get_oracle() is never called on node w/o parents (i.e. InputLayer) + # input node: can be deduced directly from perturbation domain + oracle_bounds = [ + perturbation_domain.get_lower_x(x=perturbation_domain_input), + perturbation_domain.get_upper_x(x=perturbation_domain_input), + ] + else: + if id(node) in forward_layer_map: + # forward oracle + forward_layer = forward_layer_map[id(node)] + oracle_layer = DecomonOracle( + perturbation_domain=forward_layer.perturbation_domain, + ibp=forward_layer.ibp, + affine=forward_layer.affine, + layer_input_shape=forward_layer.layer_input_shape, + is_merging_layer=forward_layer.is_merging_layer, + ) + oracle_input: list[keras.KerasTensor] = [] + for parent in parents: + oracle_input += forward_output_map[id(parent)] + if oracle_layer.inputs_outputs_spec.needs_perturbation_domain_inputs(): + oracle_input += [perturbation_domain_input] + oracle_bounds = oracle_layer(oracle_input) else: - return backward_bounds, fuse_layer + # crown oracle + + # affine bounds on parents from sub-crowns + crown_bounds = [] + for parent in parents: + if id(parent) in crown_output_map: + # already computed sub-crown? + crown_bounds_parent = crown_output_map[id(parent)] + else: + subcrown_output_shape = get_model_output_shape(node=parent, backward_bounds=[]) + crown_bounds_parent = crown( + node=parent, + layer_fn=layer_fn, + model_output_shape=subcrown_output_shape, + backward_bounds=[], + backward_map={}, # new output node, thus new backward_map + oracle_map=oracle_map, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + crown_output_map=crown_output_map, + submodels_stack=submodels_stack, + perturbation_domain_input=perturbation_domain_input, + perturbation_domain=perturbation_domain, + ) + # store sub-crown output + crown_output_map[id(parent)] = crown_bounds_parent + crown_bounds += crown_bounds_parent + oracle_input = crown_bounds + [perturbation_domain_input] + oracle_layer = DecomonOracle( + perturbation_domain=backward_layer.perturbation_domain, + ibp=False, + affine=True, + layer_input_shape=backward_layer.layer_input_shape, + is_merging_layer=backward_layer.is_merging_layer, + ) # crown bounds contains only affine bounds => ibp=False, affine=True + oracle_bounds = oracle_layer(oracle_input) -def get_input_nodes( - model: Model, - dico_nodes: Dict[int, List[Node]], - ibp: bool, - affine: bool, - input_tensors: List[keras.KerasTensor], - output_map: OutputMapDict, - layer_fn: Callable[[Layer], BackwardLayer], - joint: bool, - set_mode_layer: Layer, - perturbation_domain: Optional[PerturbationDomain] = None, - **kwargs: Any, -) -> Tuple[Dict[int, List[keras.KerasTensor]], Dict[int, BackwardLayer], Dict[int, List[keras.KerasTensor]]]: - keys = [e for e in dico_nodes.keys()] - keys.sort(reverse=True) - fuse_layer = None - input_map: Dict[int, List[keras.KerasTensor]] = {} - backward_map: Dict[int, BackwardLayer] = {} - if perturbation_domain is None: - perturbation_domain = BoxDomain() - crown_map: Dict[int, List[keras.KerasTensor]] = {} - for depth in keys: - nodes = dico_nodes[depth] - for node in nodes: - layer = node.operation + # store oracle + oracle_map[id(node)] = oracle_bounds - parents = node.parent_nodes - if not len(parents): - # if 'debug' in kwargs.keys(): - # import pdb; pdb.set_trace() - input_map[id(node)] = input_tensors - else: - output: List[keras.KerasTensor] = [] - for parent in parents: - # do something - if id(parent) in output_map.keys(): - output += output_map[id(parent)] - else: - output_crown, fuse_layer_tmp = crown_( - node=parent, - ibp=ibp, - affine=affine, - input_map=input_map, - layer_fn=layer_fn, - backward_bounds=[], - backward_map=backward_map, - joint=joint, - fuse=True, - perturbation_domain=perturbation_domain, - output_map=crown_map, - merge_layers=None, # AKA merge_layers - fuse_layer=fuse_layer, - ) - if fuse_layer is None: - fuse_layer = fuse_layer_tmp - - # convert output_crown in the right mode - if set_mode_layer is None: - set_mode_layer = Convert2BackwardMode(get_mode(ibp, affine), perturbation_domain) - output_crown = set_mode_layer(input_tensors + output_crown) - output += to_list(output_crown) - # crown_map[id(parent)]=output_crown_ - - input_map[id(node)] = output - return input_map, backward_map, crown_map + return oracle_bounds def crown_model( model: Model, - input_tensors: List[keras.KerasTensor], - back_bounds: Optional[List[keras.KerasTensor]] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - ibp: bool = True, - affine: bool = True, - perturbation_domain: Optional[PerturbationDomain] = None, - finetune: bool = False, - forward_map: Optional[OutputMapDict] = None, - softmax_to_linear: bool = True, - joint: bool = True, - layer_fn: Callable[..., BackwardLayer] = to_backward, - fuse: bool = True, - **kwargs: Any, -) -> Tuple[List[keras.KerasTensor], List[keras.KerasTensor], Dict[int, BackwardLayer], None]: - if back_bounds is None: - back_bounds = [] - if forward_map is None: - forward_map = {} - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if not isinstance(model, Model): - raise ValueError() - # import time - # zero_time = time.process_time() - has_softmax = False - if softmax_to_linear: - model, has_softmax = softmax_2_linear(model) # do better because you modify the model eventually - - # layer_fn - ########## - has_iter = False - if layer_fn is not None and len(layer_fn.__code__.co_varnames) == 1 and "layer" in layer_fn.__code__.co_varnames: - has_iter = True - - if not has_iter: - layer_fn_copy = deepcopy(layer_fn) - - def func(layer: Layer) -> Layer: - return layer_fn_copy( - layer, - mode=get_mode(ibp, affine), - finetune=finetune, - perturbation_domain=perturbation_domain, - slope=slope, - ) - - layer_fn = func - - if not callable(layer_fn): - raise ValueError("Expected `layer_fn` argument to be a callable.") - ############### - - if len(back_bounds) and len(to_list(model.output)) > 1: - raise NotImplementedError() + layer_fn: Callable[[Layer, tuple[int, ...]], DecomonLayer], + backward_bounds: list[list[keras.KerasTensor]], + from_linear_backward_bounds: list[bool], + perturbation_domain_input: keras.KerasTensor, + perturbation_domain: PerturbationDomain, + oracle_map: Optional[dict[int, Union[list[keras.KerasTensor], list[list[keras.KerasTensor]]]]] = None, + forward_output_map: Optional[dict[int, list[keras.KerasTensor]]] = None, + forward_layer_map: Optional[dict[int, DecomonLayer]] = None, + crown_output_map: Optional[dict[int, list[keras.KerasTensor]]] = None, +) -> list[keras.KerasTensor]: + """Convert a functional keras model via crown algorithm (backward propagation) + + Hypothesis: potential embedded submodels have only one input and one output. + + Args: + model: keras model to convert + layer_fn: callable converting a layer and a model_output_shape into a (backward) decomon layer + perturbation_domain_input: perturbation domain input + perturbation_domain: perturbation domain type on keras model input + backward_bounds: should be of the same size as the number of model outputs + (each sublist potentially empty for starting with identity bounds) + from_linear_backward_bounds: specify if the backward_bounds come from a linear model (=> no batchsize + upper == lower) + oracle_map: already registered oracle bounds per node + forward_output_map: forward outputs per node from a previously performed forward conversion. + To be used for forward oracle. + forward_layer_map: forward decomon layer per node from a previously performed forward conversion. + To be used for forward oracle. + crown_output_map: output of subcrowns per output node. + Avoids relaunching a crown if several nodes share parents. + To be used for crown oracle. + model_output_shape: if submodel is True, must be set to the output_shape used in the current crown + + Returns: + concatenated propagated backward bounds corresponding to each output node of the keras model - # sort nodes from input to output - dico_nodes = get_depth_dict(model) - keys = [e for e in dico_nodes.keys()] - keys.sort(reverse=True) + """ + if oracle_map is None: + oracle_map = {} + if forward_layer_map is None: + forward_layer_map = {} + if forward_output_map is None: + forward_output_map = {} + if crown_output_map is None: + crown_output_map = {} + + # ensure (sub)model is functional + model = ensure_functional_model(model) - # generate input_map - if not finetune: - joint = True - set_mode_layer = Convert2BackwardMode(get_mode(ibp, affine), perturbation_domain) + # Retrieve output nodes in same order as model.outputs + output_nodes = get_output_nodes(model) - input_map, backward_map, crown_map = get_input_nodes( - model=model, - dico_nodes=dico_nodes, - ibp=ibp, - affine=affine, - input_tensors=input_tensors, - output_map=forward_map, - layer_fn=layer_fn, - joint=joint, - perturbation_domain=perturbation_domain, - set_mode_layer=set_mode_layer, - **kwargs, - ) - # time_1 = time.process_time() - # print('step1', time_1-zero_time) - # retrieve output nodes + # Apply crown on each output, with the appropriate backward_bounds and model_output_shape output = [] - output_nodes = dico_nodes[0] - # the ordering may change - output_names = [tensor._keras_history.operation.name for tensor in to_list(model.output)] - fuse_layer = None - for output_name in output_names: - for node in output_nodes: - if node.operation.name == output_name: - # compute with crown - output_crown, fuse_layer = crown_( - node=node, - ibp=ibp, - affine=affine, - input_map=input_map, - layer_fn=layer_fn, - backward_bounds=back_bounds, - backward_map=backward_map, - joint=joint, - fuse=fuse, - perturbation_domain=perturbation_domain, - output_map=crown_map, - fuse_layer=fuse_layer, - ) - # time_2 = time.process_time() - # print('step2', time_2-time_1) - if fuse: - # import pdb; pdb.set_trace() - output += to_list(set_mode_layer(input_tensors + output_crown)) - else: - output = output_crown + for node, backward_bounds_node, from_linear in zip(output_nodes, backward_bounds, from_linear_backward_bounds): + # new backward_map and new model_output_shape for each output node + model_output_shape = get_model_output_shape( + node=node, backward_bounds=backward_bounds_node, from_linear=from_linear + ) + backward_map_node: dict[int, DecomonLayer] = {} + + output_crown = crown( + node=node, + layer_fn=layer_fn, + model_output_shape=model_output_shape, + backward_bounds=backward_bounds_node, + backward_map=backward_map_node, + oracle_map=oracle_map, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + crown_output_map=crown_output_map, + submodels_stack=[], # main model, not in any submodel + perturbation_domain_input=perturbation_domain_input, + perturbation_domain=perturbation_domain, + ) + output += output_crown - return input_tensors, output, backward_map, None + return output def convert_backward( model: Model, - input_tensors: List[keras.KerasTensor], - back_bounds: Optional[List[keras.KerasTensor]] = None, - slope: Union[str, Slope] = Slope.V_SLOPE, - ibp: bool = True, - affine: bool = True, + perturbation_domain_input: keras.KerasTensor, perturbation_domain: Optional[PerturbationDomain] = None, - finetune: bool = False, - forward_map: Optional[OutputMapDict] = None, - softmax_to_linear: bool = True, - joint: bool = True, - layer_fn: Callable[..., BackwardLayer] = to_backward, - final_ibp: bool = True, - final_affine: bool = False, - input_dim: int = -1, + layer_fn: Callable[..., DecomonLayer] = to_decomon, + backward_bounds: Optional[list[keras.KerasTensor]] = None, + from_linear_backward_bounds: Union[bool, list[bool]] = False, + slope: Slope = Slope.V_SLOPE, + forward_output_map: Optional[dict[int, list[keras.KerasTensor]]] = None, + forward_layer_map: Optional[dict[int, DecomonLayer]] = None, + mapping_keras2decomon_classes: Optional[dict[type[Layer], type[DecomonLayer]]] = None, **kwargs: Any, -) -> Tuple[List[keras.KerasTensor], List[keras.KerasTensor], Dict[int, BackwardLayer], None]: - model = ensure_functional_model(model) - if input_dim == -1: - input_dim = get_input_dim(model) - if back_bounds is None: - back_bounds = [] - if forward_map is None: - forward_map = {} +) -> list[keras.KerasTensor]: + """Convert keras model via backward propagation. + + Prepare layer_fn by freezing all args except layer and model_output_shape. + Ensure that model is functional (transform sequential ones to functional equivalent ones). + + Args: + model: keras model to convert + perturbation_domain_input: perturbation domain input + perturbation_domain: perturbation domain type on keras model input + layer_fn: callable converting a layer and a model_output_shape into a (backward) decomon layer + mapping_keras2decomon_classes: user-defined mapping between keras and decomon layers classes, to be passed to `layer_fn` + backward_bounds: if set, should be of the same size as the number of model outputs times 4, + being the concatenation of backward bounds for each keras model output + from_linear_backward_bounds: specify if backward_bounds come from a linear model (=> no batchsize + upper == lower) + if a boolean, flag for each backward bound, else a list of boolean, one per keras model output. + forward_output_map: forward outputs per node from a previously performed forward conversion. + To be used for forward oracle if not empty. + forward_layer_map: forward decomon layer per node from a previously performed forward conversion. + To be used for forward oracle if not empty. + slope: slope used by decomon activation layers + **kwargs: keyword arguments to pass to layer_fn + + Returns: + propagated affine bounds (concatenated), output of the future decomon model + + """ if perturbation_domain is None: perturbation_domain = BoxDomain() - if len(back_bounds): - if len(back_bounds) == 1: - C = back_bounds[0] - bias = K.zeros_like(C[:, 0]) - back_bounds = [C, bias] * 2 - result = crown_model( - model=model, - input_tensors=input_tensors, - back_bounds=back_bounds, + backward_bounds_for_crown_model: list[list[keras.KerasTensor]] + if backward_bounds is None: + backward_bounds_for_crown_model = [[]] * len(model.outputs) + else: + # split backward bounds per model output + backward_bounds_for_crown_model = [backward_bounds[i : i + 4] for i in range(0, len(backward_bounds), 4)] + if isinstance(from_linear_backward_bounds, bool): + from_linear_backward_bounds = [from_linear_backward_bounds] * len(model.outputs) + + model = ensure_functional_model(model) + propagation = Propagation.BACKWARD + + layer_fn = include_kwargs_layer_fn( + layer_fn, slope=slope, - ibp=ibp, - affine=affine, perturbation_domain=perturbation_domain, - finetune=finetune, - forward_map=forward_map, - softmax_to_linear=softmax_to_linear, - joint=joint, - layer_fn=layer_fn, - fuse=True, + propagation=propagation, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, **kwargs, ) - input_tensors, output, backward_map, toto = result - mode_from = get_mode(ibp, affine) - mode_to = get_mode(final_ibp, final_affine) - output = Convert2Mode( - mode_from=mode_from, - mode_to=mode_to, + output = crown_model( + model=model, + layer_fn=layer_fn, + backward_bounds=backward_bounds_for_crown_model, + from_linear_backward_bounds=from_linear_backward_bounds, + perturbation_domain_input=perturbation_domain_input, perturbation_domain=perturbation_domain, - input_dim=input_dim, - )(output) - if mode_to != mode_from and mode_from == ForwardMode.IBP: - f_input = Lambda(lambda z: Concatenate(1)([z[0][:, None], z[1][:, None]])) - output[0] = f_input([input_tensors[1], input_tensors[0]]) - return input_tensors, output, backward_map, toto + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + ) + + return output + + +def get_model_output_shape(node: Node, backward_bounds: list[Tensor], from_linear: bool = False) -> tuple[int, ...]: + """Get outer model output shape w/o batchsize. + + If any backward bounds are passed, we deduce the outer keras model output shape from it. + We assume for that: + - backward_bounds = [w_l, b_l, w_u, b_u] + - we can have w_l, w_u in diagonal representation (w_l.shape == b_l.shape) + - we have the batchsize included in the backward_bounds, except if from_linear is True + + => model_output_shape = backward_bounds[1].shape[1:] + + If no backward bounds are given, we fall back to the output shape of the given output node. + + Args: + node: current output node of the (potentially inner) keras model to convert + backward_bounds: backward bounds specified for this node + from_linear: flag telling if backward_bounds are from a linear model (and thus w/o batchsize + lower==upper) + + Returns: + outer keras model output shape, excluding batchsize + + """ + if len(backward_bounds) == 0: + return node.outputs[0].shape[1:] + else: + _, b, _, _ = backward_bounds + if from_linear: + return b.shape + else: + return b.shape[1:] + + +def include_kwargs_layer_fn( + layer_fn: Callable[..., DecomonLayer], + perturbation_domain: PerturbationDomain, + propagation: Propagation, + slope: Slope, + mapping_keras2decomon_classes: Optional[dict[type[Layer], type[DecomonLayer]]], + **kwargs: Any, +) -> Callable[[Layer, tuple[int, ...]], DecomonLayer]: + """Include external parameters in the function converting layers + + In particular, include propagation=Propagation.BACKWARD. + + Args: + layer_fn: + perturbation_domain: + propagation: + slope: + mapping_keras2decomon_classes: + **kwargs: + + Returns: + + """ + + def func(layer: Layer, model_output_shape: tuple[int, ...]) -> DecomonLayer: + return layer_fn( + layer, + model_output_shape=model_output_shape, + slope=slope, + perturbation_domain=perturbation_domain, + propagation=propagation, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, + **kwargs, + ) + + return func diff --git a/src/decomon/models/convert.py b/src/decomon/models/convert.py index 1d5a25b3..c8233be5 100644 --- a/src/decomon/models/convert.py +++ b/src/decomon/models/convert.py @@ -1,37 +1,46 @@ -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +import logging +from collections.abc import Callable +from typing import Any, Optional, Union import keras -from keras.layers import InputLayer, Layer +from keras.layers import Layer from keras.models import Model -from decomon.backward_layers.core import BackwardLayer -from decomon.core import BoxDomain, PerturbationDomain, Slope, get_mode +from decomon.constants import ConvertMethod, Propagation, Slope +from decomon.layers import DecomonLayer from decomon.layers.convert import to_decomon +from decomon.layers.fuse import Fuse +from decomon.layers.input import ( + BackwardInput, + IdentityInput, + flatten_backward_bounds, + has_no_backward_bounds, +) +from decomon.layers.output import ConvertOutput +from decomon.layers.utils.symbolify import LinkToPerturbationDomainInput from decomon.models.backward_cloning import convert_backward from decomon.models.forward_cloning import ( - LayerMapDict, - OutputMapDict, convert_forward, convert_forward_functional_model, ) from decomon.models.models import DecomonModel from decomon.models.utils import ( - Convert2Mode, - ConvertMethod, - FeedDirection, - check_model2convert_inputs, ensure_functional_model, - get_direction, + generate_perturbation_domain_input, + get_final_ibp_affine_from_method, get_ibp_affine_from_method, - get_input_dim, - get_input_tensors, is_input_node, + method2propagation, preprocess_layer, + remove_last_softmax_layers, split_activation, ) +from decomon.perturbation_domain import BoxDomain, PerturbationDomain + +logger = logging.getLogger(__name__) -def _clone_keras_model(model: Model, layer_fn: Callable[[Layer], List[Layer]]) -> Model: +def _clone_keras_model(model: Model, layer_fn: Callable[[Layer], list[Layer]]) -> Model: if model.inputs is None: raise ValueError("model.inputs must be not None. You should call the model on a batch of data.") @@ -41,21 +50,17 @@ def _clone_keras_model(model: Model, layer_fn: Callable[[Layer], List[Layer]]) - # initialize output_map and layer_map to avoid # - recreating input layers # - and converting input layers and have a cycle around them - output_map: OutputMapDict = {} - layer_map: LayerMapDict = {} + output_map: dict[int, list[keras.KerasTensor]] = {} for depth, nodes in model._nodes_by_depth.items(): for node in nodes: if is_input_node(node): output_map[id(node)] = node.output_tensors - layer_map[id(node)] = node.operation - _, output, _, _, _ = convert_forward_functional_model( + output = convert_forward_functional_model( model=model, input_tensors=model.inputs, - softmax_to_linear=False, layer_fn=layer_fn, output_map=output_map, - layer_map=layer_map, ) return Model( @@ -72,186 +77,314 @@ def split_activations_in_keras_model( def preprocess_keras_model( model: Model, + rm_last_softmax: bool = False, ) -> Model: - return _clone_keras_model(model=model, layer_fn=preprocess_layer) + """Preprocess keras model before decomon conversion + + - split activations into separate layers + - if `rm_last_softmax` is true: remove last layer for each output, + if it is a softmax activation layer. + + Args: + model: model to preprocess + rm_last_softmax: flag to enable last softmax removing + + Returns: + + """ + # split activations + model = _clone_keras_model(model=model, layer_fn=preprocess_layer) + + # remove softmax last activation layers (for each output) + if rm_last_softmax: + model = remove_last_softmax_layers(model) + + return model # create status def convert( model: Model, - input_tensors: List[keras.KerasTensor], - method: Union[str, ConvertMethod] = ConvertMethod.CROWN, - ibp: bool = False, - affine: bool = False, - back_bounds: Optional[List[keras.KerasTensor]] = None, - layer_fn: Callable[..., Layer] = to_decomon, - slope: Union[str, Slope] = Slope.V_SLOPE, - input_dim: int = -1, - perturbation_domain: Optional[PerturbationDomain] = None, - finetune: bool = False, - forward_map: Optional[OutputMapDict] = None, - shared: bool = True, - softmax_to_linear: bool = True, - finetune_forward: bool = False, - finetune_backward: bool = False, + perturbation_domain_input: keras.KerasTensor, + perturbation_domain: PerturbationDomain, + method: ConvertMethod = ConvertMethod.CROWN, + backward_bounds: Optional[list[keras.KerasTensor]] = None, + from_linear_backward_bounds: Union[bool, list[bool]] = False, + layer_fn: Callable[..., DecomonLayer] = to_decomon, + slope: Slope = Slope.V_SLOPE, + forward_output_map: Optional[dict[int, list[keras.KerasTensor]]] = None, + forward_layer_map: Optional[dict[int, DecomonLayer]] = None, final_ibp: bool = False, - final_affine: bool = False, + final_affine: bool = True, + rm_last_softmax: bool = True, + mapping_keras2decomon_classes: Optional[dict[type[Layer], type[DecomonLayer]]] = None, **kwargs: Any, -) -> Tuple[ - List[keras.KerasTensor], - List[keras.KerasTensor], - Union[LayerMapDict, Dict[int, BackwardLayer]], - Optional[OutputMapDict], -]: - if back_bounds is None: - back_bounds = [] +) -> list[keras.KerasTensor]: + """ + + Args: + model: keras model to convert + perturbation_domain_input: perturbation domain input + perturbation_domain: perturbation domain type on keras model input + method: method used to convert the model to a decomon model. See `ConvertMethod`. + backward_bounds: backward bounds to propagate, concatenation of backward bounds for each keras model output + from_linear_backward_bounds: specify if backward_bounds come from a linear model (=> no batchsize + upper == lower) + if a boolean, flag for each backward bound, else a list of boolean, one per keras model output. + layer_fn: callable converting a layer and a model_output_shape into a decomon layer + mapping_keras2decomon_classes: user-defined mapping between keras and decomon layers classes, to be passed to `layer_fn` + slope: slope used by decomon activation layers + forward_output_map: forward outputs per node from a previously performed forward conversion. + To be used for forward oracle if not empty. + To be recomputed if empty and needed by the method. + forward_layer_map: forward decomon layer per node from a previously performed forward conversion. + To be used for forward oracle if not empty. + To be recomputed if empty and needed by the method. + final_ibp: specify if final outputs should include constant bounds. + final_affine: specify if final outputs should include affine bounds. + rm_last_softmax: specify if last softmax layer (for each output) are removed during preprocessing + **kwargs: keyword arguments to pass to layer_fn + + Returns: + propagated bounds (concatenated), output of the future decomon model + + """ if perturbation_domain is None: perturbation_domain = BoxDomain() - if finetune: - finetune_forward = True - finetune_backward = True - if input_dim == -1: - input_dim = get_input_dim(model) - if isinstance(method, str): - method = ConvertMethod(method.lower()) + if not final_ibp and not final_affine: + raise ValueError("One of final_ibp and final_affine must be True.") + + if isinstance(from_linear_backward_bounds, bool): + from_linear_backward_bounds = [from_linear_backward_bounds] * len(model.outputs) # prepare the Keras Model: split non-linear activation functions into separate Activation layers - model = preprocess_keras_model(model) + model = preprocess_keras_model(model=model, rm_last_softmax=rm_last_softmax) - layer_map: Union[LayerMapDict, Dict[int, BackwardLayer]] + # loop over propagations needed + propagations = method2propagation(method) + ibp, affine = get_ibp_affine_from_method(method) + output: list[keras.KerasTensor] = [] - if method != ConvertMethod.CROWN: - input_tensors, output, layer_map, forward_map = convert_forward( + if Propagation.FORWARD in propagations: + output, forward_output_map, forward_layer_map = convert_forward( model=model, - input_tensors=input_tensors, + perturbation_domain_input=perturbation_domain_input, layer_fn=layer_fn, slope=slope, - input_dim=input_dim, - dc_decomp=False, perturbation_domain=perturbation_domain, ibp=ibp, affine=affine, - finetune=finetune_forward, - shared=shared, - softmax_to_linear=softmax_to_linear, - back_bounds=back_bounds, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, + **kwargs, ) - if get_direction(method) == FeedDirection.BACKWARD: - input_tensors, output, layer_map, forward_map = convert_backward( + if Propagation.BACKWARD in propagations: + output = convert_backward( model=model, - input_tensors=input_tensors, - back_bounds=back_bounds, - slope=slope, + perturbation_domain_input=perturbation_domain_input, perturbation_domain=perturbation_domain, - ibp=ibp, - affine=affine, - finetune=finetune_backward, - forward_map=forward_map, - final_ibp=final_ibp, - final_affine=final_affine, - input_dim=input_dim, + layer_fn=layer_fn, + backward_bounds=backward_bounds, + from_linear_backward_bounds=from_linear_backward_bounds, + slope=slope, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, **kwargs, ) - else: - # check final_ibp and final_affine - mode_from = get_mode(ibp, affine) - mode_to = get_mode(final_ibp, final_affine) - output = Convert2Mode( - mode_from=mode_from, mode_to=mode_to, perturbation_domain=perturbation_domain, input_dim=input_dim - )(output) + # output updated mode + affine = True + ibp = False + + elif backward_bounds is not None: + # Fuse backward_bounds with forward bounds if method not using backward propagation + fuse_layer = Fuse( + ibp_1=ibp, + affine_1=affine, + ibp_2=False, + affine_2=True, + m1_input_shape=model.inputs[0].shape[1:], + m_1_output_shapes=[t.shape[1:] for t in model.outputs], + from_linear_2=from_linear_backward_bounds, + ) + output = fuse_layer((output, backward_bounds)) + # output updated mode + affine = fuse_layer.affine_fused + ibp = fuse_layer.ibp_fused + + # Update output for final_ibp and final_affine + if final_ibp != ibp or final_affine != affine: + convert_layer = ConvertOutput( + ibp_from=ibp, + affine_from=affine, + ibp_to=final_ibp, + affine_to=final_affine, + perturbation_domain=perturbation_domain, + model_output_shapes=[t.shape[1:] for t in model.outputs], + ) + if convert_layer.needs_perturbation_domain_inputs(): + output.append(perturbation_domain_input) + output = convert_layer(output) # build decomon model - return input_tensors, output, layer_map, forward_map + return output def clone( model: Model, - layer_fn: Callable[..., Layer] = to_decomon, slope: Union[str, Slope] = Slope.V_SLOPE, perturbation_domain: Optional[PerturbationDomain] = None, method: Union[str, ConvertMethod] = ConvertMethod.CROWN, - back_bounds: Optional[List[keras.KerasTensor]] = None, - finetune: bool = False, - shared: bool = True, - finetune_forward: bool = False, - finetune_backward: bool = False, - extra_inputs: Optional[List[keras.KerasTensor]] = None, - to_keras: bool = True, + backward_bounds: Optional[Union[keras.KerasTensor, list[keras.KerasTensor], list[list[keras.KerasTensor]]]] = None, + from_linear_backward_bounds: Union[bool, list[bool]] = False, final_ibp: Optional[bool] = None, final_affine: Optional[bool] = None, + layer_fn: Callable[..., DecomonLayer] = to_decomon, + forward_output_map: Optional[dict[int, list[keras.KerasTensor]]] = None, + forward_layer_map: Optional[dict[int, DecomonLayer]] = None, + mapping_keras2decomon_classes: Optional[dict[type[Layer], type[DecomonLayer]]] = None, + rm_last_softmax: bool = True, **kwargs: Any, ) -> DecomonModel: + """ + + Args: + model: keras model to convert + slope: slope used by decomon activation layers + perturbation_domain: perturbation domain type on keras model input + method: method used to convert the model to a decomon model. See `ConvertMethod`. + backward_bounds: backward bounds to propagate, see `BackwardInput` for conventions + to be fused with ibp + affine bounds computed on the keras model outputs + from_linear_backward_bounds: specify if backward_bounds come from a linear model (=> no batchsize + upper == lower) + if a boolean, flag for each backward bound, else a list of boolean, one per keras model output. + final_ibp: specify if final outputs should include constant bounds. + Default to False except for forward-ibp and forward-hybrid. + final_affine: specify if final outputs should include affine bounds. + Default to True all methods except forward-ibp. + layer_fn: callable converting a layer and a model_output_shape into a decomon layer + mapping_keras2decomon_classes: user-defined mapping between keras and decomon layers classes, to be passed to `layer_fn` + forward_output_map: forward outputs per node from a previously performed forward conversion. + To be used for forward oracle if not empty. + To be recomputed if empty and needed by the method. + forward_layer_map: forward decomon layer per node from a previously performed forward conversion. + To be used for forward oracle if not empty. + To be recomputed if empty and needed by the method. + rm_last_softmax: specify if last softmax layer (for each output) are removed during preprocessing + **kwargs: keyword arguments to pass to layer_fn + + Returns: + decomon model mapping perturbation domain input and backward bounds + to ibp + affine bounds (according to final_ibp and final_affine) on model outputs + fused with the backward bounds + + The resulting DecomonModel have flatten inputs and outputs: + - inputs: [perturbation_domain_input] + backward_bounds_flattened + where backward_bounds_flattened is computed from backward_bounds as follows: + - None -> [] + - single tensor -> [backward_bounds] + - list of tensors -> backward_bounds + - list of list of tensors -> flatten: [t for sublist in backward_bounds for t in sublist] + + - single tensor -> [backward_bounds, 0, backward_bounds, 0] * nb_model_outputs + - list of 2 tensors (upper = lower bounds, for all model outputs) -> backward_bounds * 2 * nb_model_outputs + - list of 4 tensors -> backward_bounds * nb_model_outputs + - list of 4 * nb_model_outputs tensors -> backward_bounds + - list of list of tensors -> ensure having nb_model_outputs sublists -> flatten: [t for sublist in backward_bounds for t in sublist] + + - outputs: sum_{i} (affine_bounds_from[i] + constant_bounds_from[i]) + being the affine and constant bounds for each output of the keras model, with + - i: the indice of the model output considered + - sum_{i}: the concatenation of subsequent lists over i + - affine_bounds_from[i]: empty if `final_affine` is False + - constant_bounds_from[i]: empty if `final_ibp` is False + + """ + # Store model name (before converting to functional) + model_name = model.name + + # Check hypotheses: functional model + 1 flattened input + model = ensure_functional_model(model) + if len(model.inputs) > 1: + raise ValueError("The model must have only 1 input to be converted.") + + # Args preprocessing if perturbation_domain is None: perturbation_domain = BoxDomain() - if back_bounds is None: - back_bounds = [] - if extra_inputs is None: - extra_inputs = [] - if not isinstance(model, Model): - raise ValueError("Expected `model` argument " "to be a `Model` instance, got ", model) - - ibp, affine = get_ibp_affine_from_method(method) - if final_ibp is None: - final_ibp = ibp - if final_affine is None: - final_affine = affine if isinstance(method, str): method = ConvertMethod(method.lower()) - if not to_keras: - raise NotImplementedError("Only convert to Keras for now.") + if isinstance(slope, str): + slope = Slope(slope.lower()) + + default_final_ibp, default_final_affine = get_final_ibp_affine_from_method(method) + if final_ibp is None: + final_ibp = default_final_ibp + if final_affine is None: + final_affine = default_final_affine - if finetune: - finetune_forward = True - finetune_backward = True + # preprocess backward_bounds + backward_bounds_flattened: Optional[list[keras.KerasTensor]] + backward_bounds_for_convert: Optional[list[keras.KerasTensor]] + if has_no_backward_bounds(backward_bounds): + backward_bounds_flattened = None + backward_bounds_for_convert = None + else: + if isinstance(from_linear_backward_bounds, bool): + from_linear_backward_bounds = [from_linear_backward_bounds] * len(model.outputs) + # flatten backward bounds + backward_bounds_flattened = flatten_backward_bounds(backward_bounds) + # prepare for convert: ensure having 4 * nb_model_outputs tensors + backward_bounds_for_convert = BackwardInput( + model_output_shapes=[t.shape[1:] for t in model.outputs], from_linear=from_linear_backward_bounds + )(backward_bounds_flattened) - # Check hypotheses: functional model + 1 flattened input - model = ensure_functional_model(model) - check_model2convert_inputs(model) + perturbation_domain_input = generate_perturbation_domain_input( + model=model, perturbation_domain=perturbation_domain, name=f"perturbation_domain_input_{model_name}" + ) - z_tensor, input_tensors = get_input_tensors( + output = convert( model=model, + perturbation_domain_input=perturbation_domain_input, perturbation_domain=perturbation_domain, - ibp=ibp, - affine=affine, - ) - - _, output, _, _ = convert( - model, + method=method, + backward_bounds=backward_bounds_for_convert, + from_linear_backward_bounds=from_linear_backward_bounds, layer_fn=layer_fn, slope=slope, - input_tensors=input_tensors, - back_bounds=back_bounds, - method=method, - ibp=ibp, - affine=affine, - input_dim=-1, - perturbation_domain=perturbation_domain, - finetune=finetune, - shared=shared, - softmax_to_linear=True, - layer_map={}, - forward_map={}, - finetune_forward=finetune_forward, - finetune_backward=finetune_backward, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, final_ibp=final_ibp, final_affine=final_affine, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, + rm_last_softmax=rm_last_softmax, + **kwargs, ) - back_bounds_from_inputs = [elem for elem in back_bounds if isinstance(elem._keras_history.operation, InputLayer)] + # model ~ identity: in backward propagation, output can still be empty => diag representation of identity + if len(output) == 0: # identity bounds propagated as is (only if successive identity layers) + output = IdentityInput(perturbation_domain=perturbation_domain)(perturbation_domain_input) + + # full linear model? => batch independent output + if any([not isinstance(o, keras.KerasTensor) for o in output]): + logger.warning( + "Some propagated bounds have been eagerly computed, being independent from batch. " + "This should only be possible if the keras model is fully affine. -" + "We will make them artificially depend on perturbation input in order to create the DecomonModel." + ) + # Insert batch axis and repeat it to get the correct batchsize + output = LinkToPerturbationDomainInput()([perturbation_domain_input] + output) + + decomon_inputs = [perturbation_domain_input] + if backward_bounds_flattened is not None: + decomon_inputs += backward_bounds_flattened return DecomonModel( - inputs=[z_tensor] + back_bounds_from_inputs + extra_inputs, + inputs=decomon_inputs, outputs=output, perturbation_domain=perturbation_domain, - dc_decomp=False, method=method, ibp=final_ibp, affine=final_affine, - finetune=finetune, - shared=shared, - backward_bounds=(len(back_bounds) > 0), + model=model, ) diff --git a/src/decomon/models/crown.py b/src/decomon/models/crown.py deleted file mode 100644 index 2ee4a49c..00000000 --- a/src/decomon/models/crown.py +++ /dev/null @@ -1,186 +0,0 @@ -# extra layers necessary for backward LiRPA -from typing import Any, Dict, List, Optional, Tuple, Union - -import keras.ops as K -from keras.layers import InputSpec, Layer -from keras.src.layers.merging.dot import batch_dot - -from decomon.core import ForwardMode, PerturbationDomain -from decomon.keras_utils import BatchedDiagLike -from decomon.types import BackendTensor - - -class Fuse(Layer): - def __init__(self, mode: Union[str, ForwardMode], **kwargs: Any): - super().__init__(**kwargs) - self.mode = ForwardMode(mode) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - inputs_wo_backward_bounds = inputs[:-4] - backward_bounds = inputs[-4:] - - if self.mode == ForwardMode.AFFINE: - x_0, w_f_u, b_f_u, w_f_l, b_f_l = inputs_wo_backward_bounds - elif self.mode == ForwardMode.HYBRID: - x_0, u_c, w_f_u, b_f_u, l_c, w_f_l, b_f_l = inputs_wo_backward_bounds - else: - return backward_bounds - - return merge_with_previous([w_f_u, b_f_u, w_f_l, b_f_l] + backward_bounds) - - def compute_output_shape(self, input_shape: List[Tuple[Optional[int], ...]]) -> List[Tuple[Optional[int], ...]]: - inputs_wo_backward_bounds_shapes = input_shape[:-4] - backward_bounds_shapes = input_shape[-4:] - - if self.mode == ForwardMode.AFFINE: - x_0_shape, w_f_u_shape, b_f_u_shape, w_f_l_shape, b_f_l_shape = inputs_wo_backward_bounds_shapes - elif self.mode == ForwardMode.HYBRID: - ( - x_0_shape, - u_c_shape, - w_f_u_shape, - b_f_u_shape, - l_c_shape, - w_f_l_shape, - b_f_l_shape, - ) = inputs_wo_backward_bounds_shapes - else: - return backward_bounds_shapes - - return merge_with_previous_compute_output_shape( - [w_f_u_shape, b_f_u_shape, w_f_l_shape, b_f_l_shape] + backward_bounds_shapes - ) - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update({"mode": self.mode}) - return config - - -class Convert2BackwardMode(Layer): - def __init__(self, mode: Union[str, ForwardMode], perturbation_domain: PerturbationDomain, **kwargs: Any): - super().__init__(**kwargs) - self.mode = ForwardMode(mode) - self.perturbation_domain = perturbation_domain - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - inputs_wo_backward_bounds = inputs[:-4] - backward_bounds = inputs[-4:] - w_u_out, b_u_out, w_l_out, b_l_out = backward_bounds - empty_tensor = K.convert_to_tensor([]) - - if self.mode in [ForwardMode.AFFINE, ForwardMode.HYBRID]: - x_0 = inputs_wo_backward_bounds[0] - else: - u_c, l_c = inputs_wo_backward_bounds - x_0 = K.concatenate([K.expand_dims(l_c, 1), K.expand_dims(u_c, 1)], 1) - - if self.mode in [ForwardMode.IBP, ForwardMode.HYBRID]: - u_c_out = self.perturbation_domain.get_upper(x_0, w_u_out, b_u_out) - l_c_out = self.perturbation_domain.get_lower(x_0, w_l_out, b_l_out) - else: - u_c_out, l_c_out = empty_tensor, empty_tensor - - if self.mode == ForwardMode.AFFINE: - return [x_0] + backward_bounds - elif self.mode == ForwardMode.IBP: - return [u_c_out, l_c_out] - elif self.mode == ForwardMode.HYBRID: - return [x_0, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out] - - else: - raise ValueError(f"Unknwon mode {self.mode}") - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update({"mode": self.mode, "perturbation_domain": self.perturbation_domain}) - return config - - -class MergeWithPrevious(Layer): - def __init__( - self, - input_shape_layer: Optional[Tuple[int, ...]] = None, - backward_shape_layer: Optional[Tuple[int, ...]] = None, - **kwargs: Any, - ): - super().__init__(**kwargs) - self.input_shape_layer = input_shape_layer - self.backward_shape_layer = backward_shape_layer - if not (input_shape_layer is None) and not (backward_shape_layer is None): - _, n_in, n_h = input_shape_layer - _, n_h, n_out = backward_shape_layer - - w_out_spec = InputSpec(ndim=3, axes={-1: n_h, -2: n_in}) - b_out_spec = InputSpec(ndim=2, axes={-1: n_h}) - w_b_spec = InputSpec(ndim=3, axes={-1: n_out, -2: n_h}) - b_b_spec = InputSpec(ndim=2, axes={-1: n_out}) - self.input_spec = [w_out_spec, b_out_spec] * 2 + [w_b_spec, b_b_spec] * 2 # - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - return merge_with_previous(inputs) - - def compute_output_shape(self, input_shape: List[Tuple[Optional[int], ...]]) -> List[Tuple[Optional[int], ...]]: - return merge_with_previous_compute_output_shape(input_shape) - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - { - "input_shape_layer": self.input_shape_layer, - "backward_shape_layer": self.backward_shape_layer, - } - ) - return config - - -def merge_with_previous_compute_output_shape( - input_shapes: List[Tuple[Optional[int], ...]] -) -> List[Tuple[Optional[int], ...]]: - w_b_in_shape, b_b_in_shape = input_shapes[-2:] - w_out_in_shape, b_out_in_shape = input_shapes[:2] - batch_size, flattened_keras_input_shape, flattened_keras_output_shape = w_out_in_shape - _, _, flattened_model_output_shape = w_b_in_shape - b_b_out_shape = b_b_in_shape - w_b_out_shape = batch_size, flattened_keras_input_shape, flattened_model_output_shape - return [w_b_out_shape, b_b_out_shape] * 2 - - -def merge_with_previous(inputs: List[BackendTensor]) -> List[BackendTensor]: - w_u_out, b_u_out, w_l_out, b_l_out, w_b_u, b_b_u, w_b_l, b_b_l = inputs - - # w_u_out (None, n_h_in, n_h_out) - # w_b_u (None, n_h_out, n_out) - - # w_u_out_ (None, n_h_in, n_h_out, 1) - # w_b_u_ (None, 1, n_h_out, n_out) - # w_u_out_*w_b_u_ (None, n_h_in, n_h_out, n_out) - - # result (None, n_h_in, n_out) - - if len(w_u_out.shape) == 2: - w_u_out = BatchedDiagLike()(w_u_out) - - if len(w_l_out.shape) == 2: - w_l_out = BatchedDiagLike()(w_l_out) - - if len(w_b_u.shape) == 2: - w_b_u = BatchedDiagLike()(w_b_u) - - if len(w_b_l.shape) == 2: - w_b_l = BatchedDiagLike()(w_b_l) - - # import pdb; pdb.set_trace() - - z_value = K.cast(0.0, dtype=w_u_out.dtype) - w_b_u_pos = K.maximum(w_b_u, z_value) - w_b_u_neg = K.minimum(w_b_u, z_value) - w_b_l_pos = K.maximum(w_b_l, z_value) - w_b_l_neg = K.minimum(w_b_l, z_value) - - w_u = batch_dot(w_u_out, w_b_u_pos, (-1, -2)) + batch_dot(w_l_out, w_b_u_neg, (-1, -2)) - w_l = batch_dot(w_l_out, w_b_l_pos, (-1, -2)) + batch_dot(w_u_out, w_b_l_neg, (-1, -2)) - b_u = batch_dot(b_u_out, w_b_u_pos, (-1, -2)) + batch_dot(b_l_out, w_b_u_neg, (-1, -2)) + b_b_u - b_l = batch_dot(b_l_out, w_b_l_pos, (-1, -2)) + batch_dot(b_u_out, w_b_l_neg, (-1, -2)) + b_b_l - - return [w_u, b_u, w_l, b_l] diff --git a/src/decomon/models/forward_cloning.py b/src/decomon/models/forward_cloning.py index e2d8e7af..0cb0ffce 100644 --- a/src/decomon/models/forward_cloning.py +++ b/src/decomon/models/forward_cloning.py @@ -3,151 +3,158 @@ It inherits from keras Sequential class. """ -import inspect -from copy import deepcopy -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from collections.abc import Callable +from typing import Any, Optional import keras from keras.layers import InputLayer, Layer from keras.models import Model -from keras.src.utils.python_utils import to_list -from decomon.core import BoxDomain, PerturbationDomain, Slope +from decomon.constants import Propagation, Slope +from decomon.layers import DecomonLayer from decomon.layers.convert import to_decomon -from decomon.layers.core import DecomonLayer -from decomon.layers.utils import softmax_to_linear as softmax_2_linear +from decomon.layers.input import ForwardInput +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec from decomon.models.utils import ( ensure_functional_model, get_depth_dict, - get_inner_layers, - get_input_dim, + get_output_nodes, prepare_inputs_for_layer, wrap_outputs_from_layer_in_list, ) +from decomon.perturbation_domain import BoxDomain, PerturbationDomain -OutputMapKey = Union[str, int] -OutputMapVal = Union[List[keras.KerasTensor], "OutputMapDict"] -OutputMapDict = Dict[OutputMapKey, OutputMapVal] -LayerMapVal = Union[List[DecomonLayer], "LayerMapDict"] -LayerMapDict = Dict[int, LayerMapVal] - - -def include_dim_layer_fn( - layer_fn: Callable[..., Layer], - input_dim: int, - slope: Union[str, Slope] = Slope.V_SLOPE, - dc_decomp: bool = False, +def convert_forward( + model: Model, + perturbation_domain_input: keras.KerasTensor, + layer_fn: Callable[..., DecomonLayer] = to_decomon, + slope: Slope = Slope.V_SLOPE, perturbation_domain: Optional[PerturbationDomain] = None, ibp: bool = True, affine: bool = True, - finetune: bool = False, - shared: bool = True, -) -> Callable[[Layer], List[Layer]]: - """include external parameters inside the translation of a layer to its decomon counterpart + mapping_keras2decomon_classes: Optional[dict[type[Layer], type[DecomonLayer]]] = None, + **kwargs: Any, +) -> tuple[list[keras.KerasTensor], dict[int, list[keras.KerasTensor]], dict[int, DecomonLayer]]: + """Convert keras model via forward propagation. + + Prepare layer_fn by freezing all args except layer. + Ensure that model is functional (transform sequential ones to functional equivalent ones). Args: - layer_fn - input_dim - dc_decomp - perturbation_domain - finetune + model: keras model to convert + perturbation_domain_input: perturbation domain input (input to the future decomon model). + Used to convert affine bounds into constant ones. + layer_fn: conversion function on layers. Default to `to_decomon()`. + mapping_keras2decomon_classes: user-defined mapping between keras and decomon layers classes, to be passed to `layer_fn` + slope: slope used by decomon activation layers + perturbation_domain: perturbation domain type for keras input + ibp: specify if constant bounds are propagated + affine: specify if affine bounds are propagated + **kwargs: keyword arguments to pass to layer_fn Returns: + output, output_map, layer_map: + - output: propagated bounds (concatenated), see `DecomonLayer.call()` for the format. + output of the future decomon model + - output_map: output of each converted node. Can be used to feed oracle bounds used by backward conversion. + - layer_map: converted layer corresponding to each node. Can be used to transform output_map into oracle bounds """ if perturbation_domain is None: perturbation_domain = BoxDomain() - if not callable(layer_fn): - raise ValueError("Expected `layer_fn` argument to be a callable.") - - layer_fn_copy = deepcopy(layer_fn) - - if "input_dim" in inspect.signature(layer_fn).parameters: - - def func(layer: Layer) -> List[Layer]: - return [ - layer_fn_copy( - layer, - input_dim=input_dim, - slope=slope, - perturbation_domain=perturbation_domain, - dc_decomp=dc_decomp, - ibp=ibp, - affine=affine, - finetune=finetune, - shared=shared, - ) - ] - - else: - - def func(layer: Layer) -> List[Layer]: - return [layer_fn_copy(layer)] - - return func - - -def convert_forward( - model: Model, - input_tensors: List[keras.KerasTensor], - layer_fn: Callable[..., Layer] = to_decomon, - slope: Union[str, Slope] = Slope.V_SLOPE, - input_dim: int = -1, - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - ibp: bool = True, - affine: bool = True, - finetune: bool = False, - shared: bool = True, - softmax_to_linear: bool = True, - **kwargs: Any, -) -> Tuple[List[keras.KerasTensor], List[keras.KerasTensor], LayerMapDict, OutputMapDict]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - if not isinstance(model, Model): raise ValueError("Expected `model` argument " "to be a `Model` instance, got ", model) model = ensure_functional_model(model) + model_input_shape = model.inputs[0].shape[1:] + + propagation = Propagation.FORWARD + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + layer_input_shape=model_input_shape, + ) - if input_dim == -1: - input_dim = get_input_dim(model) + if inputs_outputs_spec.needs_perturbation_domain_inputs(): + perturbation_domain_inputs = [perturbation_domain_input] + else: + perturbation_domain_inputs = [] - layer_fn_to_list = include_dim_layer_fn( + layer_fn_to_list = include_kwargs_layer_fn( layer_fn, - input_dim=input_dim, + model_input_shape=model_input_shape, slope=slope, perturbation_domain=perturbation_domain, ibp=ibp, affine=affine, - finetune=finetune, - shared=shared, - ) # return a list of Decomon layers + propagation=propagation, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, + **kwargs, + ) + + # generate input tensors + forward_input_layer = ForwardInput(perturbation_domain=perturbation_domain, ibp=ibp, affine=affine) + input_tensors_wo_pertubation_domain_inputs = forward_input_layer(perturbation_domain_input) - input_tensors, output, layer_map, output_map, _ = convert_forward_functional_model( + output_map: dict[int, list[keras.KerasTensor]] = {} + layer_list_map: dict[int, list[DecomonLayer]] = {} + output = convert_forward_functional_model( model=model, - input_tensors=input_tensors, + input_tensors=input_tensors_wo_pertubation_domain_inputs, layer_fn=layer_fn_to_list, - softmax_to_linear=softmax_to_linear, + common_inputs_part=perturbation_domain_inputs, + output_map=output_map, + layer_map=layer_list_map, ) + layer_map: dict[int, DecomonLayer] = {k: v[0] for k, v in layer_list_map.items()} - return input_tensors, output, layer_map, output_map + return output, output_map, layer_map def convert_forward_functional_model( model: Model, - layer_fn: Callable[[Layer], List[Layer]], - input_tensors: List[keras.KerasTensor], - softmax_to_linear: bool = True, - count: int = 0, - output_map: Optional[OutputMapDict] = None, - layer_map: Optional[LayerMapDict] = None, - layer2layer_map: Optional[Dict[int, List[Layer]]] = None, -) -> Tuple[List[keras.KerasTensor], List[keras.KerasTensor], LayerMapDict, OutputMapDict, Dict[int, List[Layer]]]: - if softmax_to_linear: - model, has_softmax = softmax_2_linear(model) + layer_fn: Callable[[Layer], list[Layer]], + input_tensors: list[keras.KerasTensor], + common_inputs_part: Optional[list[keras.KerasTensor]] = None, + output_map: Optional[dict[int, list[keras.KerasTensor]]] = None, + layer_map: Optional[dict[int, list[Layer]]] = None, + submodel: bool = False, +) -> list[keras.KerasTensor]: + """Convert a functional keras model via forward propagation. + + Used + - for decomon conversion in forward mode with layer_fn=lambda l: [to_decomon(l)] + - also for preprocessing keras models (e.g. splitting activation layers) with layer_fn=preprocess_layer + + Hypothesis: potential embedded submodels have only one input and one output. + + Args: + model: keras model to convert + layer_fn: callable converting a layer into a list of converted layers + input_tensors: input tensors used by the converted (list of) layer(s) corresponding to the keras model input nodes + common_inputs_part: inputs part common to all converted layers. + The inputs of a converted layers is + the concatenation of the outputs of the converted layers corresponding to its inputs nodes + + this common_inputs_part (only once even for merging layers) + This allows to pass perturbation_domain_inputs only once when creating the decomon forward version of a keras model. + Default to an empty list. + output_map: output of each converted node (to be used when called recursively on submodels) + When a layer is converted into several layers, we store the propagated output of the last one. + This map is updated during the conversion. + layer_map: map between node and converted layers (to be used when called recursively on submodels) + This map is updated during the conversion. + submodel: specify if called from within another conversion to propagate through an embedded submodel + + Returns: + concatenated outputs of the converted layers corresponding to the output nodes of the keras model + + """ + if common_inputs_part is None: + common_inputs_part = [] # ensure the model is functional (to be able to use get_depth_dict), convert sequential ones into functional ones model = ensure_functional_model(model) @@ -161,9 +168,7 @@ def convert_forward_functional_model( output_map = {} if layer_map is None: layer_map = {} - if layer2layer_map is None: - layer2layer_map = {} - output: List[keras.KerasTensor] = input_tensors + output: list[keras.KerasTensor] = input_tensors for depth in keys: nodes = dico_nodes[depth] for node in nodes: @@ -177,51 +182,85 @@ def convert_forward_functional_model( output += output_map[id(parent)] if isinstance(layer, InputLayer): - # no conversion, no transformation for output (instead of trying to pass in identity layer) - layer_map[id(node)] = layer + # no conversion, propagate output unchanged + pass elif isinstance(layer, Model): - ( - _, - output, - layer_map_submodel, - output_map_submodel, - layer2layer_map_submodel, - ) = convert_forward_functional_model( + output = convert_forward_functional_model( model=layer, input_tensors=output, + common_inputs_part=common_inputs_part, layer_fn=layer_fn, - softmax_to_linear=softmax_to_linear, - count=count, - layer2layer_map=layer2layer_map, + output_map=output_map, + layer_map=layer_map, ) - count = count + get_inner_layers(layer) - layer_map.update(layer_map_submodel) - output_map.update(output_map_submodel) - layer2layer_map.update(layer2layer_map_submodel) - layer_map[id(node)] = layer_map_submodel else: - if id(layer) in layer2layer_map: + if id(node) in layer_map: # avoid converting twice layers that are shared by several nodes - converted_layers = layer2layer_map[id(layer)] + converted_layers = layer_map[id(node)] else: converted_layers = layer_fn(layer) - layer2layer_map[id(layer)] = converted_layers + layer_map[id(node)] = converted_layers for converted_layer in converted_layers: - converted_layer.name = f"{converted_layer.name}_{count}" - count += 1 - output = converted_layer(prepare_inputs_for_layer(output)) - if len(converted_layers) > 1: - output_map[f"{id(node)}_{converted_layer.name}"] = wrap_outputs_from_layer_in_list(output) - layer_map[id(node)] = converted_layers + output = wrap_outputs_from_layer_in_list( + converted_layer(prepare_inputs_for_layer(output + common_inputs_part)) + ) output_map[id(node)] = wrap_outputs_from_layer_in_list(output) output = [] - output_nodes = dico_nodes[0] - # the ordering may change - output_names = [tensor._keras_history.operation.name for tensor in to_list(model.output)] - for output_name in output_names: - for node in output_nodes: - if node.operation.name == output_name: - output += output_map[id(node)] - - return input_tensors, output, layer_map, output_map, layer2layer_map + output_nodes = get_output_nodes(model) + if submodel and len(output_nodes) > 1: + raise NotImplementedError( + "convert_forward_functional_model() not yet implemented for model " + "whose embedded submodels have multiple outputs." + ) + for node in output_nodes: + output += output_map[id(node)] + + return output + + +def include_kwargs_layer_fn( + layer_fn: Callable[..., Layer], + model_input_shape: tuple[int, ...], + perturbation_domain: PerturbationDomain, + ibp: bool, + affine: bool, + propagation: Propagation, + slope: Slope, + mapping_keras2decomon_classes: Optional[dict[type[Layer], type[DecomonLayer]]], + **kwargs: Any, +) -> Callable[[Layer], list[Layer]]: + """Include external parameters in the function converting layers + + In particular, include propagation=Propagation.FORWARD. + + Args: + layer_fn: + model_input_shape: + perturbation_domain: + ibp: + affine: + slope: + mapping_keras2decomon_classes: + **kwargs: + + Returns: + + """ + + def func(layer: Layer) -> list[Layer]: + return [ + layer_fn( + layer, + model_input_shape=model_input_shape, + slope=slope, + perturbation_domain=perturbation_domain, + ibp=ibp, + affine=affine, + propagation=propagation, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, + **kwargs, + ) + ] + + return func diff --git a/src/decomon/models/models.py b/src/decomon/models/models.py index b1ff067c..1c61c536 100644 --- a/src/decomon/models/models.py +++ b/src/decomon/models/models.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Union import keras import keras.ops as K @@ -6,44 +6,71 @@ from keras import Model from keras.utils import serialize_keras_object -from decomon.core import ( - BoxDomain, - GridDomain, - InputsOutputsSpec, - Option, - PerturbationDomain, -) -from decomon.models.utils import ConvertMethod +from decomon.constants import ConvertMethod +from decomon.layers.output import ConvertOutput +from decomon.perturbation_domain import PerturbationDomain class DecomonModel(keras.Model): + """Decomon models computing constant and/or affine bounds on outputs of a keras model. + + This is the model generated by `decomon.clone()`. + + Inputs: perturbation_domain_inputs + backward_bounds with + - perturbation_domain_inputs: peturbation domain input wrapped in a list + - backward_bounds: the precomputed backward_bounds if the decomon model has been generated with it, empty else. + + Outputs: sum_{i} (affine_bounds[i] + constant_bounds[i]): the affine and constant bounds computed + - affine_bounds_to[i]: affine bounds on i-th output of the keras model; + empty if `self.affine` is False, else: w_l[i], b_l[i], w_u[i], b_u[i] so that + + x * w_l[i] + b_l[i] <= keras_model(x)[i] <= x * w_u[i] + b_u[i] + + - constant_bounds_to[i]: constant bounds on i-th output of the keras model; + empty if `self.ibp` is False, else lower[i], upper[i] so that + + lower[i] <= keras_model(x)[i] <= upper[i] + + See `decomon.clone()` doc for more details on format. + + Args: + + + + """ + def __init__( self, - inputs: Union[keras.KerasTensor, List[keras.KerasTensor]], - outputs: Union[keras.KerasTensor, List[keras.KerasTensor]], - perturbation_domain: Optional[PerturbationDomain] = None, - dc_decomp: bool = False, - method: Union[str, ConvertMethod] = ConvertMethod.FORWARD_AFFINE, - ibp: bool = True, - affine: bool = True, - finetune: bool = False, - shared: bool = True, - backward_bounds: bool = False, + inputs: Union[keras.KerasTensor, list[keras.KerasTensor]], + outputs: Union[keras.KerasTensor, list[keras.KerasTensor]], + perturbation_domain: PerturbationDomain, + method: ConvertMethod, + ibp: bool, + affine: bool, + model: Model, **kwargs: Any, ): + """ + + Args: + inputs: inputs of the decomon model (perturbation domain input + optionally backward_bounds on a consecutive model) + outputs: outputs of the decomon model + perturbation_domain: perturbation domain type considered on keras model inputs + method: conversion method + ibp: outputing constant bounds? + affine: outputing affine bounds? + model: original keras model to bound + **kwargs: passed to `keras.Model` constructor + + """ super().__init__(inputs, outputs, **kwargs) - if perturbation_domain is None: - perturbation_domain = BoxDomain() + self.model = model self.perturbation_domain = perturbation_domain - self.dc_decomp = dc_decomp - self.method = ConvertMethod(method) + self.method = method self.ibp = ibp self.affine = affine - self.finetune = finetune - self.backward_bounds = backward_bounds - self.shared = shared - def get_config(self) -> Dict[str, Any]: + def get_config(self) -> dict[str, Any]: # force having functional config which is skipped by default # because DecomonModel.__init__() has not same signature as Functional.__init__() config = Model(self.inputs, self.outputs).get_config() @@ -52,13 +79,10 @@ def get_config(self) -> Dict[str, Any]: dict( name=self.name, perturbation_domain=serialize_keras_object(self.perturbation_domain), - dc_decomp=self.dc_decomp, method=self.method, ibp=self.ibp, affine=self.affine, - finetune=self.finetune, - shared=self.shared, - backward_bounds=self.backward_bounds, + model=serialize_keras_object(self.model), ) ) return config @@ -70,34 +94,9 @@ def set_domain(self, perturbation_domain: PerturbationDomain) -> None: if hasattr(layer, "perturbation_domain"): layer.perturbation_domain = self.perturbation_domain - def freeze_weights(self) -> None: - for layer in self.layers: - if hasattr(layer, "freeze_weights"): - layer.freeze_weights() - - def unfreeze_weights(self) -> None: - for layer in self.layers: - if hasattr(layer, "unfreeze_weights"): - layer.unfreeze_weights() - - def freeze_alpha(self) -> None: - for layer in self.layers: - if hasattr(layer, "freeze_alpha"): - layer.freeze_alpha() - - def unfreeze_alpha(self) -> None: - for layer in self.layers: - if hasattr(layer, "unfreeze_alpha"): - layer.unfreeze_alpha() - - def reset_finetuning(self) -> None: - for layer in self.layers: - if hasattr(layer, "reset_finetuning"): - layer.reset_finetuning() - def predict_on_single_batch_np( - self, inputs: Union[np.ndarray, List[np.ndarray]] - ) -> Union[np.ndarray, List[np.ndarray]]: + self, inputs: Union[np.ndarray, list[np.ndarray]] + ) -> Union[np.ndarray, list[np.ndarray]]: """Make predictions on numpy arrays fitting in one batch Avoid using `self.predict()` known to be not designed for small arrays, @@ -118,6 +117,79 @@ def predict_on_single_batch_np( else: return K.convert_to_numpy(output_tensors) + def compute_constant_bounds_np(self, inputs: Union[np.ndarray, list[np.ndarray]]) -> list[np.ndarray]: + """ + + Args: + inputs: perturbation_domain_inputs + backward_bounds, same format as `self.inputs` + + Returns: + constant bounds: sum_{i} constant_bounds[i], constant bounds for each keras model output, concatenated + with constant_bounds[i] = [lower[i], upper[i]] + + Notes: + Constant bounds are + - either directly computed as is (forward-ibp method) + - or deduced from affine bounds (forward-affine or crown-* methods) + - or chosen as tightest between both ibp and affine (forward-hybrid) + + """ + output_tensors = self(inputs) + if not (self.ibp and not self.affine): + convert_layer = ConvertOutput( + ibp_from=self.ibp, + affine_from=self.affine, + ibp_to=True, + affine_to=False, + perturbation_domain=self.perturbation_domain, + model_output_shapes=[t.shape[1:] for t in self.model.outputs], + ) + if convert_layer.needs_perturbation_domain_inputs(): + if isinstance(inputs, np.ndarray): + perturbation_domain_input = inputs + else: + perturbation_domain_input = inputs[0] + output_tensors.append(perturbation_domain_input) + # convert actual outputs + output_tensors = convert_layer(output_tensors) + return [K.convert_to_numpy(output) for output in output_tensors] + + def compute_affine_bounds_np(self, inputs: Union[np.ndarray, list[np.ndarray]]) -> list[np.ndarray]: + """ + + Args: + inputs: perturbation_domain_inputs + backward_bounds, same format as `self.inputs` + + Returns: + affine bounds: sum_{i} affine_bounds[i], affine bounds for each keras model output, concatenated + with affine_bounds[i] = [w_l[i], b_l[i], w_u[i], b_u[i]] + + Notes: + Affine bounds are + - either directly computed as is (forward-affine or crown-* methods) + - or deduced from constant bounds (forward-ibp method) + + """ + output_tensors = self(inputs) + if not (self.affine and not self.ibp): + convert_layer = ConvertOutput( + ibp_from=self.ibp, + affine_from=self.affine, + ibp_to=False, + affine_to=True, + perturbation_domain=self.perturbation_domain, + model_output_shapes=[t.shape[1:] for t in self.model.outputs], + ) + if convert_layer.needs_perturbation_domain_inputs(): + if isinstance(inputs, np.ndarray): + perturbation_domain_input = inputs + else: + perturbation_domain_input = inputs[0] + output_tensors.append(perturbation_domain_input) + # convert actual outputs + output_tensors = convert_layer(output_tensors) + return [K.convert_to_numpy(output) for output in output_tensors] + def _check_domain( perturbation_domain_prev: PerturbationDomain, perturbation_domain: PerturbationDomain @@ -126,38 +198,3 @@ def _check_domain( raise NotImplementedError("We can only change the parameters of the perturbation domain, not its type.") return perturbation_domain - - -def get_AB(model: DecomonModel) -> Dict[str, List[keras.Variable]]: - dico_AB: Dict[str, List[keras.Variable]] = {} - perturbation_domain = model.perturbation_domain - if not (isinstance(perturbation_domain, GridDomain) and perturbation_domain.opt_option == Option.milp): - return dico_AB - - for layer in model.layers: - name = layer.name - sub_names = name.split("backward_activation") - if len(sub_names) > 1: - key = f"{layer.layer.name}_{layer.rec}" - if key not in dico_AB: - dico_AB[key] = layer.grid_finetune - return dico_AB - - -def get_AB_finetune(model: DecomonModel) -> Dict[str, keras.Variable]: - dico_AB: Dict[str, keras.Variable] = {} - perturbation_domain = model.perturbation_domain - if not (isinstance(perturbation_domain, GridDomain) and perturbation_domain.opt_option == Option.milp): - return dico_AB - - if not model.finetune: - return dico_AB - - for layer in model.layers: - name = layer.name - sub_names = name.split("backward_activation") - if len(sub_names) > 1: - key = f"{layer.layer.name}_{layer.rec}" - if key not in dico_AB: - dico_AB[key] = layer.alpha_b_l - return dico_AB diff --git a/src/decomon/models/utils.py b/src/decomon/models/utils.py index f2adf005..2a5d49f5 100644 --- a/src/decomon/models/utils.py +++ b/src/decomon/models/utils.py @@ -1,165 +1,30 @@ -from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Optional, Union import keras -import keras.ops as K import numpy as np from keras import Model, Sequential -from keras.layers import ( - Activation, - Concatenate, - Flatten, - Input, - Lambda, - Layer, - Maximum, - Minimum, -) +from keras.layers import Activation, Input, Layer from keras.src import Functional from keras.src.ops.node import Node -from decomon.core import ( - BallDomain, - BoxDomain, - ForwardMode, - InputsOutputsSpec, - PerturbationDomain, - get_mode, -) -from decomon.keras_utils import BatchedIdentityLike, share_weights_and_build -from decomon.layers.utils import is_a_merge_layer -from decomon.types import BackendTensor - - -class ConvertMethod(str, Enum): - CROWN = "crown" - CROWN_FORWARD_IBP = "crown-forward-ibp" - CROWN_FORWARD_AFFINE = "crown-forward-affine" - CROWN_FORWARD_HYBRID = "crown-forward-hybrid" - FORWARD_IBP = "forward-ibp" - FORWARD_AFFINE = "forward-affine" - FORWARD_HYBRID = "forward-hybrid" - - -def get_ibp_affine_from_method(method: Union[str, ConvertMethod]) -> Tuple[bool, bool]: - method = ConvertMethod(method) - if method in [ConvertMethod.FORWARD_IBP, ConvertMethod.CROWN_FORWARD_IBP]: - return True, False - if method in [ConvertMethod.FORWARD_AFFINE, ConvertMethod.CROWN_FORWARD_AFFINE]: - return False, True - if method in [ConvertMethod.FORWARD_HYBRID, ConvertMethod.CROWN_FORWARD_HYBRID]: - return True, True - if method == ConvertMethod.CROWN: - return True, False - return True, True - - -class FeedDirection(str, Enum): - FORWARD = "feed_forward" - BACKWARD = "feed_backward" - - -def get_direction(method: Union[str, ConvertMethod]) -> FeedDirection: - if ConvertMethod(method) in [ConvertMethod.FORWARD_IBP, ConvertMethod.FORWARD_AFFINE, ConvertMethod.FORWARD_HYBRID]: - return FeedDirection.FORWARD - else: - return FeedDirection.BACKWARD - - -def has_merge_layers(model: Model) -> bool: - return any(is_a_merge_layer(layer) for layer in model.layers) - - -def check_model2convert_inputs(model: Model) -> None: - """Check that the model to convert satisfy the hypotheses for decomon on inputs. +from decomon.constants import ConvertMethod, Propagation +from decomon.keras_utils import share_weights_and_build +from decomon.perturbation_domain import PerturbationDomain - Which means: - - only one input - - the input must be flattened: only batchsize + another dimension +def generate_perturbation_domain_input( + model: Model, perturbation_domain: PerturbationDomain, name: str = "perturbation_domain_input" +) -> keras.KerasTensor: + model_input_shape = model.inputs[0].shape[1:] + dtype = model.inputs[0].dtype - """ - if len(model.inputs) > 1: - raise ValueError("The model must have only 1 input to be converted.") - if len(model.inputs[0].shape) > 2: - raise ValueError("The model must have a flattened input to be converted.") - - -def get_input_tensors( - model: Model, - perturbation_domain: PerturbationDomain, - ibp: bool = True, - affine: bool = True, -) -> Tuple[keras.KerasTensor, List[keras.KerasTensor]]: - input_dim = get_input_dim(model) - mode = get_mode(ibp=ibp, affine=affine) - dc_decomp = False - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - empty_tensor = inputs_outputs_spec.get_empty_tensor() - - input_shape_x = perturbation_domain.get_x_input_shape_wo_batchsize(input_dim) - z_tensor = Input(shape=input_shape_x, dtype=model.layers[0].dtype) - u_c_tensor, l_c_tensor, W, b, h, g = ( - empty_tensor, - empty_tensor, - empty_tensor, - empty_tensor, - empty_tensor, - empty_tensor, - ) - - if isinstance(perturbation_domain, BoxDomain): - if ibp: - u_c_tensor = Lambda(lambda z: z[:, 1], dtype=z_tensor.dtype)(z_tensor) - l_c_tensor = Lambda(lambda z: z[:, 0], dtype=z_tensor.dtype)(z_tensor) - - if affine: - W = BatchedIdentityLike()(z_tensor[:, 0]) - b = K.zeros_like(z_tensor[:, 0]) - - elif isinstance(perturbation_domain, BallDomain): - if perturbation_domain.p == np.inf: - radius = perturbation_domain.eps - u_c_tensor = Lambda( - lambda var: var + K.cast(radius, dtype=model.layers[0].dtype), dtype=model.layers[0].dtype - )(z_tensor) - if ibp: - l_c_tensor = Lambda( - lambda var: var - K.cast(radius, dtype=model.layers[0].dtype), dtype=model.layers[0].dtype - )(z_tensor) - if affine: - W = BatchedIdentityLike()(u_c_tensor) - b = K.zeros_like(u_c_tensor) - - else: - W = BatchedIdentityLike()(z_tensor) - b = K.zeros_like(z_tensor) - if ibp: - u_c_tensor = perturbation_domain.get_upper(z_tensor, W, b) - l_c_tensor = perturbation_domain.get_lower(z_tensor, W, b) - - else: - raise NotImplementedError(f"Not implemented for perturbation domain type {type(perturbation_domain)}") - - input_tensors = inputs_outputs_spec.extract_inputsformode_from_fullinputs( - [z_tensor, u_c_tensor, W, b, l_c_tensor, W, b, h, g] - ) - - return z_tensor, input_tensors - - -def get_input_dim(layer: Layer) -> int: - if isinstance(layer.input, list): - if len(layer.input) == 0: - return 0 - return int(np.prod(layer.input[0].shape[1:])) - else: - return int(np.prod(layer.input.shape[1:])) + input_shape_x = perturbation_domain.get_x_input_shape_wo_batchsize(model_input_shape) + return Input(shape=input_shape_x, dtype=dtype, name=name) def prepare_inputs_for_layer( - inputs: Union[Tuple[keras.KerasTensor, ...], List[keras.KerasTensor], keras.KerasTensor] -) -> Union[Tuple[keras.KerasTensor, ...], List[keras.KerasTensor], keras.KerasTensor]: + inputs: Union[tuple[keras.KerasTensor, ...], list[keras.KerasTensor], keras.KerasTensor] +) -> Union[tuple[keras.KerasTensor, ...], list[keras.KerasTensor], keras.KerasTensor]: """Prepare inputs for keras/decomon layers. Some Keras layers do not like list of tensors even with one single tensor. @@ -173,8 +38,19 @@ def prepare_inputs_for_layer( def wrap_outputs_from_layer_in_list( - outputs: Union[Tuple[keras.KerasTensor, ...], List[keras.KerasTensor], keras.KerasTensor] -) -> List[keras.KerasTensor]: + outputs: Union[tuple[keras.KerasTensor, ...], list[keras.KerasTensor], keras.KerasTensor] +) -> list[keras.KerasTensor]: + """Normalizes a list/tensor into a list. + + If a tensor is passed, we return + a list of size 1 containing the tensor. + + Args: + x: target object to be normalized. + + Returns: + A list. + """ if not isinstance(outputs, list): if isinstance(outputs, tuple): return list(outputs) @@ -184,7 +60,7 @@ def wrap_outputs_from_layer_in_list( return outputs -def split_activation(layer: Layer) -> List[Layer]: +def split_activation(layer: Layer) -> list[Layer]: # get activation config = layer.get_config() activation = config.pop("activation", None) @@ -218,24 +94,72 @@ def split_activation(layer: Layer) -> List[Layer]: return [layer_wo_activation, activation_layer] -def preprocess_layer(layer: Layer) -> List[Layer]: +def preprocess_layer(layer: Layer) -> list[Layer]: return split_activation(layer) +def remove_last_softmax_layers(model: Model) -> Model: + """Remove for each output the last layer if it is a softmax activation + + NB: this should be applied after the split of activations, so that we only need + to check for Activation layers. + + Args: + model: original keras model + + Returns: + model without the last softmax layers + + It will return the same model if no such softmax have to be removed, else construct a new functional model. + + """ + output_nodes = get_output_nodes(model) + new_outputs = [] + has_changed = False + for output_node in output_nodes: + layer = output_node.operation + # NB: activations have been already split => we need only to check for Activation layers + if isinstance(layer, Activation) and layer.get_config()["activation"] == "softmax": + # softmax: take parent nodes outputs instead + for parent in output_node.parent_nodes: + new_outputs += parent.outputs + has_changed = True + else: + # no softmax: keep same outputs + new_outputs += output_node.outputs + if has_changed: + return Model(model.inputs, new_outputs) + else: + return model + + def is_input_node(node: Node) -> bool: return len(node.input_tensors) == 0 -def get_depth_dict(model: Model) -> Dict[int, List[Node]]: +def get_output_nodes(model: Model) -> list[Node]: + """Get list of output nodes ordered as model.outputs + + Args: + model: + + Returns: + + """ + nodes_by_operation = {n.operation: n for subnodes in model._nodes_by_depth.values() for n in subnodes} + return [nodes_by_operation[output._keras_history.operation] for output in model.outputs] + + +def get_depth_dict(model: Model) -> dict[int, list[Node]]: depth_keys = list(model._nodes_by_depth.keys()) depth_keys.sort(reverse=True) nodes_list = [] - dico_depth: Dict[int, int] = {} - dico_nodes: Dict[int, List[Node]] = {} + dico_depth: dict[int, int] = {} + dico_nodes: dict[int, list[Node]] = {} - def fill_dico(node: Node, dico_depth: Optional[Dict[int, int]] = None) -> Dict[int, int]: + def fill_dico(node: Node, dico_depth: Optional[dict[int, int]] = None) -> dict[int, int]: if dico_depth is None: dico_depth = {} @@ -272,89 +196,6 @@ def fill_dico(node: Node, dico_depth: Optional[Dict[int, int]] = None) -> Dict[i return dico_nodes -def get_inner_layers(model: Model) -> int: - count = 0 - for layer in model.layers: - if isinstance(layer, Model): - count += get_inner_layers(layer) - else: - count += 1 - return count - - -class Convert2Mode(Layer): - def __init__( - self, - mode_from: Union[str, ForwardMode], - mode_to: Union[str, ForwardMode], - perturbation_domain: PerturbationDomain, - input_dim: int = -1, - **kwargs: Any, - ): - super().__init__(**kwargs) - self.mode_from = ForwardMode(mode_from) - self.mode_to = ForwardMode(mode_to) - self.perturbation_domain = perturbation_domain - self.input_dim = input_dim - dc_decomp = False - self.dc_decomp = dc_decomp - self.inputs_outputs_spec_from = InputsOutputsSpec( - dc_decomp=dc_decomp, - mode=mode_from, - perturbation_domain=perturbation_domain, - model_input_dim=self.input_dim, - ) - self.inputs_outputs_spec_to = InputsOutputsSpec( - dc_decomp=dc_decomp, - mode=mode_to, - perturbation_domain=perturbation_domain, - model_input_dim=self.input_dim, - ) - - def call(self, inputs: List[BackendTensor], **kwargs: Any) -> List[BackendTensor]: - compute_ibp_from_affine = self.mode_from == ForwardMode.AFFINE and self.mode_to != ForwardMode.AFFINE - tight = self.mode_from == ForwardMode.HYBRID and self.mode_to != ForwardMode.AFFINE - compute_dummy_affine = self.mode_from == ForwardMode.IBP and self.mode_to != ForwardMode.IBP - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = self.inputs_outputs_spec_from.get_fullinputs_from_inputsformode( - inputs, compute_ibp_from_affine=compute_ibp_from_affine, tight=tight - ) - - if compute_dummy_affine: - x = K.concatenate([K.expand_dims(l_c, 1), K.expand_dims(u_c, 1)], 1) - w = K.zeros_like(BatchedIdentityLike()(l_c)) - w_u = w - b_u = u_c - w_l = w - b_l = l_c - - return self.inputs_outputs_spec_to.extract_outputsformode_from_fulloutputs( - [x, u_c, w_u, b_u, l_c, w_l, b_l, h, g] - ) - - def compute_output_shape(self, input_shape: List[Tuple[Optional[int], ...]]) -> List[Tuple[Optional[int], ...]]: - ( - x_shape, - u_c_shape, - w_u_shape, - b_u_shape, - l_c_shape, - w_l_shape, - b_l_shape, - h_shape, - g_shape, - ) = self.inputs_outputs_spec_from.get_fullinputshapes_from_inputshapesformode(input_shape) - return self.inputs_outputs_spec_to.extract_inputshapesformode_from_fullinputshapes( - [x_shape, u_c_shape, w_u_shape, b_u_shape, l_c_shape, w_l_shape, b_l_shape, h_shape, g_shape] - ) - - def get_config(self) -> Dict[str, Any]: - config = super().get_config() - config.update( - {"mode_from": self.mode_from, "mode_to": self.mode_to, "perturbation_domain": self.perturbation_domain} - ) - return config - - def ensure_functional_model(model: Model) -> Functional: if isinstance(model, Functional): return model @@ -364,3 +205,39 @@ def ensure_functional_model(model: Model) -> Functional: return model else: raise NotImplementedError("Decomon model available only for functional or sequential models.") + + +def get_ibp_affine_from_method(method: ConvertMethod) -> tuple[bool, bool]: + method = ConvertMethod(method) + if method in [ConvertMethod.FORWARD_IBP, ConvertMethod.CROWN_FORWARD_IBP]: + return True, False + if method in [ConvertMethod.FORWARD_AFFINE, ConvertMethod.CROWN_FORWARD_AFFINE]: + return False, True + if method in [ConvertMethod.FORWARD_HYBRID, ConvertMethod.CROWN_FORWARD_HYBRID]: + return True, True + if method == ConvertMethod.CROWN: + return False, True + return True, True + + +def get_final_ibp_affine_from_method(method: ConvertMethod) -> tuple[bool, bool]: + method = ConvertMethod(method) + if method in [ConvertMethod.FORWARD_IBP, ConvertMethod.FORWARD_HYBRID]: + final_ibp = True + else: + final_ibp = False + if method == ConvertMethod.FORWARD_IBP: + final_affine = False + else: + final_affine = True + + return final_ibp, final_affine + + +def method2propagation(method: ConvertMethod) -> list[Propagation]: + if method == ConvertMethod.CROWN: + return [Propagation.BACKWARD] + elif method in [ConvertMethod.FORWARD_IBP, ConvertMethod.FORWARD_AFFINE, ConvertMethod.FORWARD_HYBRID]: + return [Propagation.FORWARD] + else: + return [Propagation.FORWARD, Propagation.BACKWARD] diff --git a/src/decomon/perturbation_domain.py b/src/decomon/perturbation_domain.py new file mode 100644 index 00000000..dbfbe1d6 --- /dev/null +++ b/src/decomon/perturbation_domain.py @@ -0,0 +1,463 @@ +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, Union + +import numpy as np +from keras import ops as K + +from decomon.keras_utils import batch_multid_dot +from decomon.types import Tensor + + +class Option(str, Enum): + lagrangian = "lagrangian" + milp = "milp" + + +class PerturbationDomain(ABC): + opt_option: Option + + def __init__(self, opt_option: Union[str, Option] = Option.milp): + self.opt_option = Option(opt_option) + + @abstractmethod + def get_upper_x(self, x: Tensor) -> Tensor: + """Get upper constant bound on perturbation domain input.""" + ... + + @abstractmethod + def get_lower_x(self, x: Tensor) -> Tensor: + """Get lower constant bound on perturbation domain input.""" + ... + + @abstractmethod + def get_upper(self, x: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any) -> Tensor: + """Merge upper affine bounds with perturbation domain input to get upper constant bound. + + Args: + x: perturbation domain input + w: weights of the affine bound + b: bias of the affine bound + missing_batchsize: whether w and b are missing batchsize + **kwargs: + + Returns: + + """ + ... + + @abstractmethod + def get_lower(self, x: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any) -> Tensor: + """Merge lower affine bounds with perturbation domain input to get lower constant bound. + + Args: + x: perturbation domain input + w: weights of the affine bound + b: bias of the affine bound + missing_batchsize: whether w and b are missing batchsize + **kwargs: + + Returns: + + """ + ... + + @abstractmethod + def get_nb_x_components(self) -> int: + """Get the number of components in perturabation domain input. + + For instance: + - box domain: each corner of the box -> 2 components + - ball domain: center of the ball -> 1 component + + """ + ... + + def get_config(self) -> dict[str, Any]: + return { + "opt_option": self.opt_option, + } + + def get_kerasinputlike_from_x(self, x: Tensor) -> Tensor: + """Get tensor of same shape as keras model input, from perturbation domain input x + + Args: + x: perturbation domain input + + Returns: + tensor of same shape as keras model input + + """ + if self.get_nb_x_components() == 1: + return x + else: + return x[:, 0] + + def get_x_input_shape_wo_batchsize(self, original_input_shape: tuple[int, ...]) -> tuple[int, ...]: + """Get expected perturbation domain input shape, excepting the batch axis.""" + n_comp_x = self.get_nb_x_components() + if n_comp_x == 1: + return original_input_shape + else: + return (n_comp_x,) + original_input_shape + + def get_keras_input_shape_wo_batchsize(self, x_shape: tuple[int, ...]) -> tuple[int, ...]: + """Deduce keras model input shape from perturbation domain input shape.""" + n_comp_x = self.get_nb_x_components() + if n_comp_x == 1: + return x_shape + else: + return x_shape[1:] + + +class BoxDomain(PerturbationDomain): + def get_upper(self, x: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any) -> Tensor: + x_min = x[:, 0] + x_max = x[:, 1] + return get_upper_box(x_min=x_min, x_max=x_max, w=w, b=b, missing_batchsize=missing_batchsize, **kwargs) + + def get_lower(self, x: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any) -> Tensor: + x_min = x[:, 0] + x_max = x[:, 1] + return get_lower_box(x_min=x_min, x_max=x_max, w=w, b=b, missing_batchsize=missing_batchsize, **kwargs) + + def get_upper_x(self, x: Tensor) -> Tensor: + return x[:, 1] + + def get_lower_x(self, x: Tensor) -> Tensor: + return x[:, 0] + + def get_nb_x_components(self) -> int: + return 2 + + +class GridDomain(PerturbationDomain): + pass + + +class VertexDomain(PerturbationDomain): + pass + + +class BallDomain(PerturbationDomain): + def __init__(self, eps: float, p: float = 2, opt_option: Option = Option.milp): + super().__init__(opt_option=opt_option) + self.eps = eps + # check on p + p_error_msg = "p must be a positive integer or np.inf" + try: + if p != np.inf and (int(p) != p or p <= 0): + raise ValueError(p_error_msg) + except: + raise ValueError(p_error_msg) + self.p = p + + def get_config(self) -> dict[str, Any]: + config = super().get_config() + config.update( + { + "eps": self.eps, + "p": self.p, + } + ) + return config + + def get_lower(self, x: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any) -> Tensor: + return get_lower_ball(x_0=x, eps=self.eps, p=self.p, w=w, b=b, missing_batchsize=missing_batchsize, **kwargs) + + def get_upper(self, x: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any) -> Tensor: + return get_upper_ball(x_0=x, eps=self.eps, p=self.p, w=w, b=b, missing_batchsize=missing_batchsize, **kwargs) + + def get_nb_x_components(self) -> int: + return 1 + + def get_lower_x(self, x: Tensor) -> Tensor: + return x - self.eps + + def get_upper_x(self, x: Tensor) -> Tensor: + return x + self.eps + + +def get_upper_box( + x_min: Tensor, x_max: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any +) -> Tensor: + """Compute the max of an affine function + within a box (hypercube) defined by its extremal corners + + Args: + x_min: lower bound of the box domain + x_max: upper bound of the box domain + w: weights of the affine function + b: bias of the affine function + missing_batchsize: whether w and b are missing the batchsize + + Returns: + max_(x >= x_min, x<=x_max) w*x + b + + Note: + We can have w, b in diagonal representation and/or without a batch axis. + We assume that x_min, x_max have always its batch axis. + + """ + z_value = K.cast(0.0, dtype=x_min.dtype) + w_pos = K.maximum(w, z_value) + w_neg = K.minimum(w, z_value) + + is_diag = w.shape == b.shape + diagonal = (False, is_diag) + missing_batchsize_dot = (False, missing_batchsize) + + return ( + batch_multid_dot(x_max, w_pos, diagonal=diagonal, missing_batchsize=missing_batchsize_dot) + + batch_multid_dot(x_min, w_neg, diagonal=diagonal, missing_batchsize=missing_batchsize_dot) + + b + ) + + +def get_lower_box( + x_min: Tensor, x_max: Tensor, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any +) -> Tensor: + """ + Args: + x_min: lower bound of the box domain + x_max: upper bound of the box domain + w: weights of the affine lower bound + b: bias of the affine lower bound + missing_batchsize: whether w and b are missing the batchsize + + Returns: + min_(x >= x_min, x<=x_max) w*x + b + + Note: + We can have w, b in diagonal representation and/or without a batch axis. + We assume that x_min, x_max have always its batch axis. + + """ + return get_upper_box(x_min=x_max, x_max=x_min, w=w, b=b, missing_batchsize=missing_batchsize, **kwargs) + + +def get_lq_norm(x: Tensor, p: float, axis: Union[int, list[int]] = -1) -> Tensor: + """compute Lp norm (p=1 or 2) + + Args: + x: tensor + p: the power must be an integer in (1, 2) + axis: the axis on which we compute the norm + + Returns: + ||w||^p + """ + if p == 1: + x_q = K.max(K.abs(x), axis) + elif p == 2: + x_q = K.sqrt(K.sum(K.power(x, p), axis)) + else: + raise NotImplementedError("p must be equal to 1 or 2") + + return x_q + + +def get_upper_ball( + x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any +) -> Tensor: + """max of an affine function over an Lp ball + + Args: + x_0: the center of the ball + eps: the radius + p: the type of Lp norm considered + w: weights of the affine function + b: bias of the affine function + missing_batchsize: whether w and b are missing the batchsize + + Returns: + max_(|x - x_0|_p<= eps) w*x + b + """ + if p == np.inf: + # compute x_min and x_max according to eps + x_min = x_0 - eps + x_max = x_0 + eps + return get_upper_box(x_min, x_max, w, b, missing_batchsize=missing_batchsize) + + else: + if len(kwargs): + return get_upper_ball_finetune(x_0, eps, p, w, b, missing_batchsize=missing_batchsize, **kwargs) + + # use Holder's inequality p+q=1 + # ||w||_q*eps + w*x_0 + b + + is_diag = w.shape == b.shape + + # lq-norm of w + if is_diag: + w_q = K.abs(w) + else: + nb_axes_wo_batchsize_x = len(x_0.shape) - 1 + if missing_batchsize: + reduced_axes = list(range(nb_axes_wo_batchsize_x)) + else: + reduced_axes = list(range(1, 1 + nb_axes_wo_batchsize_x)) + w_q = get_lq_norm(w, p, axis=reduced_axes) + + diagonal = (False, is_diag) + missing_batchsize_dot = (False, missing_batchsize) + return batch_multid_dot(x_0, w, diagonal=diagonal, missing_batchsize=missing_batchsize_dot) + b + w_q * eps + + +def get_lower_ball( + x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any +) -> Tensor: + """min of an affine fucntion over an Lp ball + + Args: + x_0: the center of the ball + eps: the radius + p: the type of Lp norm considered + w: weights of the affine function + b: bias of the affine function + missing_batchsize: whether w and b are missing the batchsize + + Returns: + min_(|x - x_0|_p<= eps) w*x + b + """ + if p == np.inf: + # compute x_min and x_max according to eps + x_min = x_0 - eps + x_max = x_0 + eps + return get_lower_box(x_min, x_max, w, b, missing_batchsize=missing_batchsize) + + else: + if len(kwargs): + return get_lower_ball_finetune(x_0, eps, p, w, b, missing_batchsize=missing_batchsize, **kwargs) + + # use Holder's inequality p+q=1 + # - ||w||_q*eps + w*x_0 + b + + is_diag = w.shape == b.shape + + # lq-norm of w + if is_diag: + w_q = K.abs(w) + else: + nb_axes_wo_batchsize_x = len(x_0.shape) - 1 + if missing_batchsize: + reduced_axes = list(range(nb_axes_wo_batchsize_x)) + else: + reduced_axes = list(range(1, 1 + nb_axes_wo_batchsize_x)) + w_q = get_lq_norm(w, p, axis=reduced_axes) + + diagonal = (False, is_diag) + missing_batchsize_dot = (False, missing_batchsize) + return batch_multid_dot(x_0, w, diagonal=diagonal, missing_batchsize=missing_batchsize_dot) + b - w_q * eps + + +def get_lower_ball_finetune( + x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any +) -> Tensor: + if missing_batchsize: + raise NotImplementedError() + + if "finetune_lower" in kwargs and "upper" in kwargs or "lower" in kwargs: + alpha = kwargs["finetune_lower"] + # assume alpha is the same shape as w, minus the batch dimension + n_shape = len(w.shape) - 2 + z_value = K.cast(0.0, dtype=w.dtype) + + if "upper" and "lower" in kwargs: + upper = kwargs["upper"] # flatten vector + lower = kwargs["lower"] # flatten vector + + upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) + lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) + + w_alpha = w * alpha[None] + w_alpha_bar = w * (1 - alpha) + + score_box = K.sum(K.maximum(z_value, w_alpha_bar) * lower_reshaped, 1) + K.sum( + K.minimum(z_value, w_alpha_bar) * upper_reshaped, 1 + ) + score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) + + return score_box + score_ball + + if "upper" in kwargs: + upper = kwargs["upper"] # flatten vector + upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) + + w_alpha = K.minimum(z_value, w) * alpha[None] + K.maximum(z_value, w) + w_alpha_bar = K.minimum(z_value, w) * (1 - alpha[None]) + + score_box = K.sum(K.minimum(z_value, w_alpha_bar) * upper_reshaped, 1) + score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) + + return score_box + score_ball + + if "lower" in kwargs: + lower = kwargs["lower"] # flatten vector + lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) + + w_alpha = K.maximum(z_value, w) * alpha[None] + K.minimum(z_value, w) + w_alpha_bar = K.maximum(z_value, w) * (1 - alpha[None]) + + score_box = K.sum(K.maximum(z_value, w_alpha_bar) * lower_reshaped, 1) + score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) + + return score_box + score_ball + + return get_lower_ball(x_0, eps, p, w, b) + + +def get_upper_ball_finetune( + x_0: Tensor, eps: float, p: float, w: Tensor, b: Tensor, missing_batchsize: bool = False, **kwargs: Any +) -> Tensor: + if missing_batchsize: + raise NotImplementedError() + + if "finetune_upper" in kwargs and "upper" in kwargs or "lower" in kwargs: + alpha = kwargs["finetune_upper"] + # assume alpha is the same shape as w, minus the batch dimension + n_shape = len(w.shape) - 2 + z_value = K.cast(0.0, dtype=w.dtype) + + if "upper" and "lower" in kwargs: + upper = kwargs["upper"] # flatten vector + lower = kwargs["lower"] # flatten vector + + upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) + lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) + + w_alpha = w * alpha[None] + w_alpha_bar = w * (1 - alpha) + + score_box = K.sum(K.maximum(z_value, w_alpha_bar) * upper_reshaped, 1) + K.sum( + K.minimum(z_value, w_alpha_bar) * lower_reshaped, 1 + ) + score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) + + return score_box + score_ball + + if "upper" in kwargs: + upper = kwargs["upper"] # flatten vector + upper_reshaped = np.reshape(upper, [1, -1] + [1] * n_shape) + + w_alpha = K.minimum(z_value, w) * alpha[None] + K.maximum(z_value, w) + w_alpha_bar = K.minimum(z_value, w) * (1 - alpha[None]) + + score_box = K.sum(K.maximum(z_value, w_alpha_bar) * upper_reshaped, 1) + score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) + + return score_box + score_ball + + if "lower" in kwargs: + lower = kwargs["lower"] # flatten vector + lower_reshaped = np.reshape(lower, [1, -1] + [1] * n_shape) + + w_alpha = K.maximum(z_value, w) * alpha[None] + K.minimum(z_value, w) + w_alpha_bar = K.maximum(z_value, w) * (1 - alpha[None]) + + score_box = K.sum(K.minimum(z_value, w_alpha_bar) * lower_reshaped, 1) + score_ball = get_lower_ball(x_0, eps, p, w_alpha, b) + + return score_box + score_ball + + return get_upper_ball(x_0, eps, p, w, b) diff --git a/src/decomon/types/__init__.py b/src/decomon/types/__init__.py index 0193c1f8..8f7c1d3e 100644 --- a/src/decomon/types/__init__.py +++ b/src/decomon/types/__init__.py @@ -1,7 +1,7 @@ """Typing module""" -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Union import keras @@ -20,4 +20,4 @@ """Type for any tensor, from keras or backend.""" -DecomonInputs = List[Tensor] +DecomonInputs = list[Tensor] diff --git a/src/decomon/utils.py b/src/decomon/utils.py deleted file mode 100644 index 756d70ab..00000000 --- a/src/decomon/utils.py +++ /dev/null @@ -1,721 +0,0 @@ -from typing import Any, Callable, List, Optional, Tuple, Union - -import keras.ops as K -import numpy as np -from keras.config import epsilon - -from decomon.core import ( - BoxDomain, - ForwardMode, - GridDomain, - InputsOutputsSpec, - PerturbationDomain, - Slope, - get_affine, - get_ibp, -) -from decomon.types import Tensor - -TensorFunction = Callable[[Tensor], Tensor] - - -# linear hull for activation function -def relu_prime(x: Tensor) -> Tensor: - """Derivative of relu - - Args: - x - - Returns: - - """ - - return K.clip(K.sign(x), K.cast(0, dtype=x.dtype), K.cast(1, dtype=x.dtype)) - - -def sigmoid_prime(x: Tensor) -> Tensor: - """Derivative of sigmoid - - Args: - x - - Returns: - - """ - - s_x = K.sigmoid(x) - return s_x * (K.cast(1, dtype=x.dtype) - s_x) - - -def tanh_prime(x: Tensor) -> Tensor: - """Derivative of tanh - - Args: - x - - Returns: - - """ - - s_x = K.tanh(x) - return K.cast(1, dtype=x.dtype) - K.power(s_x, K.cast(2, dtype=x.dtype)) - - -def softsign_prime(x: Tensor) -> Tensor: - """Derivative of softsign - - Args: - x - - Returns: - - """ - - return K.cast(1.0, dtype=x.dtype) / K.power(K.cast(1.0, dtype=x.dtype) + K.abs(x), K.cast(2, dtype=x.dtype)) - - -##### corners ###### -def get_lower_bound_grid(x: Tensor, W: Tensor, b: Tensor, n: int) -> Tensor: - A, B = convert_lower_search_2_subset_sum(x, W, b, n) - return subset_sum_lower(A, B, repeat=n) - - -def get_upper_bound_grid(x: Tensor, W: Tensor, b: Tensor, n: int) -> Tensor: - return -get_lower_bound_grid(x, -W, -b, n) - - -def get_bound_grid( - x: Tensor, - W_u: Tensor, - b_u: Tensor, - W_l: Tensor, - b_l: Tensor, - n: int, -) -> Tuple[Tensor, Tensor]: - upper = get_upper_bound_grid(x, W_u, b_u, n) - lower = get_lower_bound_grid(x, W_l, b_l, n) - - return upper, lower - - -# convert max Wx +b s.t Wx+b<=0 into a subset-sum problem with positive values -def convert_lower_search_2_subset_sum(x: Tensor, W: Tensor, b: Tensor, n: int) -> Tuple[Tensor, Tensor]: - x_min = x[:, 0] - x_max = x[:, 1] - - if len(W.shape) > 3: - W = K.reshape(W, (-1, W.shape[1], int(np.prod(W.shape[2:])))) - b = K.reshape(b, (-1, int(np.prod(b.shape[1:])))) - - const = BoxDomain().get_lower(x, W, b) - - weights = K.abs(W) * K.expand_dims((x_max - x_min) / n, -1) - return weights, const - - -def subset_sum_lower(W: Tensor, b: Tensor, repeat: int = 1) -> Tensor: - B = K.sort(W, axis=1) - C = K.repeat(B, repeats=repeat, axis=1) - C_reduced = K.cumsum(C, axis=1) - D = K.minimum(K.sign(K.expand_dims(-b, 1) - C_reduced) + 1, K.cast(1.0, dtype=b.dtype)) - - score = K.minimum(K.sum(D * C, 1) + b, K.cast(0.0, dtype=b.dtype)) - return score - - -# define routines to get linear relaxations useful both for forward and backward -def get_linear_hull_relu( - upper: Tensor, - lower: Tensor, - slope: Union[str, Slope], - upper_g: float = 0.0, - lower_g: float = 0.0, - **kwargs: Any, -) -> List[Tensor]: - slope = Slope(slope) - # in case upper=lower, this cases are - # considered with index_dead and index_linear - alpha = (K.relu(upper) - K.relu(lower)) / K.maximum(K.cast(epsilon(), dtype=upper.dtype), upper - lower) - - # scaling factor for the upper bound on the relu - # see README - - w_u = alpha - b_u = K.relu(lower) - alpha * lower - z_value = K.cast(0.0, dtype=upper.dtype) - o_value = K.cast(1.0, dtype=upper.dtype) - - if slope == Slope.V_SLOPE: - # 1 if upper<=-lower else 0 - index_a = -K.clip(K.sign(upper + lower) - o_value, -o_value, z_value) - - # 1 if upper>-lower else 0 - index_b = o_value - index_a - w_l = index_b - b_l = z_value * b_u - - elif slope == Slope.A_SLOPE: - w_l = K.clip(K.sign(w_u - 0.5), 0, 1) - b_l = z_value * b_u - - elif slope == Slope.Z_SLOPE: - w_l = z_value * w_u - b_l = z_value * b_u - - elif slope == Slope.O_SLOPE: - w_l = z_value * w_u + o_value - b_l = z_value * b_u - - elif slope == Slope.S_SLOPE: - w_l = w_u - b_l = z_value * b_u - - else: - raise NotImplementedError(f"Not implemented for slope {slope}") - - if "upper_grid" in kwargs: - raise NotImplementedError() - - gamma = o_value - if "finetune" in kwargs: - # retrieve variables to optimize the slopes - gamma = kwargs["finetune"][None] - - w_l = gamma * w_l + (o_value - gamma) * (o_value - w_l) - - # check inactive relu state: u<=0 - index_dead = -K.clip(K.sign(upper) - o_value, -o_value, z_value) # =1 if inactive state - index_linear = K.clip(K.sign(lower) + o_value, z_value, o_value) # 1 if linear state - - w_u = (o_value - index_dead) * w_u - w_l = (o_value - index_dead) * w_l - b_u = (o_value - index_dead) * b_u - b_l = (o_value - index_dead) * b_l - - w_u = (o_value - index_linear) * w_u + index_linear - w_l = (o_value - index_linear) * w_l + index_linear - b_u = (o_value - index_linear) * b_u - b_l = (o_value - index_linear) * b_l - - return [w_u, b_u, w_l, b_l] - - -def get_linear_hull_sigmoid(upper: Tensor, lower: Tensor, slope: Union[str, Slope], **kwargs: Any) -> List[Tensor]: - x = [upper, lower] - return get_linear_hull_s_shape(x, func=K.sigmoid, f_prime=sigmoid_prime, mode=ForwardMode.IBP, **kwargs) - - -def get_linear_hull_tanh(upper: Tensor, lower: Tensor, slope: Union[str, Slope], **kwargs: Any) -> List[Tensor]: - x = [upper, lower] - return get_linear_hull_s_shape(x, func=K.tanh, f_prime=tanh_prime, mode=ForwardMode.IBP, **kwargs) - - -def get_linear_softplus_hull(upper: Tensor, lower: Tensor, slope: Union[str, Slope], **kwargs: Any) -> List[Tensor]: - slope = Slope(slope) - # in case upper=lower, this cases are - # considered with index_dead and index_linear - u_c = K.softsign(upper) - l_c = K.softsign(lower) - alpha = (u_c - l_c) / K.maximum(K.cast(epsilon(), dtype=upper.dtype), (upper - lower)) - w_u = alpha - b_u = -alpha * lower + l_c - - z_value = K.cast(0.0, dtype=upper.dtype) - o_value = K.cast(1.0, dtype=upper.dtype) - - if slope == Slope.V_SLOPE: - # 1 if upper<=-lower else 0 - index_a = -K.clip(K.sign(upper + lower) - o_value, -o_value, z_value) - # 1 if upper>-lower else 0 - index_b = o_value - index_a - w_l = index_b - b_l = z_value * b_u - elif slope == Slope.Z_SLOPE: - w_l = z_value * w_u - b_l = z_value * b_u - elif slope == Slope.O_SLOPE: - w_l = z_value * w_u + o_value - b_l = z_value * b_u - elif slope == Slope.S_SLOPE: - w_l = w_u - b_l = z_value * b_u - else: - raise ValueError(f"Unknown slope {slope}") - - index_dead = -K.clip(K.sign(upper) - o_value, -o_value, z_value) - - w_u = (o_value - index_dead) * w_u - w_l = (o_value - index_dead) * w_l - b_u = (o_value - index_dead) * b_u - b_l = (o_value - index_dead) * b_l - - if "finetune" in kwargs: - # weighted linear combination - alpha_u, alpha_l = kwargs["finetune"] - alpha_u = alpha_u[None] - alpha_l = alpha_l[None] - - w_u = alpha_u * w_u - b_u = alpha_u * b_u + (o_value - alpha_u) * K.maximum(upper, z_value) - - w_l = alpha_l * w_l - b_l = alpha_l * b_l + (o_value - alpha_l) * K.maximum(lower, z_value) - - return [w_u, b_u, w_l, b_l] - - -def subtract( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, -) -> List[Tensor]: - """LiRPA implementation of inputs_0-inputs_1 - - Args: - inputs_0: tensor - inputs_1: tensor - dc_decomp: boolean that indicates - perturbation_domain: the type of perturbation domain - whether we return a difference of convex decomposition of our layer - - Returns: - inputs_0 - inputs_1 - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - inputs_1 = minus(inputs_1, mode=mode, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain) - output = add(inputs_0, inputs_1, dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - return output - - -def add( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, -) -> List[Tensor]: - """LiRPA implementation of inputs_0+inputs_1 - - Args: - inputs_0: tensor - inputs_1: tensor - dc_decomp: boolean that indicates - perturbation_domain: the type of perturbation domain - whether we return a difference of convex decomposition of our layer - - Returns: - inputs_0 + inputs_1 - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x_0, u_c_0, w_u_0, b_u_0, l_c_0, w_l_0, b_l_0, h_0, g_0 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_0 - ) - _, u_c_1, w_u_1, b_u_1, l_c_1, w_l_1, b_l_1, h_1, g_1 = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs_1 - ) - - x = x_0 - h = h_0 + h_1 - g = g_0 + g_1 - u_c = u_c_0 + u_c_1 - l_c = l_c_0 + l_c_1 - w_u = w_u_0 + w_u_1 - w_l = w_l_0 + w_l_1 - b_u = b_u_0 + b_u_1 - b_l = b_l_0 + b_l_1 - - fulloutputs = [x, u_c, w_u, b_u, l_c, w_l, b_l, h, g] - outputs = inputs_outputs_spec.extract_outputsformode_from_fulloutputs(fulloutputs) - - return outputs - - -def relu_( - inputs: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - **kwargs: Any, -) -> List[Tensor]: - if perturbation_domain is None: - perturbation_domain = BoxDomain() - - mode = ForwardMode(mode) - affine = get_affine(mode) - ibp = get_ibp(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - - z_value = K.cast(0.0, dtype=dtype) - o_value = K.cast(1.0, dtype=dtype) - empty_tensor = inputs_outputs_spec.get_empty_tensor(dtype=dtype) - - if dc_decomp: - h_out = K.maximum(h, -g) - g_out = g - index_dead = -K.clip(K.sign(u_c) - o_value, -o_value, z_value) # =1 if inactive state - index_linear = K.clip(K.sign(l_c) + o_value, z_value, o_value) # 1 if linear state - - h_out = (o_value - index_dead) * h_out - g_out = (o_value - index_dead) * g_out - h_out = (o_value - index_linear) * h_out + index_linear * h - g_out = (o_value - index_linear) * g_out + index_linear * g - else: - h_out, g_out = empty_tensor, empty_tensor - - if ibp: - u_c_out = K.relu(u_c) - l_c_out = K.relu(l_c) - else: - u_c_out = empty_tensor - l_c_out = empty_tensor - - if affine: - if isinstance(perturbation_domain, GridDomain): - upper_g, lower_g = get_bound_grid(x, w_u, b_u, w_l, b_l, 1) - kwargs.update({"upper_grid": upper_g, "lower_grid": lower_g}) - - w_u_out, b_u_out, w_l_out, b_l_out = get_linear_hull_relu(u_c, l_c, slope, **kwargs) - b_u_out = w_u_out * b_u + b_u_out - b_l_out = w_l_out * b_l + b_l_out - w_u_out = K.expand_dims(w_u_out, 1) * w_u - w_l_out = K.expand_dims(w_l_out, 1) * w_l - else: - w_u_out, b_u_out, w_l_out, b_l_out = empty_tensor, empty_tensor, empty_tensor, empty_tensor - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def minus( - inputs: List[Tensor], - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA implementation of minus(x)=-x. - - Args: - inputs - mode - - Returns: - - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode( - inputs, tight=False, compute_ibp_from_affine=False - ) - - u_c_out = -l_c - l_c_out = -u_c - w_u_out = -w_l - b_u_out = -b_l - w_l_out = -w_u - b_l_out = -b_u - h_out = -g - g_out = -h - - return inputs_outputs_spec.extract_outputsformode_from_fulloutputs( - [x, u_c_out, w_u_out, b_u_out, l_c_out, w_l_out, b_l_out, h_out, g_out] - ) - - -def maximum( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA implementation of element-wise max - - Args: - inputs_0: list of tensors - inputs_1: list of tensors - dc_decomp: boolean that indicates - perturbation_domain: the type of perturbation domain - whether we return a difference of convex decomposition of our layer - - Returns: - maximum(inputs_0, inputs_1) - """ - if perturbation_domain is None: - perturbation_domain = BoxDomain() - output_0 = subtract(inputs_1, inputs_0, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode) - if finetune: - finetune = kwargs["finetune_params"] - output_1 = relu_( - output_0, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode, finetune=finetune - ) - else: - output_1 = relu_(output_0, dc_decomp=dc_decomp, perturbation_domain=perturbation_domain, mode=mode) - - return add( - output_1, - inputs_0, - dc_decomp=dc_decomp, - perturbation_domain=perturbation_domain, - mode=mode, - ) - - -def minimum( - inputs_0: List[Tensor], - inputs_1: List[Tensor], - dc_decomp: bool = False, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - finetune: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """LiRPA implementation of element-wise min - - Args: - inputs_0 - inputs_1 - dc_decomp - perturbation_domain - mode - - Returns: - - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - return minus( - maximum( - minus(inputs_0, dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain), - minus(inputs_1, dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain), - dc_decomp=dc_decomp, - perturbation_domain=perturbation_domain, - mode=mode, - finetune=finetune, - **kwargs, - ), - dc_decomp=dc_decomp, - mode=mode, - perturbation_domain=perturbation_domain, - ) - - -def get_linear_hull_s_shape( - inputs: List[Tensor], - func: TensorFunction = K.sigmoid, - f_prime: TensorFunction = sigmoid_prime, - perturbation_domain: Optional[PerturbationDomain] = None, - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - slope: Union[str, Slope] = Slope.V_SLOPE, - dc_decomp: bool = False, - **kwargs: Any, -) -> List[Tensor]: - """Computing the linear hull of shape functions given the pre activation neurons - - Args: - inputs: list of input tensors - func: the function (sigmoid, tanh, softsign...) - f_prime: the derivative of the function (sigmoid_prime...) - perturbation_domain: the type of convex input domain - mode: type of Forward propagation (ibp, affine, or hybrid) - - Returns: - the updated list of tensors - """ - - if perturbation_domain is None: - perturbation_domain = BoxDomain() - mode = ForwardMode(mode) - inputs_outputs_spec = InputsOutputsSpec(dc_decomp=dc_decomp, mode=mode, perturbation_domain=perturbation_domain) - - x, u_c, w_u, b_u, l_c, w_l, b_l, h, g = inputs_outputs_spec.get_fullinputs_from_inputsformode(inputs) - dtype = x.dtype - - z_value = K.cast(0.0, dtype=dtype) - o_value = K.cast(1.0, dtype=dtype) - t_value = K.cast(2.0, dtype=dtype) - - # flatten - shape = list(u_c.shape[1:]) - u_c_flat = K.reshape(u_c, (-1, int(np.prod(shape)))) # (None, n) - l_c_flat = K.reshape(l_c, (-1, int(np.prod(shape)))) # (None, n) - - # upper bound - # derivative - s_u_prime = f_prime(u_c_flat) # (None, n) - s_l_prime = f_prime(l_c_flat) # (None, n) - s_u = func(u_c_flat) # (None, n) - s_l = func(l_c_flat) # (None, n) - - # case 0: - coeff = (s_u - s_l) / K.maximum(K.cast(epsilon(), dtype=dtype), u_c_flat - l_c_flat) - alpha_u_0 = K.where( - K.greater_equal(s_u_prime, coeff), o_value + z_value * u_c_flat, z_value * u_c_flat - ) # (None, n) - alpha_u_1 = (o_value - alpha_u_0) * ((K.sign(l_c_flat) + o_value) / t_value) - - w_u_0 = coeff - b_u_0 = -w_u_0 * l_c_flat + s_l - - w_u_1 = z_value * u_c_flat - b_u_1 = s_u - - w_u_2, b_u_2 = get_t_upper(u_c_flat, l_c_flat, s_l, func=func, f_prime=f_prime) - - w_u_out = K.reshape(alpha_u_0 * w_u_0 + alpha_u_1 * w_u_1 + (o_value - alpha_u_0 - alpha_u_1) * w_u_2, [-1] + shape) - b_u_out = K.reshape(alpha_u_0 * b_u_0 + alpha_u_1 * b_u_1 + (o_value - alpha_u_0 - alpha_u_1) * b_u_2, [-1] + shape) - - # linear hull - # case 0: - alpha_l_0 = K.where( - K.greater_equal(s_l_prime, coeff), o_value + z_value * l_c_flat, z_value * l_c_flat - ) # (None, n) - alpha_l_1 = (o_value - alpha_l_0) * ((K.sign(-u_c_flat) + o_value) / t_value) - - w_l_0 = coeff - b_l_0 = -w_l_0 * u_c_flat + s_u - - w_l_1 = z_value * u_c_flat - b_l_1 = s_l - - w_l_2, b_l_2 = get_t_lower(u_c_flat, l_c_flat, s_u, func=func, f_prime=f_prime) - - w_l_out = K.reshape(alpha_l_0 * w_l_0 + alpha_l_1 * w_l_1 + (o_value - alpha_l_0 - alpha_l_1) * w_l_2, [-1] + shape) - b_l_out = K.reshape(alpha_l_0 * b_l_0 + alpha_l_1 * b_l_1 + (o_value - alpha_l_0 - alpha_l_1) * b_l_2, [-1] + shape) - - return [w_u_out, b_u_out, w_l_out, b_l_out] - - -def get_t_upper( - u_c_flat: Tensor, - l_c_flat: Tensor, - s_l: Tensor, - func: TensorFunction = K.sigmoid, - f_prime: TensorFunction = sigmoid_prime, -) -> List[Tensor]: - """linear interpolation between lower and upper bounds on the function func to have a symbolic approximation of the best - coefficient for the affine upper bound - - Args: - u_c_flat: flatten tensor of constant upper bound - l_c_flat: flatten tensor of constant lower bound - s_l: lowest value of the function func on the domain - func: the function (sigmoid, tanh, softsign) - f_prime: the derivative of the function - - Returns: - the upper affine bounds in this subcase - """ - - o_value = K.cast(1.0, dtype=u_c_flat.dtype) - z_value = K.cast(0.0, dtype=u_c_flat.dtype) - - # step1: find t - u_c_reshaped = K.expand_dims(u_c_flat, -1) # (None, n , 1) - l_c_reshaped = K.expand_dims(l_c_flat, -1) # (None, n, 1) - t = K.cast(np.linspace(0, 1, 100)[None, None, :], dtype=u_c_flat.dtype) * u_c_reshaped # (None, n , 100) - - s_p_t = f_prime(t) # (None, n, 100) - s_t = func(t) # (None, n, 100) - - score = K.abs(s_p_t - (s_t - K.expand_dims(s_l, -1)) / (t - l_c_reshaped)) # (None, n, 100) - index = K.argmin(score, -1) # (None, n) - threshold = K.min(score, -1) # (None, n) - - index_t = K.cast( - K.where(K.greater(threshold, z_value * threshold), index, K.clip(index - 1, 0, 100)), dtype=u_c_flat.dtype - ) # (None, n) - t_value = K.sum( - K.where( - K.equal( - o_value * K.cast(np.arange(0, 100)[None, None, :], dtype=u_c_flat.dtype) + z_value * u_c_reshaped, - K.expand_dims(index_t, -1) + z_value * u_c_reshaped, - ), - t, - z_value * t, - ), - -1, - ) # (None, n) - - s_t = func(t_value) # (None, n) - w_u = (s_t - s_l) / K.maximum(K.cast(epsilon(), dtype=u_c_flat.dtype), t_value - l_c_flat) # (None, n) - b_u = -w_u * l_c_flat + s_l # + func(l_c_flat) - - return [w_u, b_u] - - -def get_t_lower( - u_c_flat: Tensor, - l_c_flat: Tensor, - s_u: Tensor, - func: TensorFunction = K.sigmoid, - f_prime: TensorFunction = sigmoid_prime, -) -> List[Tensor]: - """linear interpolation between lower and upper bounds on the function func to have a symbolic approximation of the best - coefficient for the affine lower bound - - Args: - u_c_flat: flatten tensor of constant upper bound - l_c_flat: flatten tensor of constant lower bound - s_u: highest value of the function func on the domain - func: the function (sigmoid, tanh, softsign) - f_prime: the derivative of the function - - Returns: - the lower affine bounds in this subcase - """ - z_value = K.cast(0.0, dtype=u_c_flat.dtype) - o_value = K.cast(1.0, dtype=u_c_flat.dtype) - - # step1: find t - u_c_reshaped = K.expand_dims(u_c_flat, -1) # (None, n , 1) - l_c_reshaped = K.expand_dims(l_c_flat, -1) # (None, n, 1) - t = K.cast(np.linspace(0, 1.0, 100)[None, None, :], dtype=u_c_flat.dtype) * l_c_reshaped # (None, n , 100) - - s_p_t = f_prime(t) # (None, n, 100) - s_t = func(t) # (None, n, 100) - - score = K.abs(s_p_t - (K.expand_dims(s_u, -1) - s_t) / (u_c_reshaped - t)) # (None, n, 100) - index = K.argmin(score, -1) # (None, n) - - threshold = K.min(score, -1) - index_t = K.cast( - K.where(K.greater(threshold, z_value * threshold), index, K.clip(index + 1, 0, 100)), dtype=u_c_flat.dtype - ) # (None, n) - t_value = K.sum( - K.where( - K.equal( - o_value * K.cast(np.arange(0, 100)[None, None, :], dtype=u_c_flat.dtype) + z_value * u_c_reshaped, - K.expand_dims(index_t, -1) + z_value * u_c_reshaped, - ), - t, - z_value * t, - ), - -1, - ) - - s_t = func(t_value) # (None, n) - w_l = (s_u - s_t) / K.maximum(K.cast(epsilon(), dtype=u_c_flat.dtype), u_c_flat - t_value) # (None, n) - b_l = -w_l * u_c_flat + s_u # func(u_c_flat) - - return [w_l, b_l] diff --git a/src/decomon/visualization/__init__.py b/src/decomon/visualization/__init__.py new file mode 100644 index 00000000..8ffb3f4b --- /dev/null +++ b/src/decomon/visualization/__init__.py @@ -0,0 +1 @@ +from .model_visualization import plot_model diff --git a/src/decomon/visualization/model_visualization.py b/src/decomon/visualization/model_visualization.py new file mode 100644 index 00000000..8c401942 --- /dev/null +++ b/src/decomon/visualization/model_visualization.py @@ -0,0 +1,441 @@ +from keras.src.utils.model_visualization import * + +from decomon.layers.crown import ReduceCrownBounds +from decomon.layers.fuse import Fuse +from decomon.layers.input import BackwardInput, ForwardInput, IdentityInput +from decomon.layers.oracle import DecomonOracle +from decomon.layers.output import ConvertOutput +from decomon.layers.utils.batchsize import InsertBatchAxis +from decomon.layers.utils.symbolify import LinkToPerturbationDomainInput + +decomon_utilitary_layers_default_style = dict(bgcolor="#82E0AA", color="black") +decomon_utilitary_layers = [ + LinkToPerturbationDomainInput, + InsertBatchAxis, + ReduceCrownBounds, + Fuse, + BackwardInput, + IdentityInput, + ForwardInput, + ConvertOutput, + DecomonOracle, +] +decomon_utilitary_layers_styles = {l: decomon_utilitary_layers_default_style for l in decomon_utilitary_layers} +show_decomon_layer_attributes = { + "propagation": { + "forward": { + "bgcolor": "#b3e6ff", + }, + "backward": { + "bgcolor": "#ffccdd", + }, + None: {"color": "black"}, + }, + "layer.name": {}, +} + + +def get_layer_attribute(layer, name): + subnames = name.split(".") + attr = layer + for subname in subnames: + attr = getattr(attr, subname, None) + if attr is None: + return None + return attr + + +def make_layer_label(layer, **kwargs): + class_name = layer.__class__.__name__ + + show_layer_names = kwargs.pop("show_layer_names") + show_layer_activations = kwargs.pop("show_layer_activations") + show_layer_attributes = kwargs.pop("show_layer_attributes", {}) + show_dtype = kwargs.pop("show_dtype") + show_shapes = kwargs.pop("show_shapes") + show_trainable = kwargs.pop("show_trainable") + layer_styles = kwargs.pop("layer_styles", {}) + if kwargs: + raise ValueError(f"Invalid kwargs: {kwargs}") + + table = '<' + + colspan = max(1, sum(int(x) for x in (show_dtype, show_shapes, show_trainable))) + + # style for layer node, according to its class, as specified in layer_styles + layer_style_to_apply = {"color": "white", "bgcolor": "black"} # default style + layer_style_to_apply.update(layer_styles.get(type(layer), {})) + + if show_layer_names: + table += ( + f'" + ) + else: + table += ( + f'" + ) + if show_layer_activations and hasattr(layer, "activation") and layer.activation is not None: + table += ( + f'" + ) + for attr_name, attr_style in show_layer_attributes.items(): + attribute = get_layer_attribute(layer, attr_name) + if attribute is not None: + default_style = {"bgcolor": "white", "color": "black"} + default_style.update(attr_style.get(None, {})) + style_to_apply = dict(default_style) + if attribute in attr_style: + style_to_apply.update(attr_style[attribute]) + table += ( + f'" + ) + + cols = [] + if show_shapes: + shape = None + try: + shape = layer.output.shape + except ValueError: + pass + cols.append( + ('") + ) + if show_dtype: + dtype = None + try: + dtype = layer.output.dtype + except ValueError: + pass + cols.append( + ('") + ) + if show_trainable and hasattr(layer, "trainable") and layer.weights: + if layer.trainable: + cols.append( + ('") + ) + else: + cols.append( + ('") + ) + if cols: + colspan = len(cols) + else: + colspan = 1 + + if cols: + table += "" + "".join(cols) + "" + table += "
' + f'' + f"{layer.name} ({class_name})" + "
' + f'' + f"{class_name}" + "
' + '' + f"Activation: {get_layer_activation_name(layer)}" + "
' + f'' + f"{attr_name.capitalize()}: {attribute}" + "
' f'Output shape: {shape or "?"}' "' f'Output dtype: {dtype or "?"}' "' '' "Trainable' '' "Non-trainable
>" + return table + + +def make_node(layer, **kwargs): + node = pydot.Node(str(id(layer)), label=make_layer_label(layer, **kwargs)) + node.set("fontname", "Helvetica") + node.set("border", "0") + node.set("margin", "0") + return node + + +def model_to_dot( + model, + show_shapes=False, + show_dtype=False, + show_layer_names=True, + rankdir="TB", + expand_nested=False, + dpi=200, + subgraph=False, + show_layer_activations=False, + show_trainable=False, + show_layer_attributes=None, + layer_styles=None, + **kwargs, +): + """Convert a Keras model to dot format. + + Args: + model: A Keras model instance. + show_shapes: whether to display shape information. + show_dtype: whether to display layer dtypes. + show_layer_names: whether to display layer names. + rankdir: `rankdir` argument passed to PyDot, + a string specifying the format of the plot: `"TB"` + creates a vertical plot; `"LR"` creates a horizontal plot. + expand_nested: whether to expand nested Functional models + into clusters. + dpi: Image resolution in dots per inch. + subgraph: whether to return a `pydot.Cluster` instance. + show_layer_activations: Display layer activations (only for layers that + have an `activation` property). + show_trainable: whether to display if a layer is trainable. + + Returns: + A `pydot.Dot` instance representing the Keras model or + a `pydot.Cluster` instance representing nested model if + `subgraph=True`. + """ + from keras.src.ops.function import make_node_key + + if not model.built: + raise ValueError( + "This model has not yet been built. " + "Build the model first by calling `build()` or by calling " + "the model on a batch of data." + ) + + from keras.src.models import functional, sequential + + # from keras.src.layers import Wrapper + + if not check_pydot(): + raise ImportError("You must install pydot (`pip install pydot`) for " "model_to_dot to work.") + + if subgraph: + dot = pydot.Cluster(style="dashed", graph_name=model.name) + dot.set("label", model.name) + dot.set("labeljust", "l") + else: + dot = pydot.Dot() + dot.set("rankdir", rankdir) + dot.set("concentrate", True) + dot.set("dpi", dpi) + dot.set("splines", "ortho") + dot.set_node_defaults(shape="record") + + if kwargs.pop("layer_range", None) is not None: + raise ValueError("Argument `layer_range` is no longer supported.") + if kwargs: + raise ValueError(f"Unrecognized keyword arguments: {kwargs}") + + kwargs = { + "show_layer_names": show_layer_names, + "show_layer_activations": show_layer_activations, + "show_dtype": show_dtype, + "show_shapes": show_shapes, + "show_trainable": show_trainable, + "show_layer_attributes": show_layer_attributes, + "layer_styles": layer_styles, + } + + if isinstance(model, sequential.Sequential): + # TODO + layers = model.layers + elif not isinstance(model, functional.Functional): + # We treat subclassed models as a single node. + node = make_node(model, **kwargs) + dot.add_node(node) + return dot + else: + layers = model._operations + + # Create graph nodes. + sub_n_first_node = {} + sub_n_last_node = {} + for i, layer in enumerate(layers): + # Process nested functional models. + if expand_nested and isinstance(layer, functional.Functional): + submodel = model_to_dot( + layer, + show_shapes, + show_dtype, + show_layer_names, + rankdir, + expand_nested, + subgraph=True, + show_layer_activations=show_layer_activations, + show_trainable=show_trainable, + show_layer_attributes=show_layer_attributes, + layer_styles=layer_styles, + ) + # sub_n : submodel + sub_n_nodes = submodel.get_nodes() + sub_n_first_node[layer.name] = sub_n_nodes[0] + sub_n_last_node[layer.name] = sub_n_nodes[-1] + dot.add_subgraph(submodel) + + else: + node = make_node(layer, **kwargs) + dot.add_node(node) + + # Connect nodes with edges. + # Sequential case. + if isinstance(model, sequential.Sequential): + for i in range(len(layers) - 1): + inbound_layer_id = str(id(layers[i])) + layer_id = str(id(layers[i + 1])) + add_edge(dot, inbound_layer_id, layer_id) + return dot + + # Functional case. + for i, layer in enumerate(layers): + layer_id = str(id(layer)) + for i, node in enumerate(layer._inbound_nodes): + node_key = make_node_key(layer, i) + if node_key in model._nodes: + for parent_node in node.parent_nodes: + inbound_layer = parent_node.operation + inbound_layer_id = str(id(inbound_layer)) + if not expand_nested: + assert dot.get_node(inbound_layer_id) + assert dot.get_node(layer_id) + add_edge(dot, inbound_layer_id, layer_id) + else: + # if inbound_layer is not Functional + if not isinstance(inbound_layer, functional.Functional): + # if current layer is not Functional + if not isinstance(layer, functional.Functional): + assert dot.get_node(inbound_layer_id) + assert dot.get_node(layer_id) + add_edge(dot, inbound_layer_id, layer_id) + # if current layer is Functional + elif isinstance(layer, functional.Functional): + add_edge( + dot, + inbound_layer_id, + sub_n_first_node[layer.name].get_name(), + ) + # if inbound_layer is Functional + elif isinstance(inbound_layer, functional.Functional): + name = sub_n_last_node[inbound_layer.name].get_name() + if isinstance(layer, functional.Functional): + output_name = sub_n_first_node[layer.name].get_name() + add_edge(dot, name, output_name) + else: + add_edge(dot, name, layer_id) + return dot + + +def plot_model( + model, + to_file="model.png", + show_shapes=False, + show_dtype=False, + show_layer_names=True, + rankdir="TB", + expand_nested=False, + dpi=64, + show_layer_activations=False, + show_trainable=False, + show_layer_attributes=None, + layer_styles=None, + **kwargs, +): + """Converts a Keras model to dot format and save to a file. + + Example: + + ```python + inputs = ... + outputs = ... + model = keras.Model(inputs=inputs, outputs=outputs) + + dot_img_file = '/tmp/model_1.png' + keras.utils.plot_model(model, to_file=dot_img_file, show_shapes=True) + ``` + + Args: + model: A Keras model instance + to_file: File name of the plot image. + show_shapes: whether to display shape information. + show_dtype: whether to display layer dtypes. + show_layer_names: whether to display layer names. + rankdir: `rankdir` argument passed to PyDot, + a string specifying the format of the plot: `"TB"` + creates a vertical plot; `"LR"` creates a horizontal plot. + expand_nested: whether to expand nested Functional models + into clusters. + dpi: Image resolution in dots per inch. + show_layer_activations: Display layer activations (only for layers that + have an `activation` property). + show_trainable: whether to display if a layer is trainable. + + Returns: + A Jupyter notebook Image object if Jupyter is installed. + This enables in-line display of the model plots in notebooks. + """ + + if not model.built: + raise ValueError( + "This model has not yet been built. " + "Build the model first by calling `build()` or by calling " + "the model on a batch of data." + ) + if not check_pydot(): + message = "You must install pydot (`pip install pydot`) " "for `plot_model` to work." + if "IPython.core.magics.namespace" in sys.modules: + # We don't raise an exception here in order to avoid crashing + # notebook tests where graphviz is not available. + io_utils.print_msg(message) + return + else: + raise ImportError(message) + if not check_graphviz(): + message = ( + "You must install graphviz " + "(see instructions at https://graphviz.gitlab.io/download/) " + "for `plot_model` to work." + ) + if "IPython.core.magics.namespace" in sys.modules: + # We don't raise an exception here in order to avoid crashing + # notebook tests where graphviz is not available. + io_utils.print_msg(message) + return + else: + raise ImportError(message) + + if kwargs.pop("layer_range", None) is not None: + raise ValueError("Argument `layer_range` is no longer supported.") + if kwargs: + raise ValueError(f"Unrecognized keyword arguments: {kwargs}") + + if show_layer_attributes is None: + show_layer_attributes = show_decomon_layer_attributes + if layer_styles is None: + layer_styles = decomon_utilitary_layers_styles + + dot = model_to_dot( + model, + show_shapes=show_shapes, + show_dtype=show_dtype, + show_layer_names=show_layer_names, + rankdir=rankdir, + expand_nested=expand_nested, + dpi=dpi, + show_layer_activations=show_layer_activations, + show_trainable=show_trainable, + show_layer_attributes=show_layer_attributes, + layer_styles=layer_styles, + ) + to_file = str(to_file) + if dot is None: + return + _, extension = os.path.splitext(to_file) + if not extension: + extension = "png" + else: + extension = extension[1:] + # Save image to disk. + dot.write(to_file, format=extension) + # Return the image as a Jupyter Image object, to be displayed in-line. + # Note that we cannot easily detect whether the code is running in a + # notebook, and thus we always return the Image if Jupyter is available. + if extension != "pdf": + try: + from IPython import display + + return display.Image(filename=to_file) + except ImportError: + pass diff --git a/src/decomon/wrapper.py b/src/decomon/wrapper.py index d3952e48..34c3c248 100644 --- a/src/decomon/wrapper.py +++ b/src/decomon/wrapper.py @@ -1,13 +1,14 @@ -from typing import Callable, List, Optional, Sequence, Tuple, Union +from collections.abc import Callable, Sequence +from typing import Optional, Union import keras import numpy as np import numpy.typing as npt -from decomon.core import BallDomain, BoxDomain, GridDomain +from decomon.constants import ConvertMethod from decomon.models.convert import clone from decomon.models.models import DecomonModel -from decomon.models.utils import ConvertMethod +from decomon.perturbation_domain import BallDomain, BoxDomain, GridDomain IntegerType = Union[int, np.int_] """Alias for integers types.""" @@ -61,8 +62,12 @@ def get_adv_box( batch_size: for computational efficiency, one can split the calls to minibatches n_sub_boxes: + Returns: numpy array, vector with upper bounds for adversarial attacks + + Hypothesis: the underlying keras model has only 1 output + """ if np.min(x_max - x_min) < 0: raise UserWarning("Inconsistency Error: x_max < x_min") @@ -75,6 +80,11 @@ def get_adv_box( assert isinstance(model.perturbation_domain, (BoxDomain, GridDomain)) decomon_model = model + # if missing batch axis, add it + input_shape = decomon_model.model.inputs[0].shape[1:] + x_min = np.reshape(x_min, (-1,) + input_shape) + x_max = np.reshape(x_max, (-1,) + input_shape) + n_split = 1 n_batch = len(x_min) @@ -86,24 +96,12 @@ def get_adv_box( x_min = np.reshape(x_min, [-1] + shape) x_max = np.reshape(x_max, [-1] + shape) - # reshape x_mmin, x_max - if decomon_model.backward_bounds: - input_shape = list(decomon_model.input_shape[0][2:]) - else: - input_shape = list(decomon_model.input_shape[2:]) - input_dim = np.prod(input_shape) - x_reshaped = x_min + 0 * x_min - x_reshaped = x_reshaped.reshape([-1] + input_shape) - - x_min = x_min.reshape((-1, 1, input_dim)) - x_max = x_max.reshape((-1, 1, input_dim)) - - z = np.concatenate([x_min, x_max], 1) + # construct perturbation_domain_input + perturbation_domain_input = np.concatenate([x_min[:, None], x_max[:, None]], 1) source_labels = prepare_labels(source_labels, n_batch) if target_labels is not None: target_labels = prepare_labels(target_labels, n_batch) - if n_split > 1: shape = list(source_labels.shape[1:]) source_labels = np.reshape(np.concatenate([source_labels[:, None]] * n_split, 1), [-1] + shape) @@ -113,16 +111,24 @@ def get_adv_box( if batch_size > 0: # split r = 0 - if len(x_reshaped) % batch_size > 0: + if len(perturbation_domain_input) % batch_size > 0: r += 1 - x_min_list = [x_min[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] - x_max_list = [x_max[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] + x_min_list = [ + x_min[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) + ] + x_max_list = [ + x_max[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) + ] source_labels_list = [ - source_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r) + source_labels[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) ] if target_labels is not None: target_labels_list = [ - target_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r) + target_labels[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) ] adv_score = np.concatenate( [ @@ -146,12 +152,13 @@ def get_adv_box( n_label = source_labels.shape[-1] # two possitible cases: the model improves the bound based on the knowledge of the labels - output: List[npt.NDArray[np.float_]] - if decomon_model.backward_bounds: + output: list[npt.NDArray[np.float_]] + needs_backward_bounds = len(decomon_model.inputs) > 1 + if needs_backward_bounds: # backward bounds needed C = np.diag([1] * n_label)[None] - source_labels[:, :, None] - output = decomon_model.predict_on_single_batch_np([z, C]) # type: ignore + output = decomon_model.predict_on_single_batch_np([perturbation_domain_input, C]) # type: ignore else: - output = decomon_model.predict_on_single_batch_np(z) # type: ignore + output = decomon_model.predict_on_single_batch_np(perturbation_domain_input) # type: ignore def get_ibp_score( u_c: npt.NDArray[np.float_], @@ -229,19 +236,26 @@ def get_affine_score( return np.max(np.max(upper, -2), -1) if ibp and affine: - z, u_c, w_u_f, b_u_f, l_c, w_l_f, b_l_f = output[:7] + w_l_f, b_l_f, w_u_f, b_u_f, l_c, u_c = output elif not ibp and affine: - z, w_u_f, b_u_f, w_l_f, b_l_f = output[:5] + w_l_f, b_l_f, w_u_f, b_u_f = output elif ibp and not affine: - u_c, l_c = output[:2] + l_c, u_c = output else: raise NotImplementedError("not ibp and not affine not implemented") if ibp: - adv_ibp = get_ibp_score(u_c, l_c, source_labels, target_labels, backward=decomon_model.backward_bounds) + adv_ibp = get_ibp_score(u_c, l_c, source_labels, target_labels, backward=needs_backward_bounds) if affine: adv_f = get_affine_score( - z, w_u_f, b_u_f, w_l_f, b_l_f, source_labels, target_labels, backward=decomon_model.backward_bounds + perturbation_domain_input, + w_u_f, + b_u_f, + w_l_f, + b_l_f, + source_labels, + target_labels, + backward=needs_backward_bounds, ) if ibp and not affine: @@ -295,21 +309,17 @@ def check_adv_box( assert isinstance(model.perturbation_domain, (BoxDomain, GridDomain)) decomon_model = model - ibp = decomon_model.ibp - affine = decomon_model.affine + # if missing batch axis, add it + input_shape = decomon_model.model.inputs[0].shape[1:] + x_min = np.reshape(x_min, (-1,) + input_shape) + x_max = np.reshape(x_max, (-1,) + input_shape) n_split = 1 n_batch = len(x_min) - # reshape x_mmin, x_max - input_shape = list(decomon_model.input_shape[2:]) - input_dim = np.prod(input_shape) - x_reshaped = x_min + 0 * x_min - x_reshaped = x_reshaped.reshape([-1] + input_shape) - x_min = x_min.reshape((-1, 1, input_dim)) - x_max = x_max.reshape((-1, 1, input_dim)) + # construct perturbation_domain_input + perturbation_domain_input = np.concatenate([x_min[:, None], x_max[:, None]], 1) - z = np.concatenate([x_min, x_max], 1) if isinstance(source_labels, (int, np.int_)): source_labels = np.zeros((n_batch, 1)) + source_labels @@ -332,24 +342,32 @@ def check_adv_box( if batch_size > 0: # split r = 0 - if len(x_reshaped) % batch_size > 0: + if len(perturbation_domain_input) % batch_size > 0: r += 1 - x_min_list = [x_min[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] - x_max_list = [x_max[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] + x_min_list = [ + x_min[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) + ] + x_max_list = [ + x_max[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) + ] source_labels_list = [ - source_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r) + source_labels[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) ] - target_labels_list: List[Optional[npt.NDArray[np.int_]]] + target_labels_list: list[Optional[npt.NDArray[np.int_]]] if ( (target_labels is not None) and (not isinstance(target_labels, int)) and (str(target_labels.dtype)[:3] != "int") ): target_labels_list = [ - target_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r) + target_labels[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) ] else: - target_labels_list = [target_labels] * (len(x_reshaped) // batch_size + r) + target_labels_list = [target_labels] * (len(perturbation_domain_input) // batch_size + r) return np.concatenate( [ @@ -361,15 +379,7 @@ def check_adv_box( ) else: - output = decomon_model.predict_on_single_batch_np(z) - - if not affine: - # translate into affine information - u_c = output[0] - w_u = 0 * u_c[:, None] + np.zeros((1, input_dim, 1)) - output = [z, w_u, u_c, w_u, output[-1]] - ibp = False - affine = True + w_l_f, b_l_f, w_u_f, b_u_f = decomon_model.compute_affine_bounds_np([perturbation_domain_input]) def get_affine_sample( z_tensor: npt.NDArray[np.float_], @@ -409,12 +419,7 @@ def get_affine_sample( return np.max(np.max(upper, -2), -1) - if ibp: - z, u_c, w_u_f, b_u_f, l_c, w_l_f, b_l_f = output[:7] - else: - z, w_u_f, b_u_f, w_l_f, b_l_f = output[:5] - - return get_affine_sample(z, w_l_f, b_l_f, w_u_f, b_u_f, source_labels) + return get_affine_sample(perturbation_domain_input, w_l_f, b_l_f, w_u_f, b_u_f, source_labels) #### FORMAL BOUNDS ###### @@ -470,20 +475,24 @@ def get_range_box( x_max: npt.NDArray[np.float_], batch_size: int = -1, n_sub_boxes: int = 1, -) -> Tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: - """bounding the outputs of a model in a given box - if the constant is negative, then it is a formal guarantee that there is no adversarial examples +) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: + """Bound the outputs of a model in a given box Args: model: either a Keras model or a Decomon model x_min: numpy array for the extremal lower corner of the boxes x_max: numpy array for the extremal upper corner of the boxes batch_size: for computational efficiency, one can split the - calls to minibatches + calls into minibatches Returns: 2 numpy array, vector with upper bounds and vector with lower bounds + + Hypotheses: + - the underlying keras model has only 1 output; + - if already cloned as a decomon model, non precomputed backward_bounds have been given. + """ if np.min(x_max - x_min) < 0: raise UserWarning("Inconsistency Error: x_max < x_min") @@ -495,8 +504,10 @@ def get_range_box( assert isinstance(model.perturbation_domain, (BoxDomain, GridDomain)) decomon_model = model - n_split = 1 - n_batch = len(x_min) + # if missing batch axis, add it + input_shape = decomon_model.model.inputs[0].shape[1:] + x_min = np.reshape(x_min, (-1,) + input_shape) + x_max = np.reshape(x_max, (-1,) + input_shape) if n_sub_boxes > 1: x_min, x_max = refine_boxes(x_min, x_max, n_sub_boxes) @@ -505,87 +516,33 @@ def get_range_box( shape = list(x_min.shape[2:]) x_min = np.reshape(x_min, [-1] + shape) x_max = np.reshape(x_max, [-1] + shape) + else: + n_split = 1 - # reshape x_mmin, x_max - input_shape = list(decomon_model.input_shape[2:]) - input_dim = np.prod(input_shape) - x_reshaped = x_min + 0 * x_min - x_reshaped = x_reshaped.reshape([-1] + input_shape) - x_min = x_min.reshape((-1, 1, input_dim)) - x_max = x_max.reshape((-1, 1, input_dim)) - - z = np.concatenate([x_min, x_max], 1) + # construct perturbation_domain_input + perturbation_domain_input = np.concatenate([x_min[:, None], x_max[:, None]], 1) if batch_size > 0: - # split + # call by minibatch r = 0 - if len(x_reshaped) % batch_size > 0: + if len(perturbation_domain_input) % batch_size > 0: r += 1 - x_min_list = [x_min[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] - x_max_list = [x_max[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] + x_min_list = [ + x_min[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) + ] + x_max_list = [ + x_max[batch_size * i : batch_size * (i + 1)] + for i in range(len(perturbation_domain_input) // batch_size + r) + ] results = [get_range_box(decomon_model, x_min_list[i], x_max_list[i], -1) for i in range(len(x_min_list))] u_out = np.concatenate([r[0] for r in results]) l_out = np.concatenate([r[1] for r in results]) else: - ibp = decomon_model.ibp - affine = decomon_model.affine - - output = decomon_model.predict_on_single_batch_np(z) - shape = list(output[-1].shape[1:]) - output_dim = np.prod(shape) - - if affine: - if ibp: - _, u_i, w_u_f, b_u_f, l_i, w_l_f, b_l_f = output[:7] - if len(u_i.shape) > 2: - u_i = np.reshape(u_i, (-1, output_dim)) - l_i = np.reshape(l_i, (-1, output_dim)) - else: - _, w_u_f, b_u_f, w_l_f, b_l_f = output[:5] - - # reshape if necessary - if len(w_u_f.shape) > 3: - w_u_f = np.reshape(w_u_f, (-1, input_dim, output_dim)) - w_l_f = np.reshape(w_l_f, (-1, input_dim, output_dim)) - b_u_f = np.reshape(b_u_f, (-1, output_dim)) - b_l_f = np.reshape(b_l_f, (-1, output_dim)) - - u_f = ( - np.sum(np.maximum(w_u_f, 0) * x_max[:, 0, :, None], 1) - + np.sum(np.minimum(w_u_f, 0) * x_min[:, 0, :, None], 1) - + b_u_f - ) - l_f = ( - np.sum(np.maximum(w_l_f, 0) * x_min[:, 0, :, None], 1) - + np.sum(np.minimum(w_l_f, 0) * x_max[:, 0, :, None], 1) - + b_l_f - ) - - else: - u_i = output[0] - l_i = output[1] - if len(u_i.shape) > 2: - u_i = np.reshape(u_i, (-1, output_dim)) - l_i = np.reshape(l_i, (-1, output_dim)) - - if ibp and affine: - u_out = np.minimum(u_i, u_f) - l_out = np.maximum(l_i, l_f) - elif ibp and not affine: - u_out = u_i - l_out = l_i - elif not ibp and affine: - u_out = u_f - l_out = l_f - else: - raise NotImplementedError("not ibp and not affine not implemented") - - ##### - if len(shape) > 1: - u_out = np.reshape(u_out, [-1] + shape) - l_out = np.reshape(l_out, [-1] + shape) + # call on all data at once + l_out, u_out = decomon_model.compute_constant_bounds_np([perturbation_domain_input]) if n_split > 1: u_out = np.max(np.reshape(u_out, (-1, n_split)), -1) @@ -652,8 +609,8 @@ def get_range_noise( eps: float, p: float = np.inf, batch_size: int = -1, -) -> Tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: - """Bounds the output of a model in an Lp Ball +) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: + """Bound the output of a model on an Lp Ball Args: model: either a Keras model or a Decomon model @@ -661,11 +618,15 @@ def get_range_noise( eps: the radius of the ball p: the type of Lp norm (p=2, 1, np.inf) batch_size: for computational efficiency, one can split the - calls to minibatches + calls into minibatches Returns: - 2 numpy arrays, vector with upper andlower bounds - of the range of values taken by the model inside the ball + 2 numpy arrays, vector with upper and lower bounds of the range of values taken by the model inside the ball + + Hypotheses: + - the underlying keras model has only 1 output; + - if already cloned as a decomon model, non precomputed backward_bounds have been given. + """ # check that the model is a DecomonModel, else do the conversion @@ -682,81 +643,34 @@ def get_range_noise( if eps >= 0: decomon_model.set_domain(perturbation_domain) - # reshape x_mmin, x_max - input_shape = list(decomon_model.input_shape[1:]) - input_dim = np.prod(input_shape) - x_reshaped = x + 0 * x - x_reshaped = x_reshaped.reshape([-1] + input_shape) + # if missing batch axis, add it + input_shape = decomon_model.model.inputs[0].shape[1:] + x = np.reshape(x, (-1,) + input_shape) + + # perturbation_domain_input : x itself + perturbation_domain_input = x if batch_size > 0: - # split + # call by minibatch r = 0 - if len(x_reshaped) % batch_size > 0: + if len(perturbation_domain_input) % batch_size > 0: r += 1 - x_list = [x_reshaped[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] + x_list = [ + x[batch_size * i : batch_size * (i + 1)] for i in range(len(perturbation_domain_input) // batch_size + r) + ] results = [get_range_noise(decomon_model, x_list[i], eps=eps, p=p, batch_size=-1) for i in range(len(x_list))] return np.concatenate([r[0] for r in results]), np.concatenate([r[1] for r in results]) - ibp = decomon_model.ibp - affine = decomon_model.affine - - output = decomon_model.predict_on_single_batch_np(x_reshaped) - shape = list(output[-1].shape[1:]) - output_dim = np.prod(shape) - - x_reshaped = x_reshaped.reshape((len(x_reshaped), -1)) - ord = _get_dual_ord(p) - - if affine: - if ibp: - _, u_i, w_u_f, b_u_f, l_i, w_l_f, b_l_f = output[:7] - if len(u_i.shape) > 2: - u_i = np.reshape(u_i, (-1, output_dim)) - l_i = np.reshape(l_i, (-1, output_dim)) - else: - _, w_u_f, b_u_f, w_l_f, b_l_f = output[:5] - - # reshape if necessary - if len(w_u_f.shape) > 3: - w_u_f = np.reshape(w_u_f, (-1, input_dim, output_dim)) - b_u_f = np.reshape(b_u_f, (-1, output_dim)) - w_l_f = np.reshape(w_l_f, (-1, input_dim, output_dim)) - b_l_f = np.reshape(b_l_f, (-1, output_dim)) - - u_f = eps * np.linalg.norm(w_u_f, ord=ord, axis=1) + np.sum(w_u_f * x_reshaped[:, :, None], 1) + b_u_f - l_f = -eps * np.linalg.norm(w_l_f, ord=ord, axis=1) + np.sum(w_l_f * x_reshaped[:, :, None], 1) + b_l_f - - else: - u_i = output[0] - l_i = output[1] - if len(u_i.shape) > 2: - u_i = np.reshape(u_i, (-1, output_dim)) - l_i = np.reshape(l_i, (-1, output_dim)) - ###### - - if ibp and affine: - u_out = np.minimum(u_i, u_f) - l_out = np.maximum(l_i, l_f) - elif ibp and not affine: - u_out = u_i - l_out = l_i - elif not ibp and affine: - u_out = u_f - l_out = l_f else: - raise NotImplementedError("not ibp and not affine not implemented") - - if len(shape) > 1: - u_out = np.reshape(u_out, [-1] + shape) - l_out = np.reshape(l_out, [-1] + shape) - - return u_out, l_out + # call on all data at once + l_out, u_out = decomon_model.compute_constant_bounds_np([perturbation_domain_input]) + return u_out, l_out def refine_boxes( x_min: npt.NDArray[np.float_], x_max: npt.NDArray[np.float_], n_sub_boxes: int = 10 -) -> Tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: +) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: # flatten x_min and x_max shape = list(x_min.shape[1:]) output_dim = np.prod(shape) @@ -771,7 +685,7 @@ def refine_boxes( def split( x_min: npt.NDArray[np.float_], x_max: npt.NDArray[np.float_], j: npt.NDArray[np.int_] - ) -> Tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: + ) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]: n_0 = len(x_min) n_k = x_min.shape[1] @@ -948,16 +862,12 @@ def get_adv_noise( eps = model.perturbation_domain.eps - # reshape x_mmin, x_max - input_shape = list(decomon_model.input_shape[1:]) - x_reshaped = x + 0 * x - x_reshaped = x_reshaped.reshape([-1] + input_shape) - n_split = 1 - n_batch = len(x_reshaped) + # if missing batch axis, add it + input_shape = decomon_model.model.inputs[0].shape[1:] + x = np.reshape(x, (-1,) + input_shape) - input_shape = list(decomon_model.input_shape[1:]) - x_reshaped = x + 0 * x - x_reshaped = x_reshaped.reshape([-1] + input_shape) + n_split = 1 + n_batch = len(x) if isinstance(source_labels, (int, np.int_)): source_labels = np.zeros((n_batch, 1), dtype=np.int_) + source_labels @@ -971,23 +881,23 @@ def get_adv_noise( if batch_size > 0: # split r = 0 - if len(x_reshaped) % batch_size > 0: + if len(x) % batch_size > 0: r += 1 - x_list = [x_reshaped[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] + x_list = [x[batch_size * i : batch_size * (i + 1)] for i in range(len(x) // batch_size + r)] source_labels_list = [ - source_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r) + source_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x) // batch_size + r) ] - target_labels_list: List[Optional[npt.NDArray[np.int_]]] + target_labels_list: list[Optional[npt.NDArray[np.int_]]] if ( (target_labels is not None) and (not isinstance(target_labels, int)) and (str(target_labels.dtype)[:3] != "int") ): target_labels_list = [ - target_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r) + target_labels[batch_size * i : batch_size * (i + 1)] for i in range(len(x) // batch_size + r) ] else: - target_labels_list = [target_labels] * (len(x_reshaped) // batch_size + r) + target_labels_list = [target_labels] * (len(x) // batch_size + r) results = [ get_adv_noise( @@ -1006,7 +916,7 @@ def get_adv_noise( else: ibp = decomon_model.ibp affine = decomon_model.affine - output = decomon_model.predict_on_single_batch_np(x_reshaped) + output = decomon_model.predict_on_single_batch_np(x) def get_ibp_score( u_c: npt.NDArray[np.float_], @@ -1083,18 +993,18 @@ def get_affine_score( return np.max(np.max(upper, -2), -1) if ibp and affine: - z, u_c, w_u_f, b_u_f, l_c, w_l_f, b_l_f = output[:7] - if not ibp and affine: - z, w_u_f, b_u_f, w_l_f, b_l_f = output[:5] - if ibp and not affine: - u_c, l_c = output[:2] + w_l_f, b_l_f, w_u_f, b_u_f, l_c, u_c = output + elif not ibp and affine: + w_l_f, b_l_f, w_u_f, b_u_f = output + elif ibp and not affine: + l_c, u_c = output else: raise NotImplementedError("not ibp and not affine not implemented") if ibp: adv_ibp = get_ibp_score(u_c, l_c, source_labels, target_labels) if affine: - adv_f = get_affine_score(z, w_u_f, b_u_f, w_l_f, b_l_f, source_labels, target_labels) + adv_f = get_affine_score(x, w_u_f, b_u_f, w_l_f, b_l_f, source_labels, target_labels) if ibp and not affine: adv_score = adv_ibp diff --git a/src/decomon/wrapper_with_tuning.py b/src/decomon/wrapper_with_tuning.py deleted file mode 100644 index c7af68a6..00000000 --- a/src/decomon/wrapper_with_tuning.py +++ /dev/null @@ -1,183 +0,0 @@ -from typing import Union - -import keras -import numpy as np -import numpy.typing as npt -from keras.optimizers import Adam - -from decomon.metrics.loss import get_upper_loss -from decomon.models.models import DecomonModel -from decomon.wrapper import get_lower_box, get_upper_box, refine_boxes - - -#### FORMAL BOUNDS ###### -def get_upper_box_tuning( - model: Union[keras.Model, DecomonModel], - decomon_model_concat: keras.Model, - x_min: npt.NDArray[np.float_], - x_max: npt.NDArray[np.float_], - batch_size: int = 1, - n_sub_boxes: int = 1, - lr: float = 0.1, - epochs: int = 100, -) -> npt.NDArray[np.float_]: - """upper bound the maximum of a model in a given box - - Args: - model: either a Keras model or a Decomon model - x_min: numpy array for the extremal lower corner of the boxes - x_max: numpy array for the extremal upper corner of the boxes - batch_size: for computational efficiency, one can split the - calls to minibatches - fast: useful in the forward-backward or in the hybrid-backward - mode to optimize the scores - - Returns: - numpy array, vector with upper bounds for adversarial attacks - """ - - if np.min(x_max - x_min) < 0: - raise UserWarning("Inconsistency Error: x_max < x_min") - - # check that the model is a DecomonModel, else do the conversion - decomon_model = model - - baseline_upper = get_upper_box(model, x_min, x_max, batch_size=batch_size, n_sub_boxes=n_sub_boxes) - - if n_sub_boxes > 1: - x_min, x_max = refine_boxes(x_min, x_max, n_sub_boxes) - # reshape - shape = list(x_min.shape[2:]) - x_min = np.reshape(x_min, [-1] + shape) - x_max = np.reshape(x_max, [-1] + shape) - - # reshape x_mmin, x_max - input_shape = list(decomon_model.input_shape[2:]) - input_dim = np.prod(input_shape) - x_reshaped = x_min + 0 * x_min - x_reshaped = x_reshaped.reshape([-1] + input_shape) - x_min = x_min.reshape((-1, 1, input_dim)) - x_max = x_max.reshape((-1, 1, input_dim)) - - z = np.concatenate([x_min, x_max], 1) - - if batch_size > 1: - # split - r = 0 - if len(x_reshaped) % batch_size > 0: - r += 1 - x_min_list = [x_min[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] - x_max_list = [x_max[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] - - results = [ - get_upper_box_tuning( - decomon_model, decomon_model_concat, x_min_list[i], x_max_list[i], -1, lr=lr, epochs=epochs - ) - for i in range(len(x_min_list)) - ] - - return np.concatenate(results) - - else: - # freeze_weights - model.freeze_weights() - - # create loss - loss_upper = get_upper_loss(model) - decomon_model_concat.compile(Adam(lr=lr), loss_upper) - - decomon_model_concat.fit(z, baseline_upper, epochs=epochs, verbose=0) - - upper = get_upper_box(model, x_min, x_max, batch_size=batch_size, n_sub_boxes=n_sub_boxes) - - # reset alpha - model.reset_finetuning() - - return np.minimum(baseline_upper, upper) - - -#### FORMAL BOUNDS ###### -def get_lower_box_tuning( - model: Union[keras.Model, DecomonModel], - decomon_model_concat: keras.Model, - x_min: npt.NDArray[np.float_], - x_max: npt.NDArray[np.float_], - batch_size: int = 1, - n_sub_boxes: int = 1, - lr: float = 0.1, - epochs: int = 100, -) -> npt.NDArray[np.float_]: - """upper bound the maximum of a model in a given box - - Args: - model: either a Keras model or a Decomon model - x_min: numpy array for the extremal lower corner of the boxes - x_max: numpy array for the extremal upper corner of the boxes - batch_size: for computational efficiency, one can split the - calls to minibatches - fast: useful in the forward-backward or in the hybrid-backward - mode to optimize the scores - - Returns: - numpy array, vector with upper bounds for adversarial attacks - """ - - if np.min(x_max - x_min) < 0: - raise UserWarning("Inconsistency Error: x_max < x_min") - - # check that the model is a DecomonModel, else do the conversion - decomon_model = model - - baseline_upper = get_lower_box(model, x_min, x_max, batch_size=batch_size, n_sub_boxes=n_sub_boxes) - - if n_sub_boxes > 1: - x_min, x_max = refine_boxes(x_min, x_max, n_sub_boxes) - # reshape - shape = list(x_min.shape[2:]) - x_min = np.reshape(x_min, [-1] + shape) - x_max = np.reshape(x_max, [-1] + shape) - - # reshape x_mmin, x_max - input_shape = list(decomon_model.input_shape[2:]) - input_dim = np.prod(input_shape) - x_reshaped = x_min + 0 * x_min - x_reshaped = x_reshaped.reshape([-1] + input_shape) - x_min = x_min.reshape((-1, 1, input_dim)) - x_max = x_max.reshape((-1, 1, input_dim)) - - z = np.concatenate([x_min, x_max], 1) - - if batch_size > 0 and batch_size != len(x_min): - # split - r = 0 - if len(x_reshaped) % batch_size > 0: - r += 1 - x_min_list = [x_min[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] - x_max_list = [x_max[batch_size * i : batch_size * (i + 1)] for i in range(len(x_reshaped) // batch_size + r)] - - results = [ - get_lower_box_tuning( - decomon_model, decomon_model_concat, x_min_list[i], x_max_list[i], -1, lr=lr, epochs=epochs - ) - for i in range(len(x_min_list)) - ] - - model.reset_finetuning() - - return np.concatenate(results) - else: - # freeze_weights - model.freeze_weights() - - # create loss - loss_upper = get_upper_loss(model) - decomon_model_concat.compile(Adam(lr=lr), loss_upper) - - decomon_model_concat.fit(z, baseline_upper, epochs=epochs, verbose=0) - - upper = get_lower_box(model, x_min, x_max, batch_size=batch_size, n_sub_boxes=n_sub_boxes) - - # reset alpha - model.reset_finetuning() - - return np.minimum(baseline_upper, upper) diff --git a/tests/conftest.py b/tests/conftest.py index 84cb3a1b..99b82805 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,50 +1,77 @@ -from typing import List, Optional, Union +from typing import Optional, Union import keras import keras.config as keras_config import keras.ops as K import numpy as np -import numpy.typing as npt import pytest -from keras import KerasTensor -from keras.layers import ( - Activation, - Add, - Average, - Conv2D, - Dense, - Flatten, - Input, - Reshape, +from keras import KerasTensor, Model, Sequential +from keras.layers import Activation, Add, Conv2D, Dense, Flatten, Input +from pytest_cases import ( + fixture, + fixture_union, + param_fixture, + param_fixtures, + unpack_fixture, ) -from keras.models import Model, Sequential -from numpy.testing import assert_almost_equal -from pytest_cases import fixture, fixture_union, param_fixture -from decomon.core import ForwardMode, Slope +from decomon.constants import ConvertMethod, Propagation, Slope from decomon.keras_utils import ( BACKEND_JAX, BACKEND_NUMPY, BACKEND_PYTORCH, BACKEND_TENSORFLOW, + batch_multid_dot, ) -from decomon.models.utils import ConvertMethod -from decomon.types import Tensor - - -@pytest.fixture(params=[m.value for m in ForwardMode]) -def mode(request): - return request.param +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.perturbation_domain import BoxDomain +from decomon.types import BackendTensor, Tensor + +empty, diag, nobatch = param_fixtures( + "empty, diag, nobatch", + [ + (True, True, True), + (False, True, True), + (False, True, False), + (False, False, True), + (False, False, False), + ], + ids=["identity", "diagonal-nobatch", "diagonal", "nobatch", "generic"], +) +ibp, affine, propagation = param_fixtures( + "ibp, affine, propagation", + [ + (True, True, Propagation.FORWARD), + (False, True, Propagation.FORWARD), + (True, False, Propagation.FORWARD), + (True, True, Propagation.BACKWARD), + ], + ids=["forward-hybrid", "forward-affine", "forward-ibp", "backward"], +) +final_ibp, final_affine = param_fixtures( + "final_ibp, final_affine", [(True, False), (False, True), (True, True)], ids=["ibp", "affine", "hybrid"] +) +slope = param_fixture("slope", [s.value for s in Slope]) +n = param_fixture("n", list(range(10))) +odd = param_fixture("odd", list(range(2))) +use_bias = param_fixture("use_bias", [True, False]) +randomize = param_fixture("randomize", [True, False]) +padding = param_fixture("padding", ["same", "valid"]) +activation = param_fixture("activation", [None, "relu", "softsign"]) +data_format = param_fixture("data_format", ["channels_last", "channels_first"]) +method = param_fixture("method", [m.value for m in ConvertMethod]) +input_shape = param_fixture("input_shape", [(1,), (3,), (5, 6, 2)], ids=["0d", "1d", "multid"]) +equal_ibp = param_fixture("equal_ibp", [True, False]) -@pytest.fixture(params=[s.value for s in Slope]) -def slope(request): - return request.param +@pytest.fixture +def batchsize(): + return 10 -@pytest.fixture(params=list(range(10))) -def n(request): - return request.param +@pytest.fixture +def perturbation_domain(): + return BoxDomain() @pytest.fixture(params=[32, 64, 16]) @@ -71,96 +98,13 @@ def decimal(floatx): return 4 -@pytest.fixture(params=[True, False]) -def dc_decomp(request): - return request.param - - -@pytest.fixture(params=[True, False]) -def use_bias(request): - return request.param - - -@pytest.fixture(params=[True, False]) -def shared(request): - return request.param - - -@pytest.fixture(params=["same", "valid"]) -def padding(request): - return request.param - - -@pytest.fixture(params=[None, "linear", "relu"]) -def activation(request): - return request.param - - -data_format = param_fixture(argname="data_format", argvalues=["channels_last", "channels_first"]) - - -@pytest.fixture(params=[0, 1]) -def odd(request): - return request.param - - -@pytest.fixture(params=[1, 2, 3, -1]) -def axis(request): - return request.param - - -@pytest.fixture(params=[1, 2, 11]) -def channels(request): - return request.param - - -@pytest.fixture(params=[1, 2, 3]) -def filter_size(request): - return request.param - - -@pytest.fixture(params=[1, 2]) -def strides(request): - return request.param - - -@pytest.fixture(params=[True, False]) -def flatten(request): - return request.param - - -@pytest.fixture(params=[0, 1, 2, 3, 4, 5, 6]) -def finetune_odd(request) -> Optional[np.ndarray]: - # hard code several configuration of finetune for images odd=1 (6, 6, 2) - finetune_params = np.zeros((6, 6, 2)) - if request.param == 1: - finetune_params += 1 - elif request.param == 2: - finetune_params[0] = 1 - elif request.param == 3: - finetune_params[:, 0] = 1 - elif request.param == 4: - finetune_params[:, :, 0] = 1 - elif request.param == 5: - finetune_params[0, 0, 0] = 1 - else: - return None - - return finetune_params - - -@pytest.fixture(params=[m.value for m in ConvertMethod]) -def method(request): - return request.param - - class ModelNumpyFromKerasTensors: - def __init__(self, inputs: List[KerasTensor], outputs: List[KerasTensor]): + def __init__(self, inputs: list[KerasTensor], outputs: list[KerasTensor]): self.inputs = inputs self.outputs = outputs self._model = Model(inputs, outputs) - def __call__(self, inputs_: List[np.ndarray]): + def __call__(self, inputs_: list[np.ndarray]): output_tensors = self._model(inputs_) if isinstance(output_tensors, list): return [K.convert_to_numpy(output) for output in output_tensors] @@ -169,6 +113,8 @@ def __call__(self, inputs_: List[np.ndarray]): class Helpers: + function = ModelNumpyFromKerasTensors + @staticmethod def in_GPU_mode() -> bool: backend = keras.config.backend() @@ -190,92 +136,347 @@ def in_GPU_mode() -> bool: raise NotImplementedError(f"Not implemented for {backend} backend.") @staticmethod - def is_method_mode_compatible(method, mode): - return not ( - ConvertMethod(method) in {ConvertMethod.CROWN_FORWARD_IBP, ConvertMethod.FORWARD_IBP} - and ForwardMode(mode) != ForwardMode.IBP + def generate_random_tensor(shape_wo_batchsize, batchsize=10, dtype=keras_config.floatx(), nobatch=False): + if nobatch: + shape = shape_wo_batchsize + else: + shape = (batchsize,) + shape_wo_batchsize + return K.convert_to_tensor(2.0 * np.random.random(shape) - 1.0, dtype=dtype) + + @staticmethod + def get_decomon_input_shapes( + model_input_shape, + model_output_shape, + layer_input_shape, + layer_output_shape, + ibp, + affine, + propagation, + perturbation_domain, + empty=False, + diag=False, + nobatch=False, + for_linear_layer=False, + remove_perturbation_domain_inputs=False, + add_perturbation_domain_inputs=False, + ): + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + linear=for_linear_layer, ) + if ( + inputs_outputs_spec.needs_perturbation_domain_inputs() and not remove_perturbation_domain_inputs + ) or add_perturbation_domain_inputs: + perturbation_domain_inputs_shape = [perturbation_domain.get_x_input_shape_wo_batchsize(model_input_shape)] + else: + perturbation_domain_inputs_shape = [] - function = ModelNumpyFromKerasTensors + if inputs_outputs_spec.needs_affine_bounds_inputs() and not empty: + if propagation == Propagation.FORWARD: + b_in_shape = layer_input_shape + w_in_shape = model_input_shape + layer_input_shape + else: + b_in_shape = model_output_shape + w_in_shape = layer_output_shape + model_output_shape + if diag: + w_in_shape = b_in_shape + + affine_bounds_to_propagate_shape = [w_in_shape, b_in_shape, w_in_shape, b_in_shape] + else: + affine_bounds_to_propagate_shape = [] + + if inputs_outputs_spec.needs_constant_bounds_inputs(): + constant_oracle_bounds_shape = [layer_input_shape, layer_input_shape] + else: + constant_oracle_bounds_shape = [] + + return affine_bounds_to_propagate_shape, constant_oracle_bounds_shape, perturbation_domain_inputs_shape @staticmethod - def predict_on_small_numpy( - model: Model, x: Union[np.ndarray, List[np.ndarray]] - ) -> Union[np.ndarray, List[np.ndarray]]: - """Make predictions for model directly on small numpy arrays + def get_decomon_symbolic_inputs( + model_input_shape, + model_output_shape, + layer_input_shape, + layer_output_shape, + ibp, + affine, + propagation, + perturbation_domain, + empty=False, + diag=False, + nobatch=False, + for_linear_layer=False, + remove_perturbation_domain_inputs=False, + add_perturbation_domain_inputs=False, + dtype=keras_config.floatx(), + ): + """Generate decomon symbolic inputs for a decomon layer - Avoid using `model.predict()` known to be not designed for small arrays, - and leading to memory leaks when used in loops. + To be used as `decomon_layer(*decomon_inputs)`. - See https://keras.io/api/models/model_training_apis/#predict-method and - https://github.com/tensorflow/tensorflow/issues/44711 + Args: + model_input_shape: + model_output_shape: + layer_input_shape: + layer_output_shape: + ibp: + affine: + propagation: + perturbation_domain: + + Returns: + + """ + ( + affine_bounds_to_propagate_shape, + constant_oracle_bounds_shape, + perturbation_domain_inputs_shape, + ) = Helpers.get_decomon_input_shapes( + model_input_shape, + model_output_shape, + layer_input_shape, + layer_output_shape, + ibp, + affine, + propagation, + perturbation_domain, + empty=empty, + diag=diag, + nobatch=nobatch, + for_linear_layer=for_linear_layer, + remove_perturbation_domain_inputs=remove_perturbation_domain_inputs, + add_perturbation_domain_inputs=add_perturbation_domain_inputs, + ) + perturbation_domain_inputs = [Input(shape, dtype=dtype) for shape in perturbation_domain_inputs_shape] + constant_oracle_bounds = [Input(shape, dtype=dtype) for shape in constant_oracle_bounds_shape] + if nobatch: + affine_bounds_to_propagate = [ + Input(batch_shape=shape, dtype=dtype) for shape in affine_bounds_to_propagate_shape + ] + else: + affine_bounds_to_propagate = [Input(shape=shape, dtype=dtype) for shape in affine_bounds_to_propagate_shape] + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + ) + return inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + + @staticmethod + def generate_simple_decomon_layer_inputs_from_keras_input( + keras_input, + layer_output_shape, + ibp, + affine, + propagation, + perturbation_domain, + empty=False, + diag=False, + nobatch=False, + for_linear_layer=False, + dtype=keras_config.floatx(), + equal_ibp=True, + remove_perturbation_domain_inputs=False, + add_perturbation_domain_inputs=False, + ): + """Generate simple decomon inputs for a layer from the corresponding keras input + + Hypothesis: single-layer model => model input/output = layer input/output + + For affine bounds, weights= identity + bias = 0 + For constant bounds, (keras_input, keras_input) + + To be used as `decomon_layer(*decomon_inputs)`. Args: - model: - x: + keras_input: + layer_output_shape: + ibp: + affine: + propagation: + perturbation_domain: + empty: + diag: + nobatch: Returns: """ - output_tensors = model(x) - if isinstance(output_tensors, list): - return [K.convert_to_numpy(output) for output in output_tensors] + layer_input_shape = tuple(keras_input.shape[1:]) + model_input_shape = layer_input_shape + model_output_shape = layer_output_shape + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + linear=for_linear_layer, + ) + + if ( + inputs_outputs_spec.needs_perturbation_domain_inputs() and not remove_perturbation_domain_inputs + ) or add_perturbation_domain_inputs: + x = Helpers.generate_simple_perturbation_domain_inputs_from_keras_input( + keras_input=keras_input, perturbation_domain=perturbation_domain + ) + perturbation_domain_inputs = [x] else: - return K.convert_to_numpy(output_tensors) + perturbation_domain_inputs = [] + + if inputs_outputs_spec.needs_affine_bounds_inputs() and not empty: + batchsize = keras_input.shape[0] + if propagation == Propagation.FORWARD: + bias_shape = layer_input_shape + else: + bias_shape = layer_output_shape + flatten_bias_dim = int(np.prod(bias_shape)) + if diag: + w_in = K.ones(bias_shape, dtype=dtype) + else: + w_in = K.reshape(K.eye(flatten_bias_dim, dtype=dtype), bias_shape + bias_shape) + b_in = K.zeros(bias_shape, dtype=dtype) + if not nobatch: + w_in = K.repeat( + w_in[None], + batchsize, + axis=0, + ) + b_in = K.repeat( + b_in[None], + batchsize, + axis=0, + ) + affine_bounds_to_propagate = [w_in, b_in, w_in, b_in] + else: + affine_bounds_to_propagate = [] + + if inputs_outputs_spec.needs_constant_bounds_inputs(): + if equal_ibp: + constant_oracle_bounds = [keras_input, keras_input] + else: + batchsize = keras_input.shape[0] + lower = K.repeat(K.min(keras_input, axis=0, keepdims=True), batchsize, axis=0) + upper = K.repeat(K.max(keras_input, axis=0, keepdims=True), batchsize, axis=0) + constant_oracle_bounds = [lower, upper] + else: + constant_oracle_bounds = [] + + return inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + + @staticmethod + def generate_simple_perturbation_domain_inputs_from_keras_input( + keras_input, perturbation_domain, equal_bounds=True + ): + if isinstance(perturbation_domain, BoxDomain): + if equal_bounds: + return K.concatenate([keras_input[:, None], keras_input[:, None]], axis=1) + else: + batchsize = keras_input.shape[0] + lower = K.repeat(K.min(keras_input, axis=0, keepdims=True), batchsize, axis=0) + upper = K.repeat(K.max(keras_input, axis=0, keepdims=True), batchsize, axis=0) + return K.concatenate([lower[:, None], upper[:, None]], axis=1) + else: + raise NotImplementedError + + @staticmethod + def generate_merging_decomon_input_from_single_decomon_inputs( + decomon_inputs: list[list[Tensor]], ibp: bool, affine: bool, propagation: Propagation, linear: bool + ) -> list[Tensor]: + inputs_outputs_spec_single = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=tuple(), + model_input_shape=tuple(), + model_output_shape=tuple(), + linear=linear, + ) + affine_bounds_to_propagate, constant_oracle_bounds, perturbation_domain_inputs = [], [], [] + for decomon_input in decomon_inputs: + ( + affine_bounds_to_propagate_i, + constant_oracle_bounds_i, + perturbation_domain_inputs_i, + ) = inputs_outputs_spec_single.split_inputs(decomon_input) + perturbation_domain_inputs = perturbation_domain_inputs_i + if propagation == Propagation.FORWARD: + affine_bounds_to_propagate.append(affine_bounds_to_propagate_i) + else: + affine_bounds_to_propagate = affine_bounds_to_propagate_i + constant_oracle_bounds.append(constant_oracle_bounds_i) + + inputs_outputs_spec_merging = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=[tuple()], + model_input_shape=tuple(), + model_output_shape=tuple(), + is_merging_layer=True, + linear=linear, + ) + return inputs_outputs_spec_merging.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) @staticmethod - def get_standard_values_1d_box(n, dc_decomp=True, grad_bounds=False, nb=100): + def get_standard_values_0d_box(n, batchsize=10): """A set of functions with their monotonic decomposition for testing the activations""" - w_u_ = np.ones(nb, dtype=keras_config.floatx()) - b_u_ = np.zeros(nb, dtype=keras_config.floatx()) - w_l_ = np.ones(nb, dtype=keras_config.floatx()) - b_l_ = np.zeros(nb, dtype=keras_config.floatx()) + w_u_ = np.ones(batchsize) + b_u_ = np.zeros(batchsize) + w_l_ = np.ones(batchsize) + b_l_ = np.zeros(batchsize) if n == 0: # identity - y_ = np.linspace(-2, -1, nb) - x_ = np.linspace(-2, -1, nb) - h_ = np.linspace(-2, -1, nb) - g_ = np.zeros_like(x_) + y_ = np.linspace(-2, -1, batchsize) + x_ = np.linspace(-2, -1, batchsize) elif n == 1: - y_ = np.linspace(1, 2, nb) - x_ = np.linspace(1, 2, nb) - h_ = np.linspace(1, 2, nb) - g_ = np.zeros_like(x_) + y_ = np.linspace(1, 2, batchsize) + x_ = np.linspace(1, 2, batchsize) elif n == 2: - y_ = np.linspace(-1, 1, nb) - x_ = np.linspace(-1, 1, nb) - h_ = np.linspace(-1, 1, nb) - g_ = np.zeros_like(x_) + y_ = np.linspace(-1, 1, batchsize) + x_ = np.linspace(-1, 1, batchsize) elif n == 3: # identity - y_ = np.linspace(-2, -1, nb) - x_ = np.linspace(-2, -1, nb) - h_ = 2 * np.linspace(-2, -1, nb) - g_ = -np.linspace(-2, -1, nb) + y_ = np.linspace(-2, -1, batchsize) + x_ = np.linspace(-2, -1, batchsize) elif n == 4: - y_ = np.linspace(1, 2, nb) - x_ = np.linspace(1, 2, nb) - h_ = 2 * np.linspace(1, 2, nb) - g_ = -np.linspace(1, 2, nb) + y_ = np.linspace(1, 2, batchsize) + x_ = np.linspace(1, 2, batchsize) elif n == 5: - y_ = np.linspace(-1, 1, nb) - x_ = np.linspace(-1, 1, nb) - h_ = 2 * np.linspace(-1, 1, nb) - g_ = -np.linspace(-1, 1, nb) + y_ = np.linspace(-1, 1, batchsize) + x_ = np.linspace(-1, 1, batchsize) elif n == 6: - assert nb == 100, "expected nb=100 samples" # cosine function - x_ = np.linspace(-np.pi, np.pi, 100) + x_ = np.linspace(-np.pi, np.pi, batchsize) y_ = np.cos(x_) - h_ = np.concatenate([y_[:50], np.ones((50,))]) - 0.5 - g_ = np.concatenate([np.ones((50,)), y_[50:]]) - 0.5 w_u_ = np.zeros_like(x_) w_l_ = np.zeros_like(x_) b_u_ = np.ones_like(x_) @@ -283,24 +484,24 @@ def get_standard_values_1d_box(n, dc_decomp=True, grad_bounds=False, nb=100): elif n == 7: # h and g >0 - h_ = np.linspace(0.5, 2, nb) - g_ = np.linspace(1, 2, nb)[::-1] + h_ = np.linspace(0.5, 2, batchsize) + g_ = np.linspace(1, 2, batchsize)[::-1] x_ = h_ + g_ y_ = h_ + g_ elif n == 8: # h <0 and g <0 # h_max+g_max <=0 - h_ = np.linspace(-2, -1, nb) - g_ = np.linspace(-2, -1, nb)[::-1] + h_ = np.linspace(-2, -1, batchsize) + g_ = np.linspace(-2, -1, batchsize)[::-1] y_ = h_ + g_ x_ = h_ + g_ elif n == 9: # h >0 and g <0 # h_min+g_min >=0 - h_ = np.linspace(4, 5, nb) - g_ = np.linspace(-2, -1, nb)[::-1] + h_ = np.linspace(4, 5, batchsize) + g_ = np.linspace(-2, -1, batchsize)[::-1] y_ = h_ + g_ x_ = h_ + g_ @@ -312,207 +513,26 @@ def get_standard_values_1d_box(n, dc_decomp=True, grad_bounds=False, nb=100): x_0_ = np.concatenate([x_min_[:, None], x_max_[:, None]], 1) - u_c_ = np.max(y_) * np.ones((nb,)) - l_c_ = np.min(y_) * np.ones((nb,)) - - if dc_decomp: - output = [ - x_[:, None], - y_[:, None], - x_0_[:, :, None], - u_c_[:, None], - w_u_[:, None, None], - b_u_[:, None], - l_c_[:, None], - w_l_[:, None, None], - b_l_[:, None], - h_[:, None], - g_[:, None], - ] - else: - output = [ - x_[:, None], - y_[:, None], - x_0_[:, :, None], - u_c_[:, None], - w_u_[:, None, None], - b_u_[:, None], - l_c_[:, None], - w_l_[:, None, None], - b_l_[:, None], - ] + u_c_ = np.max(y_) * np.ones((batchsize,)) + l_c_ = np.min(y_) * np.ones((batchsize,)) + + output = [ + x_[:, None], + y_[:, None], + x_0_[:, :, None], + u_c_[:, None], + w_u_[:, None, None], + b_u_[:, None], + l_c_[:, None], + w_l_[:, None, None], + b_l_[:, None], + ] # cast element return [e.astype(keras_config.floatx()) for e in output] @staticmethod - def get_inputs_for_mode_from_full_inputs( - inputs: Union[List[Tensor], List[npt.NDArray[np.float_]]], - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = True, - ) -> Union[List[Tensor], List[npt.NDArray[np.float_]]]: - """Extract from full inputs the ones corresponding to the selected mode. - - Args: - inputs: inputs from `get_standard_values_xxx()` or `get_tensor_decomposition_xxx()` - mode: - dc_decomp: - - Returns: - - """ - mode = ForwardMode(mode) - if dc_decomp: - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l, h, g = inputs - if mode == ForwardMode.HYBRID: - return inputs[2:] - elif mode == ForwardMode.AFFINE: - return [z, W_u, b_u, W_l, b_l, h, g] - elif mode == ForwardMode.IBP: - return [u_c, l_c, h, g] - else: - raise ValueError("Unknown mode.") - else: - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs - if mode == ForwardMode.HYBRID: - return inputs[2:] - elif mode == ForwardMode.AFFINE: - return [z, W_u, b_u, W_l, b_l] - elif mode == ForwardMode.IBP: - return [u_c, l_c] - else: - raise ValueError("Unknown mode.") - - @staticmethod - def get_inputs_np_for_decomon_model_from_full_inputs( - inputs: List[npt.NDArray[np.float_]], - ) -> npt.NDArray[np.float_]: - """Extract from full numpy inputs the ones for a decomon model prediction. - - Args: - inputs: inputs from `get_standard_values_xxx()` - - Returns: - - """ - l_c_, u_c_ = Helpers.get_input_ref_bounds_from_full_inputs(inputs=inputs) - return np.concatenate((l_c_[:, None], u_c_[:, None]), axis=1) - - @staticmethod - def get_input_ref_bounds_from_full_inputs( - inputs: Union[List[Tensor], List[npt.NDArray[np.float_]]], - ) -> Union[List[Tensor], List[npt.NDArray[np.float_]]]: - """Extract lower and upper bound for input ref from full inputs - - Args: - inputs: inputs from `get_standard_values_xxx()` or `get_tensor_decomposition_xxx()` - - Returns: - - """ - u_c_, l_c_ = inputs[3], inputs[6] - return [l_c_, u_c_] - - @staticmethod - def prepare_full_np_inputs_for_convert_model( - inputs: List[npt.NDArray[np.float_]], - dc_decomp: bool = True, - ) -> List[npt.NDArray[np.float_]]: - """Prepare full numpy inputs for convert_forward or convert_backward. - - W_u and W_l will be idendity matrices, and b_u, b_l zeros vectors. - - Args: - inputs: inputs from `get_standard_values_xxx()` or `get_tensor_decomposition_xxx()` - - Returns: - - """ - if dc_decomp: - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l, h, g = inputs - else: - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs - - b_u = np.zeros_like(b_u) - b_l = np.zeros_like(b_l) - W_u = np.repeat(np.identity(n=W_u.shape[-1])[None, :, :], repeats=W_u.shape[0], axis=0) - W_l = np.repeat(np.identity(n=W_l.shape[-1])[None, :, :], repeats=W_l.shape[0], axis=0) - - if dc_decomp: - return [x, y, z, u_c, W_u, b_u, l_c, W_l, b_l, h, g] - else: - return [x, y, z, u_c, W_u, b_u, l_c, W_l, b_l] - - @staticmethod - def get_input_tensors_for_decomon_convert_from_full_inputs( - inputs: List[Tensor], - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = True, - ) -> List[Tensor]: - """Extract from full tensor inputs the ones for a conversion to decomon model. - - Args: - inputs: inputs from `get_tensor_decomposition_xxx()` - mode: - dc_decomp: - - Returns: - - """ - mode = ForwardMode(mode) - if dc_decomp: - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l, h, g = inputs - input_box_tensor = K.concatenate([K.expand_dims(l_c, 1), K.expand_dims(u_c, 1)], 1) - if mode == ForwardMode.HYBRID: - return [input_box_tensor, u_c, W_u, b_u, l_c, W_l, b_l, h, g] - elif mode == ForwardMode.AFFINE: - return [input_box_tensor, W_u, b_u, W_l, b_l, h, g] - elif mode == ForwardMode.IBP: - return [u_c, l_c, h, g] - else: - raise ValueError("Unknown mode.") - else: - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs - input_box_tensor = K.concatenate([K.expand_dims(l_c, 1), K.expand_dims(u_c, 1)], 1) - if mode == ForwardMode.HYBRID: - return [input_box_tensor, u_c, W_u, b_u, l_c, W_l, b_l] - elif mode == ForwardMode.AFFINE: - return [input_box_tensor, W_u, b_u, W_l, b_l] - elif mode == ForwardMode.IBP: - return [u_c, l_c] - else: - raise ValueError("Unknown mode.") - - @staticmethod - def get_input_ref_from_full_inputs( - inputs: Union[List[Tensor], List[npt.NDArray[np.float_]]] - ) -> Union[Tensor, npt.NDArray[np.float_]]: - """Extract from full inputs the input of reference for the original Keras layer. - - Args: - inputs: inputs from `get_standard_values_xxx()` or `get_tensor_decomposition_xxx()` - - Returns: - - """ - return inputs[1] - - @staticmethod - def get_tensor_decomposition_1d_box(dc_decomp=True): - if dc_decomp: - return [ - Input((1,), dtype=keras_config.floatx()), - Input((1,), dtype=keras_config.floatx()), - Input((2, 1), dtype=keras_config.floatx()), - Input((1,), dtype=keras_config.floatx()), - Input((1, 1), dtype=keras_config.floatx()), - Input((1,), dtype=keras_config.floatx()), - Input((1,), dtype=keras_config.floatx()), - Input((1, 1), dtype=keras_config.floatx()), - Input((1,), dtype=keras_config.floatx()), - Input((1,), dtype=keras_config.floatx()), - Input((1,), dtype=keras_config.floatx()), - ] + def get_tensor_decomposition_0d_box(): return [ Input((1,), dtype=keras_config.floatx()), Input((1,), dtype=keras_config.floatx()), @@ -526,154 +546,32 @@ def get_tensor_decomposition_1d_box(dc_decomp=True): ] @staticmethod - def get_full_outputs_from_outputs_for_mode( - outputs_for_mode: Union[List[Tensor], List[npt.NDArray[np.float_]]], - mode: Union[str, ForwardMode] = ForwardMode.HYBRID, - dc_decomp: bool = True, - full_inputs: Optional[Union[List[Tensor], List[npt.NDArray[np.float_]]]] = None, - ) -> Union[List[Tensor], List[npt.NDArray[np.float_]]]: - mode = ForwardMode(mode) - if dc_decomp: - if mode == ForwardMode.HYBRID: - z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, h_, g_ = outputs_for_mode - elif mode == ForwardMode.AFFINE: - z_, w_u_, b_u_, w_l_, b_l_, h_, g_ = outputs_for_mode - u_c_, l_c_ = None, None - elif mode == ForwardMode.IBP: - u_c_, l_c_, h_, g_ = outputs_for_mode - z_, w_u_, b_u_, w_l_, b_l_ = None, None, None, None, None - if full_inputs is not None: - z_ = full_inputs[2] - else: - raise ValueError("Unknown mode.") - else: - if mode == ForwardMode.HYBRID: - z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_ = outputs_for_mode - elif mode == ForwardMode.AFFINE: - z_, w_u_, b_u_, w_l_, b_l_ = outputs_for_mode - u_c_, l_c_ = None, None - elif mode == ForwardMode.IBP: - u_c_, l_c_ = outputs_for_mode - z_, w_u_, b_u_, w_l_, b_l_ = None, None, None, None, None - if full_inputs is not None: - z_ = full_inputs[2] - else: - raise ValueError("Unknown mode.") - h_, g_ = None, None - return [z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, h_, g_] - - @staticmethod - def get_input_dim_multid_box(odd): - if odd: - return 3 - else: - return 2 - - @staticmethod - def get_input_dim_images_box(odd): - if odd: - return 7 - else: - return 6 - - @staticmethod - def get_input_dim_from_full_inputs(inputs: Union[List[Tensor], List[npt.NDArray[np.float_]]]) -> int: - """Get input_dim for to_decomon or to_backward from full inputs - - Args: - inputs: inputs from `get_standard_values_xxx()` or `get_tensor_decomposition_xxx()` - - Returns: - - """ - return inputs[0].shape[-1] - - @staticmethod - def get_tensor_decomposition_multid_box(odd=1, dc_decomp=True): - n = Helpers.get_input_dim_multid_box(odd) - - if dc_decomp: - # x, y, z, u, w_u, b_u, l, w_l, b_l, h, g - return [ - Input((n,), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((2, n), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n, n), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n, n), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - ] - return [ - Input((n,), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((2, n), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n, n), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - Input((n, n), dtype=keras_config.floatx()), - Input((n,), dtype=keras_config.floatx()), - ] - - @staticmethod - def get_standard_values_multid_box(odd=1, dc_decomp=True): - if dc_decomp: - ( - x_0, - y_0, - z_0, - u_c_0, - w_u_0, - b_u_0, - l_c_0, - w_l_0, - b_l_0, - h_0, - g_0, - ) = Helpers.get_standard_values_1d_box(0, dc_decomp) - ( - x_1, - y_1, - z_1, - u_c_1, - w_u_1, - b_u_1, - l_c_1, - w_l_1, - b_l_1, - h_1, - g_1, - ) = Helpers.get_standard_values_1d_box(1, dc_decomp) - else: - ( - x_0, - y_0, - z_0, - u_c_0, - w_u_0, - b_u_0, - l_c_0, - w_l_0, - b_l_0, - ) = Helpers.get_standard_values_1d_box(0, dc_decomp) - ( - x_1, - y_1, - z_1, - u_c_1, - w_u_1, - b_u_1, - l_c_1, - w_l_1, - b_l_1, - ) = Helpers.get_standard_values_1d_box(1, dc_decomp) + def get_standard_values_1d_box(odd=1, batchsize=10): + ( + x_0, + y_0, + z_0, + u_c_0, + w_u_0, + b_u_0, + l_c_0, + w_l_0, + b_l_0, + ) = Helpers.get_standard_values_0d_box(n=0, batchsize=batchsize) + ( + x_1, + y_1, + z_1, + u_c_1, + w_u_1, + b_u_1, + l_c_1, + w_l_1, + b_l_1, + ) = Helpers.get_standard_values_0d_box(n=1, batchsize=batchsize) if not odd: - # output (x_0+x_1, x_0+2*x_0) + # output (x_0+x_1, x_0+2*x_1) x_ = np.concatenate([x_0, x_1], -1) z_min_ = np.concatenate([z_0[:, 0], z_1[:, 0]], -1) z_max_ = np.concatenate([z_0[:, 1], z_1[:, 1]], -1) @@ -684,10 +582,6 @@ def get_standard_values_multid_box(odd=1, dc_decomp=True): b_l_ = np.concatenate([b_l_0 + b_l_1, b_l_0 + 2 * b_l_1], -1) l_c_ = np.concatenate([l_c_0 + l_c_1, l_c_0 + 2 * l_c_1], -1) - if dc_decomp: - h_ = np.concatenate([h_0 + h_1, h_0 + 2 * h_1], -1) - g_ = np.concatenate([g_0 + g_1, g_0 + 2 * g_1], -1) - w_u_ = np.zeros((len(x_), 2, 2)) w_u_[:, 0, 0] = w_u_0[:, 0, 0] w_u_[:, 1, 0] = w_u_1[:, 0, 0] @@ -711,9 +605,7 @@ def get_standard_values_multid_box(odd=1, dc_decomp=True): l_c_2, w_l_2, b_l_2, - h_2, - g_2, - ) = Helpers.get_standard_values_1d_box(2) + ) = Helpers.get_standard_values_0d_box(n=2, batchsize=batchsize) # output (x_0+x_1, x_0+2*x_0, x_2) x_ = np.concatenate([x_0, x_1, x_2], -1) @@ -726,10 +618,6 @@ def get_standard_values_multid_box(odd=1, dc_decomp=True): u_c_ = np.concatenate([u_c_0 + u_c_1, u_c_0 + 2 * u_c_1, u_c_2], -1) l_c_ = np.concatenate([l_c_0 + l_c_1, l_c_0 + 2 * l_c_1, l_c_2], -1) - if dc_decomp: - h_ = np.concatenate([h_0 + h_1, h_0 + 2 * h_1, h_2], -1) - g_ = np.concatenate([g_0 + g_1, g_0 + 2 * g_1, g_2], -1) - w_u_ = np.zeros((len(x_), 3, 3)) w_u_[:, 0, 0] = w_u_0[:, 0, 0] w_u_[:, 1, 0] = w_u_1[:, 0, 0] @@ -744,148 +632,36 @@ def get_standard_values_multid_box(odd=1, dc_decomp=True): w_l_[:, 1, 1] = 2 * w_l_1[:, 0, 0] w_l_[:, 2, 2] = w_l_2[:, 0, 0] - if dc_decomp: - return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, h_, g_] return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_] @staticmethod - def build_image_from_1D_box(odd=0, m=0, dc_decomp=True): - n = Helpers.get_input_dim_images_box(odd) - - if dc_decomp: - ( - x_, - y_0, - z_, - u_c_0, - w_u_0, - b_u_0, - l_c_0, - w_l_0, - b_l_0, - h_0, - g_0, - ) = Helpers.get_standard_values_1d_box(m, dc_decomp=dc_decomp) - else: - ( - x_, - y_0, - z_, - u_c_0, - w_u_0, - b_u_0, - l_c_0, - w_l_0, - b_l_0, - ) = Helpers.get_standard_values_1d_box(m, dc_decomp=dc_decomp) - - y_ = np.concatenate([(i + 1) * y_0 for i in range(n * n)], -1).reshape((-1, n, n)) - b_u_ = np.concatenate([(i + 1) * b_u_0 for i in range(n * n)], -1).reshape((-1, n, n)) - b_l_ = np.concatenate([(i + 1) * b_l_0 for i in range(n * n)], -1).reshape((-1, n, n)) - - if dc_decomp: - h_ = np.concatenate([(i + 1) * h_0 for i in range(n * n)], -1).reshape((-1, n, n)) - g_ = np.concatenate([(i + 1) * g_0 for i in range(n * n)], -1).reshape((-1, n, n)) + def get_tensor_decomposition_1d_box(odd=1): + n = Helpers.get_input_dim_1d_box(odd) - u_c_ = np.concatenate([(i + 1) * u_c_0 for i in range(n * n)], -1).reshape((-1, n, n)) - l_c_ = np.concatenate([(i + 1) * l_c_0 for i in range(n * n)], -1).reshape((-1, n, n)) - - w_u_ = np.zeros((len(x_), 1, n * n)) - w_l_ = np.zeros((len(x_), 1, n * n)) - - for i in range(n * n): - w_u_[:, 0, i] = (i + 1) * w_u_0[:, 0, 0] - w_l_[:, 0, i] = (i + 1) * w_l_0[:, 0, 0] - - w_u_ = w_u_.reshape((-1, 1, n, n)) - w_l_ = w_l_.reshape((-1, 1, n, n)) - - if dc_decomp: - return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, h_, g_] - return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_] + return [ + Input((n,), dtype=keras_config.floatx()), + Input((n,), dtype=keras_config.floatx()), + Input((2, n), dtype=keras_config.floatx()), + Input((n,), dtype=keras_config.floatx()), + Input((n, n), dtype=keras_config.floatx()), + Input((n,), dtype=keras_config.floatx()), + Input((n,), dtype=keras_config.floatx()), + Input((n, n), dtype=keras_config.floatx()), + Input((n,), dtype=keras_config.floatx()), + ] @staticmethod - def build_image_from_2D_box(odd=0, m0=0, m1=1, dc_decomp=True): - if dc_decomp: - ( - x_0, - y_0, - z_0, - u_c_0, - w_u_0, - b_u_0, - l_c_0, - w_l_0, - b_l_0, - h_0, - g_0, - ) = Helpers.build_image_from_1D_box(odd, m0, dc_decomp) - ( - x_1, - y_1, - z_1, - u_c_1, - w_u_1, - b_u_1, - l_c_1, - w_l_1, - b_l_1, - h_1, - g_1, - ) = Helpers.build_image_from_1D_box(odd, m1, dc_decomp) + def get_input_dim_1d_box(odd): + if odd: + return 3 else: - ( - x_0, - y_0, - z_0, - u_c_0, - w_u_0, - b_u_0, - l_c_0, - w_l_0, - b_l_0, - ) = Helpers.build_image_from_1D_box(odd, m0, dc_decomp) - ( - x_1, - y_1, - z_1, - u_c_1, - w_u_1, - b_u_1, - l_c_1, - w_l_1, - b_l_1, - ) = Helpers.build_image_from_1D_box(odd, m1, dc_decomp) - - x_ = np.concatenate([x_0, x_1], -1) - z_min_ = np.concatenate([z_0[:, 0], z_1[:, 0]], -1) - z_max_ = np.concatenate([z_0[:, 1], z_1[:, 1]], -1) - z_ = np.concatenate([z_min_[:, None], z_max_[:, None]], 1) - y_ = y_0 + y_1 - b_u_ = b_u_0 + b_u_1 - b_l_ = b_l_0 + b_l_1 - - u_c_ = u_c_0 + u_c_1 - l_c_ = l_c_0 + l_c_1 - - w_u_ = np.concatenate([w_u_0, w_u_1], 1) - w_l_ = np.concatenate([w_l_0, w_l_1], 1) - - if dc_decomp: - h_ = h_0 + h_1 - g_ = g_0 + g_1 - - return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, h_, g_] - return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_] + return 2 @staticmethod - def get_standard_values_images_box(data_format="channels_last", odd=0, m0=0, m1=1, dc_decomp=True): + def get_standard_values_images_box(data_format="channels_last", odd=0, m0=0, m1=1, batchsize=10): if data_format == "channels_last": - output = Helpers.build_image_from_2D_box(odd, m0, m1, dc_decomp) - if dc_decomp: - x_0, y_0, z_0, u_c_0, w_u_0, b_u_0, l_c_0, w_l_0, b_l_0, h_0, g_0 = output - else: - x_0, y_0, z_0, u_c_0, w_u_0, b_u_0, l_c_0, w_l_0, b_l_0 = output + output = Helpers.build_image_from_2D_box(odd=odd, m0=m0, m1=m1, batchsize=batchsize) + x_0, y_0, z_0, u_c_0, w_u_0, b_u_0, l_c_0, w_l_0, b_l_0 = output x_ = x_0 z_ = z_0 @@ -905,22 +681,12 @@ def get_standard_values_images_box(data_format="channels_last", odd=0, m0=0, m1= w_u_ = np.concatenate([w_u_0, w_u_0], -1) w_l_ = np.concatenate([w_l_0, w_l_0], -1) - if dc_decomp: - h_0 = h_0[:, :, :, None] - g_0 = g_0[:, :, :, None] - h_ = np.concatenate([h_0, h_0], -1) - g_ = np.concatenate([g_0, g_0], -1) - else: output = Helpers.get_standard_values_images_box( - data_format="channels_last", odd=odd, m0=m0, m1=m1, dc_decomp=dc_decomp + data_format="channels_last", odd=odd, m0=m0, m1=m1, batchsize=batchsize ) - x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_ = output[:9] - if dc_decomp: - h_, g_ = output[-2:] - h_ = np.transpose(h_, (0, 3, 1, 2)) - g_ = np.transpose(g_, (0, 3, 1, 2)) + x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_ = output y_ = np.transpose(y_, (0, 3, 1, 2)) u_c_ = np.transpose(u_c_, (0, 3, 1, 2)) l_c_ = np.transpose(l_c_, (0, 3, 1, 2)) @@ -929,13 +695,10 @@ def get_standard_values_images_box(data_format="channels_last", odd=0, m0=0, m1= w_u_ = np.transpose(w_u_, (0, 1, 4, 2, 3)) w_l_ = np.transpose(w_l_, (0, 1, 4, 2, 3)) - if dc_decomp: - return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, h_, g_] - else: - return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_] + return [x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_] @staticmethod - def get_tensor_decomposition_images_box(data_format, odd, dc_decomp=True): + def get_tensor_decomposition_images_box(data_format, odd): n = Helpers.get_input_dim_images_box(odd) if data_format == "channels_last": @@ -953,8 +716,6 @@ def get_tensor_decomposition_images_box(data_format, odd, dc_decomp=True): Input((n, n, 2), dtype=keras_config.floatx()), ] - if dc_decomp: - output += [Input((n, n, 2), dtype=keras_config.floatx()), Input((n, n, 2), dtype=keras_config.floatx())] else: output = [ Input((2,), dtype=keras_config.floatx()), @@ -967,475 +728,454 @@ def get_tensor_decomposition_images_box(data_format, odd, dc_decomp=True): Input((2, 2, n, n), dtype=keras_config.floatx()), Input((2, n, n), dtype=keras_config.floatx()), ] - if dc_decomp: - output += [Input((2, n, n), dtype=keras_config.floatx()), Input((2, n, n), dtype=keras_config.floatx())] return output @staticmethod - def assert_output_properties_box(x_, y_, h_, g_, x_min_, x_max_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, decimal=4): - if y_ is None: - y_ = h_ + g_ - if h_ is not None: - assert_almost_equal(h_ + g_, y_, decimal=decimal, err_msg="decomposition error") - - assert np.min(x_min_ <= x_max_), "x_min >x_max" - - assert_almost_equal(np.clip(x_min_ - x_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_min >x_") - assert_almost_equal(np.clip(x_ - x_max_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_max < x_") - if w_u_ is not None or w_l_ is not None: - x_expand = x_ + np.zeros_like(x_) - n_expand = len(w_u_.shape) - len(x_expand.shape) - for i in range(n_expand): - x_expand = np.expand_dims(x_expand, -1) - - if w_l_ is not None: - lower_ = np.sum(w_l_ * x_expand, 1) + b_l_ - if w_u_ is not None: - upper_ = np.sum(w_u_ * x_expand, 1) + b_u_ - - # check that the functions h_ and g_ remains monotonic - if h_ is not None: - assert_almost_equal( - np.clip(h_[:-1] - h_[1:], 0, np.inf), - np.zeros_like(h_[1:]), - decimal=decimal, - err_msg="h is not increasing", - ) - assert_almost_equal( - np.clip(g_[1:] - g_[:-1], 0, np.inf), - np.zeros_like(g_[1:]), - decimal=decimal, - err_msg="g is not increasing", - ) + def get_input_dim_images_box(odd): + if odd: + return 7 + else: + return 6 - # - if w_u_ is not None: - if keras_config.floatx() == "float32": - assert_almost_equal( - np.clip(y_ - upper_, 0.0, 1e6), - np.zeros_like(y_), - decimal=decimal, - err_msg="upper x_max" - - assert_almost_equal(np.clip(x_min_ - x_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_min >x_") - assert_almost_equal(np.clip(x_ - x_max_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_max < x_") - if w_u_ is not None: - x_expand = x_ + np.zeros_like(x_) - n_expand = len(w_u_.shape) - len(x_expand.shape) - for i in range(n_expand): - x_expand = np.expand_dims(x_expand, -1) - - lower_ = np.sum(w_l_ * x_expand, 1) + b_l_ - upper_ = np.sum(w_u_ * x_expand, 1) + b_u_ - - if y_ is not None: - if l_c_ is not None: - assert_almost_equal( - np.clip(l_c_ - y_, 0.0, np.inf), np.zeros_like(y_), decimal=decimal, err_msg="l_c >y" - ) - assert_almost_equal(np.clip(y_ - u_c_, 0.0, 1e6), np.zeros_like(y_), decimal=decimal, err_msg="u_c x_max" + def replace_none_by_batchsize(shapes: list[tuple[Optional[int], ...]], batchsize: int) -> list[tuple[int]]: + return [tuple(dim if dim is not None else batchsize for dim in shape) for shape in shapes] - assert_almost_equal(np.clip(x_min_ - x_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_min >x_") - assert_almost_equal(np.clip(x_ - x_max_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_max < x_") - - x_expand = x_ + np.zeros_like(x_) - n_expand = len(w_u_.shape) - len(x_expand.shape) - for i in range(n_expand): - x_expand = np.expand_dims(x_expand, -1) - - lower_ = np.sum(w_l_ * x_expand, 1) + b_l_ - upper_ = np.sum(w_u_ * x_expand, 1) + b_u_ - - # check that the functions h_ and g_ remains monotonic + @staticmethod + def predict_on_small_numpy( + model: Model, x: Union[np.ndarray, list[np.ndarray]] + ) -> Union[np.ndarray, list[np.ndarray]]: + """Make predictions for model directly on small numpy arrays - assert_almost_equal( - np.clip(l_c_ - y_, 0.0, np.inf), - np.zeros_like(y_), - decimal=decimal, - err_msg="l_c >y", - ) - assert_almost_equal( - np.clip(y_ - u_c_, 0.0, 1e6), - np.zeros_like(y_), - decimal=decimal, - err_msg="u_c Model: + if dtype is None: + dtype = keras_config.floatx() + layers = [] + layers.append(Input(input_shape, dtype=dtype)) + layers.append(Dense(10, dtype=dtype)) + if activation is not None: + layers.append(Activation(activation, dtype=dtype)) + layers.append(Dense(10, dtype=dtype)) + layers.append(Dense(1, activation="linear", dtype=dtype)) + model = Sequential(layers) + return model - assert_almost_equal( - np.clip(lower_.min(0) - l_c_.max(0), 0.0, np.inf), - np.zeros_like(y_.min(0)), - decimal=decimal, - err_msg="lower_ >l_c", - ) - assert_almost_equal( - np.clip(u_c_.min(0) - upper_.max(0), 0.0, 1e6), - np.zeros_like(y_.min(0)), - decimal=decimal, - err_msg="upper Model: + if dtype is None: + dtype = keras_config.floatx() + if same_output_shape: + n1, n2, n3 = 10, 10, 10 + else: + n1, n2, n3 = 10, 11, 12 + input_tensor = Input(input_shape, dtype=dtype) + output_tensor = Dense(n1, dtype=dtype, activation=activation)(input_tensor) + output_tensor_1 = Dense(n2, dtype=dtype, activation=activation)(output_tensor) + output_tensor_2 = Dense(n3, dtype=dtype, activation=activation)(output_tensor) + model = Model(input_tensor, [output_tensor_1, output_tensor_2]) + return model @staticmethod - def toy_network_tutorial(dtype="float32"): + def toy_network_submodel( + input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None, activation: Optional[str] = "relu" + ) -> Model: + if dtype is None: + dtype = keras_config.floatx() + submodel_input_shape = input_shape[:-1] + (10,) layers = [] - layers.append(Input((1,), dtype=dtype)) - layers.append(Dense(100, dtype=dtype)) - layers.append(Activation("relu", dtype=dtype)) - layers.append(Dense(100, dtype=dtype)) + layers.append(Input(input_shape, dtype=dtype)) + layers.append(Dense(10, dtype=dtype)) + if activation is not None: + layers.append(Activation(activation, dtype=dtype)) + layers.append(Helpers.toy_network_tutorial(submodel_input_shape, dtype=dtype, activation=activation)) + layers.append(Dense(10, dtype=dtype)) layers.append(Dense(1, activation="linear", dtype=dtype)) model = Sequential(layers) return model @staticmethod - def toy_network_tutorial_with_embedded_activation(dtype="float32"): + def toy_network_add( + input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None, activation: Optional[str] = "relu" + ) -> Model: + if dtype is None: + dtype = keras_config.floatx() + input_tensor = Input(input_shape, dtype=dtype) + output = Dense(10, dtype=dtype)(input_tensor) + if activation is not None: + output = Activation(activation, dtype=dtype)(output) + output = Add()([output, output]) + output = Dense(10, dtype=dtype)(output) + if activation is not None: + output = Activation(activation, dtype=dtype)(output) + model = Model(inputs=input_tensor, outputs=output) + return model + + @staticmethod + def toy_network_add_monolayer( + input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None, activation: Optional[str] = "relu" + ) -> Model: + if dtype is None: + dtype = keras_config.floatx() + input_tensor = Input(input_shape, dtype=dtype) + output = Dense(10, dtype=dtype)(input_tensor) + if activation is not None: + output = Activation(activation, dtype=dtype)(output) + output = Add()([output]) + output = Dense(10, dtype=dtype)(output) + if activation is not None: + output = Activation(activation, dtype=dtype)(output) + model = Model(inputs=input_tensor, outputs=output) + return model + + @staticmethod + def toy_network_tutorial_with_embedded_activation(input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None): + if dtype is None: + dtype = keras_config.floatx() layers = [] - layers.append(Input((1,), dtype=dtype)) - layers.append(Dense(100, activation="relu", dtype=dtype)) - layers.append(Dense(100, dtype=dtype)) + layers.append(Input(input_shape, dtype=dtype)) + layers.append(Dense(10, activation="relu", dtype=dtype)) + layers.append(Dense(10, dtype=dtype)) layers.append(Dense(1, activation="linear", dtype=dtype)) model = Sequential(layers) return model @staticmethod - def toy_embedded_sequential(dtype="float32"): + def toy_embedded_sequential(input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None): + if dtype is None: + dtype = keras_config.floatx() layers = [] units = 10 - layers.append(Input((1,), dtype=dtype)) + submodel_input_shape = input_shape[:-1] + (units,) + layers.append(Input(input_shape, dtype=dtype)) layers.append(Dense(units, activation="relu", dtype=dtype)) layers.append( - Helpers.dense_NN_1D( - dtype=dtype, archi=[2, 3, 2], sequential=True, input_dim=units, activation="relu", use_bias=False + Helpers.dense_NN( + dtype=dtype, + archi=[2, 3, 2], + sequential=True, + input_shape=submodel_input_shape, + activation="relu", + use_bias=False, ) ) layers.append(Dense(1, activation="linear", dtype=dtype)) @@ -1443,8 +1183,12 @@ def toy_embedded_sequential(dtype="float32"): return model @staticmethod - def dense_NN_1D(input_dim, archi, sequential, activation, use_bias, dtype="float32"): - layers = [Input((input_dim,), dtype=dtype)] + def dense_NN( + archi, sequential, activation, use_bias, input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None + ): + if dtype is None: + dtype = keras_config.floatx() + layers = [Input(input_shape, dtype=dtype)] layers += [Dense(n_i, use_bias=use_bias, activation=activation, dtype=dtype) for n_i in archi] if sequential: @@ -1457,13 +1201,22 @@ def dense_NN_1D(input_dim, archi, sequential, activation, use_bias, dtype="float return Model(input, output) @staticmethod - def toy_struct_v0_1D(input_dim, archi, activation, use_bias, merge_op=Add, dtype="float32"): - nnet_0 = Helpers.dense_NN_1D( - input_dim=input_dim, archi=archi, sequential=False, activation=activation, use_bias=use_bias, dtype=dtype + def toy_struct_v0( + archi, activation, use_bias, merge_op=Add, input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None + ): + if dtype is None: + dtype = keras_config.floatx() + nnet_0 = Helpers.dense_NN( + input_shape=input_shape, + archi=archi, + sequential=False, + activation=activation, + use_bias=use_bias, + dtype=dtype, ) nnet_1 = Dense(archi[-1], use_bias=use_bias, activation="linear", name="toto", dtype=dtype) - x = Input((input_dim,), dtype=dtype) + x = Input(input_shape, dtype=dtype) h_0 = nnet_0(x) h_1 = nnet_1(x) @@ -1472,9 +1225,19 @@ def toy_struct_v0_1D(input_dim, archi, activation, use_bias, merge_op=Add, dtype return Model(x, y) @staticmethod - def toy_struct_v1_1D(input_dim, archi, sequential, activation, use_bias, merge_op=Add, dtype="float32"): - nnet_0 = Helpers.dense_NN_1D( - input_dim=input_dim, + def toy_struct_v1( + archi, + sequential, + activation, + use_bias, + merge_op=Add, + input_shape: tuple[int, ...] = (1,), + dtype: Optional[str] = None, + ): + if dtype is None: + dtype = keras_config.floatx() + nnet_0 = Helpers.dense_NN( + input_shape=input_shape, archi=archi, sequential=sequential, activation=activation, @@ -1482,7 +1245,7 @@ def toy_struct_v1_1D(input_dim, archi, sequential, activation, use_bias, merge_o dtype=dtype, ) - x = Input((input_dim,), dtype=dtype) + x = Input(input_shape, dtype=dtype) h_0 = nnet_0(x) h_1 = nnet_0(x) y = merge_op(dtype=dtype)([h_0, h_1]) @@ -1490,17 +1253,27 @@ def toy_struct_v1_1D(input_dim, archi, sequential, activation, use_bias, merge_o return Model(x, y) @staticmethod - def toy_struct_v2_1D(input_dim, archi, sequential, activation, use_bias, merge_op=Add, dtype="float32"): - nnet_0 = Helpers.dense_NN_1D( - input_dim=input_dim, + def toy_struct_v2( + archi, + sequential, + activation, + use_bias, + merge_op=Add, + input_shape: tuple[int, ...] = (1,), + dtype: Optional[str] = None, + ): + if dtype is None: + dtype = keras_config.floatx() + nnet_0 = Helpers.dense_NN( + input_shape=input_shape, archi=archi, sequential=sequential, activation=activation, use_bias=use_bias, dtype=dtype, ) - nnet_1 = Helpers.dense_NN_1D( - input_dim=input_dim, + nnet_1 = Helpers.dense_NN( + input_shape=input_shape, archi=archi, sequential=sequential, activation=activation, @@ -1509,7 +1282,7 @@ def toy_struct_v2_1D(input_dim, archi, sequential, activation, use_bias, merge_o ) nnet_2 = Dense(archi[-1], use_bias=use_bias, activation="linear", dtype=dtype) - x = Input((input_dim,), dtype=dtype) + x = Input(input_shape, dtype=dtype) nnet_0(x) nnet_1(x) nnet_1.set_weights([-p for p in nnet_0.get_weights()]) # be sure that the produced output will differ @@ -1520,16 +1293,18 @@ def toy_struct_v2_1D(input_dim, archi, sequential, activation, use_bias, merge_o return Model(x, y) @staticmethod - def toy_struct_cnn(dtype="float32", image_data_shape=(6, 6, 2)): - input_dim = int(np.prod(image_data_shape)) + def toy_struct_cnn( + input_shape: tuple[int, ...] = (6, 6, 2), dtype: Optional[str] = None, data_format="channels_last" + ): + if dtype is None: + dtype = keras_config.floatx() layers = [ - Input((input_dim,)), - Reshape(target_shape=image_data_shape), + Input(input_shape), Conv2D( 10, kernel_size=(3, 3), activation="relu", - data_format="channels_last", + data_format=data_format, dtype=dtype, ), Flatten(dtype=dtype), @@ -1538,33 +1313,47 @@ def toy_struct_cnn(dtype="float32", image_data_shape=(6, 6, 2)): return Sequential(layers) @staticmethod - def toy_model(model_name, dtype="float32"): + def toy_model(model_name, input_shape: tuple[int, ...] = (1,), dtype: Optional[str] = None): + if dtype is None: + dtype = keras_config.floatx() if model_name == "tutorial": - return Helpers.toy_network_tutorial(dtype=dtype) + return Helpers.toy_network_tutorial(input_shape=input_shape, dtype=dtype) elif model_name == "tutorial_activation_embedded": - return Helpers.toy_network_tutorial_with_embedded_activation(dtype=dtype) + return Helpers.toy_network_tutorial_with_embedded_activation(input_shape=input_shape, dtype=dtype) + elif model_name == "add": + return Helpers.toy_network_add(input_shape=input_shape, dtype=dtype) elif model_name == "merge_v0": - return Helpers.toy_struct_v0_1D(dtype=dtype, input_dim=1, archi=[2, 3, 2], activation="relu", use_bias=True) + return Helpers.toy_struct_v0( + dtype=dtype, input_shape=input_shape, archi=[2, 3, 2], activation="relu", use_bias=True + ) elif model_name == "merge_v1": - return Helpers.toy_struct_v1_1D( - dtype=dtype, input_dim=1, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=False + return Helpers.toy_struct_v1( + dtype=dtype, + input_shape=input_shape, + archi=[2, 3, 2], + activation="relu", + use_bias=True, + sequential=False, ) elif model_name == "merge_v1_seq": - return Helpers.toy_struct_v1_1D( - dtype=dtype, input_dim=1, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=True - ) - elif model_name == "merge_v1_2": - return Helpers.toy_struct_v1_1D( - dtype=dtype, input_dim=2, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=False + return Helpers.toy_struct_v1( + dtype=dtype, input_shape=input_shape, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=True ) elif model_name == "merge_v2": - return Helpers.toy_struct_v2_1D( - dtype=dtype, input_dim=1, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=False + return Helpers.toy_struct_v2( + dtype=dtype, + input_shape=input_shape, + archi=[2, 3, 2], + activation="relu", + use_bias=True, + sequential=False, ) elif model_name == "cnn": - return Helpers.toy_struct_cnn(dtype=dtype) - elif model_name == "embedded_model": - return Helpers.toy_embedded_sequential(dtype=dtype) + return Helpers.toy_struct_cnn(input_shape=input_shape, dtype=dtype) + elif model_name == "embedded_model_v1": + return Helpers.toy_embedded_sequential(input_shape=input_shape, dtype=dtype) + elif model_name == "embedded_model_v2": + return Helpers.toy_network_submodel(input_shape=input_shape, dtype=dtype) else: raise ValueError(f"model_name {model_name} unknown") @@ -1574,98 +1363,491 @@ def helpers(): return Helpers -@pytest.fixture( - params=[ - "tutorial", - "tutorial_activation_embedded", - "merge_v0", - "merge_v1", - "merge_v1_seq", - "merge_v1_2", - "merge_v2", - "cnn", - "embedded_model", - ] -) -def toy_model(request, helpers): - model_name = request.param - return helpers.toy_model(model_name, dtype=keras_config.floatx()) +@fixture +def simple_layer_input_functions( + ibp, affine, propagation, perturbation_domain, batchsize, input_shape, equal_ibp, empty, diag, nobatch, helpers +): + keras_symbolic_model_input_fn = lambda: Input(input_shape) + keras_symbolic_layer_input_fn = lambda keras_symbolic_model_input: keras_symbolic_model_input + + decomon_symbolic_input_fn = lambda output_shape, linear: helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape, + model_output_shape=output_shape, + layer_input_shape=input_shape, + layer_output_shape=output_shape, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + empty=empty, + diag=diag, + nobatch=nobatch, + for_linear_layer=linear, + ) + + keras_model_input_fn = lambda: helpers.generate_random_tensor(input_shape, batchsize=batchsize) + keras_layer_input_fn = lambda keras_model_input: keras_model_input + + decomon_input_fn = lambda keras_model_input, keras_layer_input, output_shape, linear: helpers.generate_simple_decomon_layer_inputs_from_keras_input( + keras_input=keras_layer_input, + layer_output_shape=output_shape, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + empty=empty, + diag=diag, + nobatch=nobatch, + for_linear_layer=linear, + equal_ibp=equal_ibp, + ) + + return ( + keras_symbolic_model_input_fn, + keras_symbolic_layer_input_fn, + decomon_symbolic_input_fn, + keras_model_input_fn, + keras_layer_input_fn, + decomon_input_fn, + equal_ibp, + True, + ) + + +def convert_standard_input_functions_for_single_layer( + get_tensor_decomposition_fn, get_standard_values_fn, ibp, affine, propagation, perturbation_domain, helpers +): + keras_symbolic_model_input_fn = lambda: get_tensor_decomposition_fn()[0] + keras_symbolic_layer_input_fn = lambda _: get_tensor_decomposition_fn()[1] + keras_model_input_fn = lambda: K.convert_to_tensor(get_standard_values_fn()[0]) + keras_layer_input_fn = lambda _: K.convert_to_tensor(get_standard_values_fn()[1]) + + if propagation == Propagation.FORWARD: + + def decomon_symbolic_input_fn(output_shape, linear): + x, y, z, u_c, w_u, b_u, l_c, w_l, b_l = get_tensor_decomposition_fn() + layer_input_shape = y.shape[1:] + model_input_shape = x.shape[1:] + + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=output_shape, + linear=linear, + ) + if affine: + affine_bounds_to_propagate = [w_l, b_l, w_u, b_u] + else: + affine_bounds_to_propagate = [] -@pytest.fixture( - params=[ - "tutorial", - "tutorial_activation_embedded", - "merge_v0", - "merge_v1", - "merge_v1_seq", - "merge_v2", - "embedded_model", - ] + if ibp: + constant_oracle_bounds = [l_c, u_c] + else: + constant_oracle_bounds = [] + + if inputs_outputs_spec.needs_perturbation_domain_inputs(): + if isinstance(perturbation_domain, BoxDomain): + perturbation_domain_inputs = [z] + else: + raise NotImplementedError + else: + perturbation_domain_inputs = [] + + return inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + + def decomon_input_fn(keras_model_input, keras_layer_input, output_shape, linear): + x, y, z, u_c, w_u, b_u, l_c, w_l, b_l = get_standard_values_fn() + layer_input_shape = tuple(y.shape[1:]) + model_input_shape = tuple(x.shape[1:]) + + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=output_shape, + linear=linear, + ) + + if affine: + affine_bounds_to_propagate = [K.convert_to_tensor(a) for a in (w_l, b_l, w_u, b_u)] + else: + affine_bounds_to_propagate = [] + + if ibp: + constant_oracle_bounds = [K.convert_to_tensor(a) for a in (l_c, u_c)] + else: + constant_oracle_bounds = [] + + if inputs_outputs_spec.needs_perturbation_domain_inputs(): + if isinstance(perturbation_domain, BoxDomain): + perturbation_domain_inputs = [K.convert_to_tensor(z)] + else: + raise NotImplementedError + else: + perturbation_domain_inputs = [] + + return inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + + else: # backward + + def decomon_symbolic_input_fn(output_shape, linear): + x, y, z, u_c, w_u, b_u, l_c, w_l, b_l = get_tensor_decomposition_fn() + layer_input_shape = y.shape[1:] + model_input_shape = x.shape[1:] + + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=output_shape, + linear=linear, + ) + + if inputs_outputs_spec.needs_constant_bounds_inputs(): + constant_oracle_bounds = [l_c, u_c] + else: + constant_oracle_bounds = [] + + if inputs_outputs_spec.needs_perturbation_domain_inputs(): + if isinstance(perturbation_domain, BoxDomain): + perturbation_domain_inputs = [z] + else: + raise NotImplementedError + else: + perturbation_domain_inputs = [] + + # take identity affine bounds + if inputs_outputs_spec.needs_affine_bounds_inputs(): + simple_decomon_inputs = helpers.get_decomon_symbolic_inputs( + model_input_shape=model_input_shape, + model_output_shape=output_shape, + layer_input_shape=layer_input_shape, + layer_output_shape=output_shape, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + empty=empty, + diag=diag, + nobatch=nobatch, + ) + affine_bounds_to_propagate, _, _ = inputs_outputs_spec.split_inputs(simple_decomon_inputs) + else: + affine_bounds_to_propagate = [] + + return inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + + def decomon_input_fn(keras_model_input, keras_layer_input, output_shape, linear): + x, y, z, u_c, w_u, b_u, l_c, w_l, b_l = get_standard_values_fn() + layer_input_shape = tuple(y.shape[1:]) + model_input_shape = tuple(x.shape[1:]) + + inputs_outputs_spec = InputsOutputsSpec( + ibp=ibp, + affine=affine, + propagation=propagation, + layer_input_shape=layer_input_shape, + model_input_shape=model_input_shape, + model_output_shape=output_shape, + linear=linear, + ) + + if inputs_outputs_spec.needs_constant_bounds_inputs(): + constant_oracle_bounds = [K.convert_to_tensor(a) for a in (l_c, u_c)] + else: + constant_oracle_bounds = [] + + if inputs_outputs_spec.needs_perturbation_domain_inputs(): + if isinstance(perturbation_domain, BoxDomain): + perturbation_domain_inputs = [K.convert_to_tensor(z)] + else: + raise NotImplementedError + else: + perturbation_domain_inputs = [] + + #  take identity affine bounds + if inputs_outputs_spec.needs_affine_bounds_inputs(): + simple_decomon_inputs = helpers.generate_simple_decomon_layer_inputs_from_keras_input( + keras_input=keras_layer_input, + layer_output_shape=output_shape, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + empty=empty, + diag=diag, + nobatch=nobatch, + for_linear_layer=linear, + ) + affine_bounds_to_propagate, _, _ = inputs_outputs_spec.split_inputs(simple_decomon_inputs) + else: + affine_bounds_to_propagate = [] + + return inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate, + constant_oracle_bounds=constant_oracle_bounds, + perturbation_domain_inputs=perturbation_domain_inputs, + ) + + return ( + keras_symbolic_model_input_fn, + keras_symbolic_layer_input_fn, + decomon_symbolic_input_fn, + keras_model_input_fn, + keras_layer_input_fn, + decomon_input_fn, + False, + False, + ) + + +@fixture +def standard_layer_input_functions_0d(n, ibp, affine, propagation, batchsize, helpers): + perturbation_domain = BoxDomain() + get_tensor_decomposition_fn = helpers.get_tensor_decomposition_0d_box + get_standard_values_fn = lambda: helpers.get_standard_values_0d_box(n=n, batchsize=batchsize) + return convert_standard_input_functions_for_single_layer( + get_tensor_decomposition_fn, get_standard_values_fn, ibp, affine, propagation, perturbation_domain, helpers + ) + + +@fixture +def standard_layer_input_functions_1d(odd, ibp, affine, propagation, batchsize, helpers): + perturbation_domain = BoxDomain() + get_tensor_decomposition_fn = lambda: helpers.get_tensor_decomposition_1d_box(odd=odd) + get_standard_values_fn = lambda: helpers.get_standard_values_1d_box(odd=odd, batchsize=batchsize) + return convert_standard_input_functions_for_single_layer( + get_tensor_decomposition_fn, get_standard_values_fn, ibp, affine, propagation, perturbation_domain, helpers + ) + + +@fixture +def standard_layer_input_functions_multid(data_format, ibp, affine, propagation, batchsize, helpers): + perturbation_domain = BoxDomain() + odd, m0, m1 = 0, 0, 1 + get_tensor_decomposition_fn = lambda: helpers.get_tensor_decomposition_images_box(data_format=data_format, odd=odd) + get_standard_values_fn = lambda: helpers.get_standard_values_images_box( + data_format=data_format, odd=odd, m0=m0, m1=m1, batchsize=batchsize + ) + return convert_standard_input_functions_for_single_layer( + get_tensor_decomposition_fn, get_standard_values_fn, ibp, affine, propagation, perturbation_domain, helpers + ) + + +layer_input_functions = fixture_union( + "layer_input_functions", + [ + simple_layer_input_functions, + standard_layer_input_functions_0d, + standard_layer_input_functions_1d, + standard_layer_input_functions_multid, + ], + unpack_into="keras_symbolic_model_input_fn, keras_symbolic_layer_input_fn, decomon_symbolic_input_fn, keras_model_input_fn, keras_layer_input_fn, decomon_input_fn, equal_ibp_bounds, equal_affine_bounds", +) + +( + simple_keras_symbolic_model_input_fn, + simple_keras_symbolic_layer_input_fn, + simple_decomon_symbolic_input_fn, + simple_keras_model_input_fn, + simple_keras_layer_input_fn, + simple_decomon_input_fn, + simple_equal_ibp_bounds, + simple_equal_affine_bounds, +) = unpack_fixture( + "simple_keras_symbolic_model_input_fn, simple_keras_symbolic_layer_input_fn, simple_decomon_symbolic_input_fn, simple_keras_model_input_fn, simple_keras_layer_input_fn, simple_decomon_input_fn, simple_equal_ibp_bounds, simple_equal_affine_bounds", + simple_layer_input_functions, ) -def toy_model_1d(request, helpers): - model_name = request.param - return helpers.toy_model(model_name, dtype=keras_config.floatx()) -@fixture() -def decomon_inputs_1d(n, mode, dc_decomp, helpers): - # tensors inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) +# keras/decomon model inputs +@fixture +def simple_model_input_functions(perturbation_domain, equal_ibp, batchsize, helpers): + decomon_symbolic_input_fn = lambda keras_symbolic_input: Input( + perturbation_domain.get_x_input_shape_wo_batchsize(keras_symbolic_input.shape[1:]) + ) + keras_input_fn = lambda keras_symbolic_input: helpers.generate_random_tensor( + keras_symbolic_input.shape[1:], batchsize=batchsize + ) + decomon_input_fn = lambda keras_input: helpers.generate_simple_perturbation_domain_inputs_from_keras_input( + keras_input=keras_input, perturbation_domain=perturbation_domain, equal_bounds=equal_ibp + ) + + return ( + decomon_symbolic_input_fn, + keras_input_fn, + decomon_input_fn, + ) + + +( + simple_model_decomon_symbolic_input_fn, + simple_model_keras_input_fn, + simple_model_decomon_input_fn, +) = unpack_fixture( + "simple_model_decomon_symbolic_input_fn, simple_model_keras_input_fn, simple_model_decomon_input_fn", + simple_model_input_functions, +) - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n=n, dc_decomp=dc_decomp) - inputs_for_mode_ = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_, mode=mode, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - # inputs metadata worth to pass to the layer - inputs_metadata = dict() +@fixture +def simple_model_inputs(simple_model_input_functions, input_shape): + ( + decomon_symbolic_input_fn, + keras_input_fn, + decomon_input_fn, + ) = simple_model_input_functions + + keras_symbolic_input = Input(input_shape) + decomon_symbolic_input = decomon_symbolic_input_fn(keras_symbolic_input) + keras_input = keras_input_fn(keras_symbolic_input) + decomon_input = decomon_input_fn(keras_input) + metadata = dict(name="simple") + + return keras_symbolic_input, decomon_symbolic_input, keras_input, decomon_input, metadata + + +( + simple_model_keras_symbolic_input, + simple_model_decomon_symbolic_input, + simple_model_keras_input, + simple_model_decomon_input, + simple_model_decomon_input_metadata, +) = unpack_fixture( + "simple_model_keras_symbolic_input, simple_model_decomon_symbolic_input, simple_model_keras_input, simple_model_decomon_input, simple_model_decomon_input_metadata", + simple_model_inputs, +) - return inputs, inputs_for_mode, input_ref, inputs_, inputs_for_mode_, input_ref_, inputs_metadata +def convert_standard_inputs_for_model(get_tensor_decomposition_fn, get_standard_values_fn, metadata): + x, y, z, u_c, w_u, b_u, l_c, w_l, b_l = get_tensor_decomposition_fn() + x_, y_, z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_ = get_standard_values_fn() -@fixture() -def decomon_inputs_multid(odd, mode, dc_decomp, helpers): - # tensors inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) + keras_symbolic_input = y + keras_input = K.convert_to_tensor(y_) + decomon_symbolic_input = K.concatenate([l_c[:, None], u_c[:, None]], axis=1) + decomon_input = K.convert_to_tensor(np.concatenate((l_c_[:, None], u_c_[:, None]), axis=1)) - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode_ = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_, mode=mode, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) + return keras_symbolic_input, decomon_symbolic_input, keras_input, decomon_input, metadata - # inputs metadata worth to pass to the layer - inputs_metadata = dict() - return inputs, inputs_for_mode, input_ref, inputs_, inputs_for_mode_, input_ref_, inputs_metadata +@fixture +def standard_model_inputs_0d(n, batchsize, helpers): + get_tensor_decomposition_fn = helpers.get_tensor_decomposition_0d_box + get_standard_values_fn = lambda: helpers.get_standard_values_0d_box(n=n, batchsize=batchsize) + metadata = dict(name="standard-0d", n=n) + return convert_standard_inputs_for_model(get_tensor_decomposition_fn, get_standard_values_fn, metadata) -@fixture() -def decomon_inputs_images(data_format, mode, dc_decomp, helpers): - odd, m_0, m_1 = 0, 0, 1 +@fixture +def standard_model_inputs_1d(odd, batchsize, helpers): + get_tensor_decomposition_fn = lambda: helpers.get_tensor_decomposition_1d_box(odd=odd) + get_standard_values_fn = lambda: helpers.get_standard_values_1d_box(odd=odd, batchsize=batchsize) + metadata = dict(name="standard-1d", odd=odd) + return convert_standard_inputs_for_model(get_tensor_decomposition_fn, get_standard_values_fn, metadata) - # tensors inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format=data_format, odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format=data_format, odd=odd, dc_decomp=dc_decomp) - inputs_for_mode_ = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_, mode=mode, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) +@fixture +def standard_model_inputs_multid(data_format, batchsize, helpers): + odd, m0, m1 = 0, 0, 1 + get_tensor_decomposition_fn = lambda: helpers.get_tensor_decomposition_images_box(data_format=data_format, odd=odd) + get_standard_values_fn = lambda: helpers.get_standard_values_images_box( + data_format=data_format, odd=odd, m0=m0, m1=m1, batchsize=batchsize + ) + metadata = dict(name="standard-multid", data_format=data_format) + return convert_standard_inputs_for_model(get_tensor_decomposition_fn, get_standard_values_fn, metadata) - # inputs metadata worth to pass to the layer - inputs_metadata = dict(data_format=data_format) - return inputs, inputs_for_mode, input_ref, inputs_, inputs_for_mode_, input_ref_, inputs_metadata +model_inputs = fixture_union( + "model_inputs", + [ + simple_model_inputs, + standard_model_inputs_0d, + standard_model_inputs_1d, + standard_model_inputs_multid, + ], + unpack_into="model_keras_symbolic_input, model_decomon_symbolic_input, model_keras_input, model_decomon_input, model_decomon_input_metadata", +) -decomon_inputs = fixture_union( - "decomon_inputs", - [decomon_inputs_1d, decomon_inputs_multid, decomon_inputs_images], - unpack_into="inputs, inputs_for_mode, input_ref, inputs_, inputs_for_mode_, input_ref_, inputs_metadata", +# keras toy models +toy_model_name = param_fixture( + "toy_model_name", + [ + "tutorial", + "tutorial_linear", + "tutorial_activation_embedded", + "add", + "add_linear", + "merge_v0", + "merge_v1", + "merge_v1_seq", + "merge_v2", + "cnn", + "embedded_model_v1", + "embedded_model_v2", + ], ) + + +@fixture +def toy_model_fn(toy_model_name, helpers): + """Return a function generating a keras model from (input_shape, dtype).""" + if toy_model_name == "tutorial": + return Helpers.toy_network_tutorial + elif toy_model_name == "tutorial_linear": + return lambda input_shape, dtype=None: Helpers.toy_network_tutorial( + input_shape=input_shape, dtype=dtype, activation=None + ) + elif toy_model_name == "tutorial_activation_embedded": + return Helpers.toy_network_tutorial_with_embedded_activation + elif toy_model_name == "add": + return Helpers.toy_network_add + elif toy_model_name == "add_linear": + return lambda input_shape, dtype=None: Helpers.toy_network_add( + input_shape=input_shape, dtype=dtype, activation=None + ) + elif toy_model_name == "merge_v0": + return lambda input_shape, dtype=None: Helpers.toy_struct_v0( + dtype=dtype, input_shape=input_shape, archi=[2, 3, 2], activation="relu", use_bias=True + ) + elif toy_model_name == "merge_v1": + return lambda input_shape, dtype=None: Helpers.toy_struct_v1( + dtype=dtype, input_shape=input_shape, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=False + ) + elif toy_model_name == "merge_v1_seq": + return lambda input_shape, dtype=None: Helpers.toy_struct_v1( + dtype=dtype, input_shape=input_shape, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=True + ) + elif toy_model_name == "merge_v2": + return lambda input_shape, dtype=None: Helpers.toy_struct_v2( + dtype=dtype, input_shape=input_shape, archi=[2, 3, 2], activation="relu", use_bias=True, sequential=False + ) + elif toy_model_name == "cnn": + return Helpers.toy_struct_cnn + elif toy_model_name == "embedded_model_v1": + return Helpers.toy_embedded_sequential + elif toy_model_name == "embedded_model_v2": + return Helpers.toy_network_submodel + else: + raise ValueError(f"model_name {toy_model_name} unknown") diff --git a/tests/lirpa_comparison/test_comparison_lirpa.py b/tests/lirpa_comparison/test_comparison_lirpa.py index c8780b8b..8bfad380 100644 --- a/tests/lirpa_comparison/test_comparison_lirpa.py +++ b/tests/lirpa_comparison/test_comparison_lirpa.py @@ -9,8 +9,8 @@ from onnx2keras import onnx_to_keras from onnx2torch import convert +from decomon.constants import ConvertMethod from decomon.models.convert import clone -from decomon.models.utils import ConvertMethod @pytest.mark.parametrize( diff --git a/tests/test_activation.py b/tests/test_activation.py deleted file mode 100644 index c41b7f36..00000000 --- a/tests/test_activation.py +++ /dev/null @@ -1,143 +0,0 @@ -# Test unit for decomon with Dense layers - - -import keras.ops as K -import numpy as np -import pytest -from numpy.testing import assert_almost_equal - -from decomon.core import ForwardMode, Slope -from decomon.layers.activations import relu, sigmoid, softmax, softsign, tanh - - -@pytest.mark.parametrize( - "activation_func, tensor_func, decimal", - [ - (sigmoid, K.sigmoid, 5), - (tanh, K.tanh, 5), - (softsign, K.softsign, 4), - (softmax, K.softmax, 4), - (relu, K.relu, 4), - ], -) -def test_activation_1D_box(n, mode, floatx, decimal, helpers, activation_func, tensor_func): - # softmax: test only n=0,3 - if activation_func is softmax: - if n not in {0, 3}: - pytest.skip("softmax activation only possible for n=0 or 3") - - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs=inputs_) - - # reference output - output_ref_ = K.convert_to_numpy(tensor_func(input_ref_)) - - # decomon output - output = activation_func(inputs_for_mode, dc_decomp=dc_decomp, mode=mode) - f_decomon = helpers.function(inputs, output) - outputs_ = f_decomon(inputs_) - - #  check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) - - -@pytest.mark.parametrize( - "n", - [ - 0, - 1, - 2, - ], -) -def test_activation_1D_box_slope(n, slope, helpers): - mode = ForwardMode.AFFINE - activation_func = relu - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # decomon output - outputs = activation_func(inputs_for_mode, dc_decomp=dc_decomp, mode=mode, slope=slope) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # full outputs & inputs - ( - z_output, - u_c_output, - w_u_output, - b_u_output, - l_c_output, - w_l_output, - b_l_output, - h_output, - g_output, - ) = helpers.get_full_outputs_from_outputs_for_mode( - outputs_for_mode=outputs_, - full_inputs=inputs_, - mode=mode, - dc_decomp=dc_decomp, - ) - - ( - x_0, - y_0, - z_0, - u_c_0, - W_u_0, - b_u_0, - l_c_0, - W_l_0, - b_l_0, - ) = inputs_ # numpy values - - # check bounds according to slope - slope = Slope(slope) - if n == 0: - assert_almost_equal(w_u_output, np.zeros(w_u_output.shape)) - assert_almost_equal(b_u_output, np.zeros(b_u_output.shape)) - assert_almost_equal(w_l_output, np.zeros(w_l_output.shape)) - assert_almost_equal(b_l_output, np.zeros(b_l_output.shape)) - elif n == 1: - assert_almost_equal(w_u_output, np.ones(w_u_output.shape)) - assert_almost_equal(b_u_output, np.zeros(b_u_output.shape)) - assert_almost_equal(w_l_output, W_l_0) - assert_almost_equal(b_l_output, np.zeros(b_l_output.shape)) - elif n == 2: - assert_almost_equal(w_u_output, 0.5 * np.ones(w_u_output.shape)) - assert_almost_equal(b_u_output, 0.5 * np.ones(b_u_output.shape)) - if slope == Slope.O_SLOPE: - assert_almost_equal(w_l_output, W_l_0) - assert_almost_equal(b_l_output, np.zeros(b_l_output.shape)) - elif slope == Slope.Z_SLOPE: - assert_almost_equal(w_l_output, np.zeros(w_l_output.shape)) - assert_almost_equal(b_l_output, np.zeros(b_l_output.shape)) - elif slope == Slope.V_SLOPE: - assert_almost_equal(w_l_output, np.zeros(w_l_output.shape)) - assert_almost_equal(b_l_output, np.zeros(b_l_output.shape)) - elif slope == Slope.S_SLOPE: - assert_almost_equal(w_l_output, w_u_output) - assert_almost_equal(b_l_output, np.zeros(b_l_output.shape)) - elif slope == Slope.A_SLOPE: - assert_almost_equal(b_l_output, np.zeros(b_l_output.shape)) - assert_almost_equal(w_l_output, np.zeros(w_l_output.shape)) diff --git a/tests/test_backward_activation.py b/tests/test_backward_activation.py deleted file mode 100644 index 598f805c..00000000 --- a/tests/test_backward_activation.py +++ /dev/null @@ -1,101 +0,0 @@ -import numpy as np -import pytest -from numpy.testing import assert_almost_equal - -from decomon.backward_layers.activations import backward_relu, backward_softsign -from decomon.core import ForwardMode, Slope - - -@pytest.mark.parametrize( - "activation_func", - [ - backward_relu, - backward_softsign, - ], -) -def test_activation_backward_1D_box(n, mode, floatx, decimal, activation_func, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # backward outputs - outputs = activation_func(inputs_for_mode, mode=mode) - f_func = helpers.function(inputs, outputs) - outputs_ = f_func(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -@pytest.mark.parametrize( - "n", - [ - 0, - 1, - 2, - ], -) -def test_activation_backward_1D_box_slope(n, slope, helpers): - mode = ForwardMode.AFFINE - activation_func = backward_relu - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - x_, y_, z_, u_c_, W_u_, B_u_, l_c_, W_l_, B_l_ = inputs_ - - # backward outputs - outputs = activation_func(inputs_for_mode, mode=mode) - f_func = helpers.function(inputs, outputs) - outputs_ = f_func(inputs_) - - # backward recomposition - w_u_, b_u_, w_l_, b_l_ = outputs_ - w_u_b = np.sum(np.maximum(w_u_, 0) * W_u_ + np.minimum(w_u_, 0) * W_l_, 1)[:, :, None] - b_u_b = b_u_ + np.sum(np.maximum(w_u_, 0) * B_u_[:, :, None], 1) + np.sum(np.minimum(w_u_, 0) * B_l_[:, :, None], 1) - w_l_b = np.sum(np.maximum(w_l_, 0) * W_l_ + np.minimum(w_l_, 0) * W_u_, 1)[:, :, None] - b_l_b = b_l_ + np.sum(np.maximum(w_l_, 0) * B_l_[:, :, None], 1) + np.sum(np.minimum(w_l_, 0) * B_u_[:, :, None], 1) - - # check bounds according to slope - slope = Slope(slope) - if n == 0: - assert_almost_equal(w_u_b, np.zeros(w_u_b.shape)) - assert_almost_equal(b_u_b, np.zeros(b_u_b.shape)) - assert_almost_equal(w_l_b, np.zeros(w_l_b.shape)) - assert_almost_equal(b_l_b, np.zeros(b_l_b.shape)) - elif n == 1: - assert_almost_equal(w_u_b, len(w_u_b) * np.ones(w_u_b.shape)) # * len ?? - assert_almost_equal(b_u_b, np.zeros(b_u_b.shape)) - assert_almost_equal(w_l_b, len(w_l_b) * np.ones(w_l_b.shape)) - assert_almost_equal(b_l_b, np.zeros(b_l_b.shape)) - elif n == 2: - assert_almost_equal(w_u_b, 0.5 * len(w_u_b) * np.ones(w_u_b.shape)) - assert_almost_equal(b_u_b, 0.5 * np.ones(b_u_b.shape)) - if slope == Slope.O_SLOPE: - assert_almost_equal(w_l_b, np.zeros(w_l_b.shape)) # ?? - assert_almost_equal(b_l_b, np.zeros(b_l_b.shape)) - elif slope == Slope.Z_SLOPE: - assert_almost_equal(w_l_b, np.zeros(w_l_b.shape)) - assert_almost_equal(b_l_b, np.zeros(b_l_b.shape)) - elif slope == Slope.V_SLOPE: - assert_almost_equal(w_l_b, np.zeros(w_l_b.shape)) - assert_almost_equal(b_l_b, np.zeros(b_l_b.shape)) - elif slope == Slope.S_SLOPE: - assert_almost_equal(w_l_b, np.zeros(w_l_b.shape)) # ?? - assert_almost_equal(b_l_b, np.zeros(b_l_b.shape)) - elif slope == Slope.A_SLOPE: - assert_almost_equal(b_l_b, np.zeros(b_l_b.shape)) - assert_almost_equal(w_l_b, np.zeros(w_l_b.shape)) diff --git a/tests/test_backward_compute_output_shape.py b/tests/test_backward_compute_output_shape.py deleted file mode 100644 index 8753ff01..00000000 --- a/tests/test_backward_compute_output_shape.py +++ /dev/null @@ -1,79 +0,0 @@ -import pytest -from keras.layers import Activation, InputLayer, Reshape - -from decomon.backward_layers.convert import to_backward -from decomon.layers.core import DecomonLayer -from decomon.layers.decomon_layers import ( - DecomonActivation, - DecomonBatchNormalization, - DecomonConv2D, - DecomonDense, - DecomonFlatten, -) -from decomon.layers.decomon_merge_layers import DecomonAdd, DecomonConcatenate -from decomon.layers.decomon_reshape import DecomonReshape -from decomon.layers.maxpooling import DecomonMaxPooling2D - - -@pytest.mark.parametrize( - "layer_class, layer_kwargs", - [ - (DecomonDense, dict(units=3, use_bias=True)), - (DecomonFlatten, dict()), - (DecomonBatchNormalization, dict(center=True, scale=True)), - (DecomonBatchNormalization, dict(center=False, scale=False)), - (DecomonActivation, dict(activation="linear")), - (DecomonActivation, dict(activation="relu")), - (Activation, dict(activation="linear")), - (Activation, dict(activation="relu")), - (DecomonConv2D, dict(filters=10, kernel_size=(3, 3))), - (DecomonReshape, dict(target_shape=(1, -1, 1))), - (Reshape, dict(target_shape=(1, -1, 1))), - ], -) -@pytest.mark.parametrize("dc_decomp", [False]) # limit dc_decomp -@pytest.mark.parametrize("n", [0]) # limit 1d cases -@pytest.mark.parametrize("odd", [0]) # limit multid cases -@pytest.mark.parametrize("data_format", ["channels_last"]) # limit images cases -def test_compute_output_shape( - helpers, - mode, - dc_decomp, - layer_class, - layer_kwargs, - inputs_for_mode, # decomon inputs: symbolic tensors - input_ref, # keras input: symbolic tensor - inputs_for_mode_, # decomon inputs: numpy arrays - inputs_metadata, # inputs metadata: data_format, ... -): - # skip nonsensical combinations - if (layer_class == DecomonBatchNormalization or layer_class == DecomonMaxPooling2D) and dc_decomp: - pytest.skip(f"{layer_class} with dc_decomp=True not yet implemented.") - if (layer_class == DecomonConv2D or layer_class == DecomonMaxPooling2D) and len(input_ref.shape) < 4: - pytest.skip(f"{layer_class} applies only on image-like inputs.") - - # add data_format for convolution and maxpooling - if layer_class in (DecomonConv2D, DecomonMaxPooling2D): - layer_kwargs["data_format"] = inputs_metadata["data_format"] - - # construct and build original layer (decomon or keras) - if issubclass(layer_class, DecomonLayer): - layer = layer_class(mode=mode, dc_decomp=dc_decomp, **layer_kwargs) - layer(inputs_for_mode) - else: # keras layer - layer = layer_class(**layer_kwargs) - layer(input_ref) - - # get backward layer - backward_layer = to_backward(layer, mode=mode) - - # check symbolic tensor output shapes - inputshapes = [i.shape for i in inputs_for_mode] - outputshapes = backward_layer.compute_output_shape(inputshapes) - outputs = backward_layer(inputs_for_mode) - assert [o.shape for o in outputs] == outputshapes - - # check output shapes for concrete call - outputs_ = backward_layer(inputs_for_mode_) - # compare without batch sizes - assert [tuple(o.shape)[1:] for o in outputs_] == [s[1:] for s in outputshapes] diff --git a/tests/test_backward_conv.py b/tests/test_backward_conv.py deleted file mode 100644 index 88bc3131..00000000 --- a/tests/test_backward_conv.py +++ /dev/null @@ -1,85 +0,0 @@ -# Test unit for decomon with Dense layers - - -import keras.config as keras_config -import numpy as np -import pytest - -from decomon.backward_layers.convert import to_backward -from decomon.layers.decomon_layers import DecomonConv2D - - -def test_Decomon_conv_box(data_format, padding, use_bias, mode, floatx, decimal, helpers): - # skip unavailable combinations - if floatx == 16 and keras_config.backend() == "torch" and not helpers.in_GPU_mode(): - pytest.skip("Pytorch does not implement conv2d for float16 in CPU mode.") - - if data_format == "channels_first" and not helpers.in_GPU_mode() and keras_config.backend() == "tensorflow": - pytest.skip("data format 'channels first' is possible only in GPU mode for tensorflow.") - - odd, m_0, m_1 = 0, 0, 1 - dc_decomp = False - - # tensor inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - - # decomon layer - decomon_layer = DecomonConv2D( - 10, - kernel_size=(3, 3), - dc_decomp=dc_decomp, - padding=padding, - use_bias=use_bias, - mode=mode, - dtype=keras_config.floatx(), - data_format=data_format, - ) - decomon_layer(inputs_for_mode) # init weights - - # get backward layer - backward_layer = to_backward(decomon_layer) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - w_u_, b_u_, w_l_, b_l_ = outputs_ - - # reshape the matrices - w_u_ = w_u_[:, None] - w_l_ = w_l_[:, None] - b_u_ = b_u_[:, None] - b_l_ = b_l_[:, None] - - b_l = b_l.reshape((len(b_l), -1)) - b_u = b_u.reshape((len(b_u), -1)) - W_u = W_u.reshape((len(W_u), W_u.shape[1], -1)) - W_l = W_l.reshape((len(W_l), W_l.shape[1], -1)) - - # backward recomposition - w_r_u = np.sum(np.maximum(0.0, w_u_) * np.expand_dims(W_u, -1), 2) + np.sum( - np.minimum(0.0, w_u_) * np.expand_dims(W_l, -1), 2 - ) - w_r_l = np.sum(np.maximum(0.0, w_l_) * np.expand_dims(W_l, -1), 2) + np.sum( - np.minimum(0.0, w_l_) * np.expand_dims(W_u, -1), 2 - ) - b_r_u = ( - np.sum(np.maximum(0, w_u_[:, 0]) * np.expand_dims(b_u, -1), 1)[:, None] - + np.sum(np.minimum(0, w_u_[:, 0]) * np.expand_dims(b_l, -1), 1)[:, None] - + b_u_ - ) - b_r_l = ( - np.sum(np.maximum(0, w_l_[:, 0]) * np.expand_dims(b_l, -1), 1)[:, None] - + np.sum(np.minimum(0, w_l_[:, 0]) * np.expand_dims(b_u, -1), 1)[:, None] - + b_l_ - ) - - # check bounds consistency - helpers.assert_output_properties_box_linear(x, None, z[:, 0], z[:, 1], None, w_r_u, b_r_u, None, w_r_l, b_r_l) diff --git a/tests/test_backward_dense.py b/tests/test_backward_dense.py deleted file mode 100644 index 4c236189..00000000 --- a/tests/test_backward_dense.py +++ /dev/null @@ -1,112 +0,0 @@ -import keras.config as keras_config -from keras.layers import Dense, Input - -from decomon.backward_layers.convert import to_backward -from decomon.core import ForwardMode -from decomon.layers.decomon_layers import DecomonDense - - -def test_Backward_Dense_1D_box(n, use_bias, mode, floatx, decimal, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_dim = helpers.get_input_dim_from_full_inputs(inputs) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # keras layer - keras_layer = Dense(1, use_bias=use_bias, dtype=keras_config.floatx()) - keras_layer(input_ref) # init weights - - # get backward layer - backward_layer = to_backward(keras_layer, mode=mode, input_dim=input_dim) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_Dense_multiD_box(odd, floatx, decimal, mode, helpers): - dc_decomp = False - use_bias = True - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_dim = helpers.get_input_dim_from_full_inputs(inputs) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # keras layer - keras_layer = Dense(1, use_bias=use_bias, dtype=keras_config.floatx()) - keras_layer(input_ref) # init weights - - # get backward layer - backward_layer = to_backward(keras_layer, mode=mode, input_dim=input_dim) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_DecomonDense_1D_box(n, helpers): - dc_decomp = False - use_bias = True - mode = ForwardMode.HYBRID - decimal = 5 - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_dim = helpers.get_input_dim_from_full_inputs(inputs) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # decomon layer - decomon_layer = DecomonDense(1, use_bias=use_bias, dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx()) - decomon_layer(inputs_for_mode) # init weights - - # get backward layer - backward_layer = to_backward(decomon_layer, input_dim=input_dim) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - # try to call the backward layer with previous bounds - w_out = Input((1, 1)) - b_out = Input((1,)) - previous_bounds = [w_out, b_out, w_out, b_out] - backward_layer(inputs_for_mode + previous_bounds) diff --git a/tests/test_backward_layer_wo_merge.py b/tests/test_backward_layer_wo_merge.py deleted file mode 100644 index 90807e7c..00000000 --- a/tests/test_backward_layer_wo_merge.py +++ /dev/null @@ -1,115 +0,0 @@ -import keras.config as keras_config -import numpy as np -import pytest -from keras.layers import ( - Activation, - BatchNormalization, - Conv2D, - Dense, - Dropout, - Flatten, - Input, - MaxPooling2D, - Permute, - Reshape, -) -from keras.ops import convert_to_numpy -from keras.src.layers.merging.dot import batch_dot - -from decomon.backward_layers.convert import to_backward -from decomon.core import ForwardMode -from decomon.layers.core import DecomonLayer -from decomon.layers.decomon_layers import DecomonDense - - -@pytest.mark.parametrize( - "layer_class, layer_kwargs", - [ - (Dense, dict(units=3, use_bias=True)), - (Dense, dict(units=3, use_bias=False)), - (Activation, dict(activation="linear")), - (Activation, dict(activation="relu")), - (Reshape, dict(target_shape=(1, -1, 1))), - (BatchNormalization, dict()), - (BatchNormalization, dict(axis=1)), - (BatchNormalization, dict(center=False, scale=False)), - (BatchNormalization, dict(axis=2, center=False)), - (BatchNormalization, dict(axis=3)), - (Conv2D, dict(filters=10, kernel_size=(3, 3), data_format="channels_last")), - (Flatten, dict()), - (Dropout, dict(rate=0.9)), - (Permute, dict(dims=(1,))), - (Permute, dict(dims=(2, 1, 3))), - ], -) -@pytest.mark.parametrize("randomize_weights", [False, True]) -@pytest.mark.parametrize("floatx", [32]) # fix floatx -@pytest.mark.parametrize("data_format", ["channels_last"]) # limit images cases -@pytest.mark.parametrize("dc_decomp", [False]) # limit dc_decomp -def test_backward_layer( - helpers, - mode, - layer_class, - layer_kwargs, - randomize_weights, - decimal, - dc_decomp, - inputs_for_mode, # decomon inputs: symbolic tensors - inputs, # decomon inputs: symbolic tensors - input_ref, # keras input: symbolic tensor - inputs_, # decomon inputs: numpy arrays - input_ref_, # keras input: numpy array - inputs_metadata, # inputs metadata: data_format, ... -): - # skip nonsensical combinations - if ( - layer_class is BatchNormalization - and "axis" in layer_kwargs - and layer_kwargs["axis"] > 1 - and len(input_ref.shape) < 4 - ): - pytest.skip("batchnormalization on axis>1 possible only for image-like data") - if layer_class in (Conv2D, MaxPooling2D) and len(input_ref.shape) < 4: - pytest.skip("convolution and maxpooling2d possible only for image-like data") - if layer_class is Permute and len(layer_kwargs["dims"]) < 3 and len(input_ref.shape) >= 4: - pytest.skip("1d permutation not possible for image-like data") - if layer_class is Permute and len(layer_kwargs["dims"]) == 3 and len(input_ref.shape) < 4: - pytest.skip("3d permutation possible only for image-like data") - - # add data_format for convolution and maxpooling - if layer_class in (Conv2D,): - layer_kwargs["data_format"] = inputs_metadata["data_format"] - - # construct and build original layer (keras) - layer = layer_class(**layer_kwargs) - output_ref = layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - - # randomize weights - if randomize_weights: - for weight in layer.weights: - weight.assign(np.random.random(tuple(weight.shape))) - - # get backward layer & function - backward_layer = to_backward(layer, mode=mode) - outputs = backward_layer(inputs_for_mode) - f_backward = helpers.function(inputs, outputs) - - # keras & decomon outputs - output_ref_ = f_ref(inputs_) - outputs_ = f_backward(inputs_) - - # flattened keras inputs/outputs - input_ref_flat_ = np.reshape(input_ref_, (input_ref_.shape[0], -1)) - output_ref_flat_ = np.reshape(output_ref_, (output_ref_.shape[0], -1)) - - # check bounds consistency - w_u_out_, b_u_out_, w_l_out_, b_l_out_ = outputs_ - upper_ = convert_to_numpy(batch_dot(w_u_out_, input_ref_flat_, axes=(-2, -1))) + b_u_out_ # batch mult - lower_ = convert_to_numpy(batch_dot(w_l_out_, input_ref_flat_, axes=(-2, -1))) + b_l_out_ # batch mult - np.testing.assert_almost_equal( - np.clip(lower_ - output_ref_flat_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_min >x_" - ) - np.testing.assert_almost_equal( - np.clip(output_ref_flat_ - upper_, 0, np.inf), 0.0, decimal=decimal, err_msg="x_max < x_" - ) diff --git a/tests/test_backward_layers.py b/tests/test_backward_layers.py deleted file mode 100644 index 98353986..00000000 --- a/tests/test_backward_layers.py +++ /dev/null @@ -1,184 +0,0 @@ -import keras.config as keras_config -import pytest -from keras.layers import Layer, Reshape - -from decomon.backward_layers.convert import to_backward -from decomon.core import ForwardMode, Slope -from decomon.layers.decomon_layers import DecomonActivation, DecomonFlatten -from decomon.layers.decomon_reshape import DecomonReshape - - -def test_Backward_Activation_1D_box_model(n, activation, mode, floatx, decimal, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # decomon layer - decomon_layer = DecomonActivation(activation, dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx()) - decomon_layer(inputs_for_mode) # build it - - # get backward layer - backward_layer = to_backward(decomon_layer) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_Activation_1D_box_model_slope(helpers): - n = 2 - activation = "relu" - mode = ForwardMode.AFFINE - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - #  decomon layer - decomon_layer = DecomonActivation(activation, dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx()) - decomon_layer(inputs_for_mode) # build it - - # to_backward with a given slope => outputs - outputs_by_slope = {} - for slope in Slope: - layer_backward = to_backward(decomon_layer, slope=slope, mode=mode) - assert layer_backward.slope == slope - outputs = layer_backward(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_by_slope[slope] = f_decomon(inputs_) - - # check results - # O_Slope != Z_Slope - same_outputs_O_n_Z = [ - (a == b).all() for a, b in zip(outputs_by_slope[Slope.O_SLOPE], outputs_by_slope[Slope.Z_SLOPE]) - ] - assert not all(same_outputs_O_n_Z) - - # V_Slope == Z_Slope - for a, b in zip(outputs_by_slope[Slope.V_SLOPE], outputs_by_slope[Slope.Z_SLOPE]): - assert (a == b).all() - - -def test_Backward_Activation_multiD_box(odd, activation, floatx, decimal, mode, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - #  decomon layer - decomon_layer = DecomonActivation(activation, dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx()) - decomon_layer(inputs_for_mode) # build it - - # get backward layer - backward_layer = to_backward(decomon_layer) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_Flatten_multiD_box(odd, floatx, decimal, mode, data_format, helpers): - if data_format == "channels_first" and not helpers.in_GPU_mode() and keras_config.backend() == "tensorflow": - pytest.skip("data format 'channels first' is possible only in GPU mode for tensorflow.") - - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - #  decomon layer - decomon_layer = DecomonFlatten(data_format, dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx()) - decomon_layer(inputs_for_mode) # build it - - # get backward layer - backward_layer = to_backward(decomon_layer) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_Reshape_multiD_box(odd, floatx, decimal, mode, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - #  decomon layer - decomon_layer = DecomonReshape((-1,), dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx()) - decomon_layer(inputs_for_mode) # build it - - # get backward layer - backward_layer = to_backward(decomon_layer) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_to_backward_ko(): - class MyLayer(Layer): - ... - - layer = MyLayer() - with pytest.raises(NotImplementedError): - to_backward(layer) - - -def test_name(): - layer = Reshape((1, 2)) - backward_layer = to_backward(layer) - backward_layer.name.startswith(f"{layer.name}_") diff --git a/tests/test_backward_layers_get_config.py b/tests/test_backward_layers_get_config.py deleted file mode 100644 index af7456bd..00000000 --- a/tests/test_backward_layers_get_config.py +++ /dev/null @@ -1,120 +0,0 @@ -import pytest -from keras.layers import Layer - -from decomon.backward_layers.backward_layers import ( - BackwardActivation, - BackwardBatchNormalization, - BackwardConv2D, - BackwardDense, - BackwardDropout, - BackwardFlatten, - BackwardInputLayer, - BackwardPermute, - BackwardReshape, -) -from decomon.backward_layers.backward_maxpooling import BackwardMaxPooling2D -from decomon.backward_layers.backward_merge import ( - BackwardAdd, - BackwardAverage, - BackwardConcatenate, - BackwardMaximum, - BackwardMinimum, - BackwardMultiply, - BackwardSubtract, -) -from decomon.layers.decomon_layers import ( - DecomonActivation, - DecomonBatchNormalization, - DecomonConv2D, - DecomonDense, - DecomonDropout, -) -from decomon.layers.decomon_merge_layers import ( - DecomonAdd, - DecomonAverage, - DecomonConcatenate, - DecomonMaximum, - DecomonMinimum, - DecomonMultiply, - DecomonSubtract, -) -from decomon.layers.decomon_reshape import DecomonPermute, DecomonReshape -from decomon.layers.maxpooling import DecomonMaxPooling2D - - -def test_backward_layers(): - activation = "linear" - sublayer = DecomonActivation(activation) - layer = BackwardActivation(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - units = 2 - sublayer = DecomonDense(units=units, use_bias=False) - layer = BackwardDense(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - filters = 2 - kernel_size = 3, 3 - sublayer = DecomonConv2D(filters=filters, kernel_size=kernel_size) - layer = BackwardConv2D(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - rate = 0.9 - sublayer = DecomonDropout(rate=rate) - layer = BackwardDropout(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - dims = (1, 2, 3) - sublayer = DecomonPermute(dims=dims) - layer = BackwardPermute(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - shape = (1, 2, 3) - sublayer = DecomonReshape(target_shape=shape) - layer = BackwardReshape(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - sublayer = DecomonBatchNormalization() - layer = BackwardBatchNormalization(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - sublayer = Layer() - for cls in [BackwardFlatten, BackwardInputLayer]: - layer = cls(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - -backward2decomon = { - BackwardAdd: DecomonAdd, - BackwardAverage: DecomonAverage, - BackwardConcatenate: DecomonConcatenate, - # BackwardDot: DecomonDot, # not yet implemented - BackwardMaximum: DecomonMaximum, - BackwardMinimum: DecomonMinimum, - BackwardMultiply: DecomonMultiply, - BackwardSubtract: DecomonSubtract, -} - - -def test_backward_merge(): - for backwardlayer_cls, decomonlayer_cls in backward2decomon.items(): - sublayer = decomonlayer_cls() - layer = backwardlayer_cls(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ - - -@pytest.mark.skip("Not yet implemented.") -def test_backward_maxpooling(): - sublayer = DecomonMaxPooling2D() - layer = BackwardMaxPooling2D(layer=sublayer) - config = layer.get_config() - assert config["layer"]["class_name"] == sublayer.__class__.__name__ diff --git a/tests/test_backward_native_layers.py b/tests/test_backward_native_layers.py deleted file mode 100644 index 1ad25053..00000000 --- a/tests/test_backward_native_layers.py +++ /dev/null @@ -1,132 +0,0 @@ -import keras.config as keras_config -import pytest -from keras.layers import Activation, Flatten, Reshape - -from decomon.backward_layers.convert import to_backward - - -def test_Backward_NativeActivation_1D_box_model(n, activation, mode, floatx, decimal, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # keras layer - keras_layer = Activation(activation, dtype=keras_config.floatx()) - keras_layer(input_ref) # build it - - # get backward layer - backward_layer = to_backward(keras_layer, mode=mode) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_NativeActivation_multiD_box(odd, activation, floatx, decimal, mode, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # keras layer - keras_layer = Activation(activation, dtype=keras_config.floatx()) - keras_layer(input_ref) # build it - - # get backward layer - backward_layer = to_backward(keras_layer, mode=mode) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_NativeFlatten_multiD_box(odd, floatx, decimal, mode, data_format, helpers): - if data_format == "channels_first" and not helpers.in_GPU_mode() and keras_config.backend() == "tensorflow": - pytest.skip("data format 'channels first' is possible only in GPU mode for tensorflow.") - - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # keras layer - keras_layer = Flatten(data_format, dtype=keras_config.floatx()) - keras_layer(input_ref) # build it - - # get backward layer - backward_layer = to_backward(keras_layer, mode=mode) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) - - -def test_Backward_NativeReshape_multiD_box(odd, floatx, decimal, mode, helpers): - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # keras layer - keras_layer = Reshape((-1,), dtype=keras_config.floatx()) - keras_layer(input_ref) # build it - - # get backward layer - backward_layer = to_backward(keras_layer, mode=mode) - - # backward outputs - outputs = backward_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_, - backward_outputs=outputs_, - decimal=decimal, - ) diff --git a/tests/test_backward_utils.py b/tests/test_backward_utils.py deleted file mode 100644 index 97cf7b07..00000000 --- a/tests/test_backward_utils.py +++ /dev/null @@ -1,91 +0,0 @@ -# Test unit for decomon with Dense layers - -import keras.config as keras_config -import numpy as np -import pytest -from keras.layers import Input - -from decomon.backward_layers.utils import ( - backward_add, - backward_maximum, - backward_subtract, -) - - -def add_op(x, y): - return x + y - - -def subtract_op(x, y): - return x - y - - -@pytest.mark.parametrize( - "n_0, n_1", - [ - (0, 3), - (1, 4), - (2, 5), - ], -) -@pytest.mark.parametrize( - "backward_func, tensor_op", - [ - (backward_add, add_op), - (backward_maximum, np.maximum), - (backward_subtract, subtract_op), - ], -) -def test_reduce_backward_1D_box(n_0, n_1, backward_func, tensor_op, mode, floatx, decimal, helpers): - dc_decomp = False - - #  tensor inputs - inputs_0 = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_1 = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_0_ = helpers.get_standard_values_1d_box(n_0, dc_decomp=dc_decomp) - inputs_1_ = helpers.get_standard_values_1d_box(n_1, dc_decomp=dc_decomp) - input_ref_0_ = helpers.get_input_ref_from_full_inputs(inputs=inputs_0_) - batchsize = input_ref_0_.shape[0] - - x_0, y_0, z_0, u_c_0, W_u_0, b_u_0, l_c_0, W_l_0, b_l_0 = inputs_0_ - x_1, y_1, z_1, u_c_1, W_u_1, b_u_1, l_c_1, W_l_1, b_l_1 = inputs_1_ - - # backward outputs - w_out = Input((1, 1), dtype=keras_config.floatx()) - b_out = Input((1,), dtype=keras_config.floatx()) - back_bounds_0, back_bounds_1 = backward_func( - inputs_for_mode_0, inputs_for_mode_1, w_out, b_out, w_out, b_out, mode=mode - ) - f_add = helpers.function(inputs_0 + inputs_1 + [w_out, b_out], back_bounds_0 + back_bounds_1) - outputs_ = f_add(inputs_0_ + inputs_1_ + [np.ones((batchsize, 1, 1)), np.zeros((batchsize, 1))]) - # get back separate outputs - outputs_0_ = outputs_[: len(outputs_) // 2] - outputs_1_ = outputs_[len(outputs_) // 2 :] - - # reference output and constant lower and upper bounds - output_ref = tensor_op(y_0, y_1) - upper_constant_bound = tensor_op(u_c_0, u_c_1) - lower_constant_bound = tensor_op(l_c_0, l_c_1) - - #  check bounds consistency - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_0_, - backward_outputs=outputs_0_, - decimal=decimal, - output_ref=output_ref, - upper_constant_bound=upper_constant_bound, - lower_constant_bound=lower_constant_bound, - ) - - helpers.assert_backward_layer_output_properties_box_linear( - full_inputs=inputs_1_, - backward_outputs=outputs_1_, - decimal=decimal, - output_ref=output_ref, - upper_constant_bound=upper_constant_bound, - lower_constant_bound=lower_constant_bound, - ) diff --git a/tests/test_clone.py b/tests/test_clone.py index 0a4d355b..b46415c5 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -1,25 +1,19 @@ -# creating toy network and assess that the decomposition is correct - - -import keras.config as keras_config import keras.ops as K import numpy as np import pytest -from keras.layers import Activation, Dense, Flatten, Input, Reshape -from keras.models import Model, Sequential - -from decomon.core import ForwardMode, Slope, get_affine, get_ibp, get_mode -from decomon.layers.decomon_reshape import DecomonReshape -from decomon.models import clone -from decomon.models.convert import FeedDirection, get_direction -from decomon.models.utils import ( - ConvertMethod, - get_ibp_affine_from_method, - has_merge_layers, -) +from keras.layers import Activation, Dense, Input +from keras.models import Model +from pytest_cases import parametrize +from decomon.constants import ConvertMethod, Propagation, Slope +from decomon.layers import DecomonDense, DecomonLayer +from decomon.layers.input import IdentityInput +from decomon.layers.utils.symbolify import LinkToPerturbationDomainInput +from decomon.models.convert import clone +from decomon.perturbation_domain import BoxDomain -def test_convert_nok_several_inputs(): + +def test_clone_nok_several_inputs(): a = Input((1,)) b = Input((2,)) model = Model([a, b], a) @@ -28,260 +22,586 @@ def test_convert_nok_several_inputs(): clone(model) -def test_convert_nok_unflattened_input(): - a = Input((1, 2)) - model = Model(a, a) - - with pytest.raises(ValueError, match="flattened input"): - clone(model) - +@parametrize( + "toy_model_name", + [ + "tutorial", + "tutorial_linear", + "tutorial_activation_embedded", + "add", + "add_linear", + "merge_v0", + "merge_v1", + "merge_v1_seq", + "merge_v2", + # "cnn", # DecomonConv2D not yet implemented + "embedded_model_v1", + "embedded_model_v2", + ], +) +def test_clone( + toy_model_name, + toy_model_fn, + method, + perturbation_domain, + model_keras_symbolic_input, + model_keras_input, + model_decomon_input, + model_decomon_input_metadata, + helpers, +): + # input shape? + input_shape = model_keras_symbolic_input.shape[1:] + + # skip cnn on 0d or 1d input_shape + if toy_model_name == "cnn" and len(input_shape) == 1: + pytest.skip("cnn not possible on 0d or 1d input.") + + slope = Slope.Z_SLOPE + decimal = 4 -def test_clone_with_backbounds(method, helpers): - dc_decomp = False - n = 0 - ibp, affine = get_ibp_affine_from_method(method) - mode = get_mode(ibp, affine) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - input_decomon_wo_backbounds = helpers.get_inputs_np_for_decomon_model_from_full_inputs(inputs=inputs_) - - #  keras model and output of reference - ref_nn = helpers.toy_network_tutorial() - output_ref_ = helpers.predict_on_small_numpy(ref_nn, input_ref_) - - # create back_bounds for adversarial robustness like studies - output_dim = int(np.prod(ref_nn.output_shape[1:])) - C = Input((output_dim, output_dim)) - batchsize = input_decomon_wo_backbounds.shape[0] - C_ = np.repeat(np.identity(output_dim), repeats=batchsize, axis=0) - inputs_decomon_ = [input_decomon_wo_backbounds, C_] - - # decomon conversion - decomon_model = clone(ref_nn, method=method, final_ibp=ibp, final_affine=affine, back_bounds=[C]) - - #  decomon outputs - outputs_ = helpers.predict_on_small_numpy(decomon_model, inputs_decomon_) - - #  check bounds consistency - helpers.assert_decomon_model_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, + # keras model to convert + if toy_model_name == "cnn": + kwargs_toy_model = dict(data_format=model_decomon_input_metadata["data_format"]) + else: + kwargs_toy_model = {} + keras_model = toy_model_fn(input_shape=input_shape, **kwargs_toy_model) + + # conversion + decomon_model = clone(model=keras_model, slope=slope, perturbation_domain=perturbation_domain, method=method) + + # call on actual outputs + keras_output = keras_model(model_keras_input) + decomon_output = decomon_model(model_decomon_input) + + ibp = decomon_model.ibp + affine = decomon_model.affine + + if method in (ConvertMethod.FORWARD_IBP, ConvertMethod.FORWARD_HYBRID): + assert ibp + else: + assert not ibp + + if method == ConvertMethod.FORWARD_IBP: + assert not affine + else: + assert affine + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output, + keras_input=model_keras_input, + keras_output=keras_output, + decimal=decimal, + ibp=ibp, + affine=affine, ) + # check that we added a layer to insert batch axis + if toy_model_name.endswith("_linear") and str(method).lower().startswith("crown"): + assert isinstance(decomon_model.layers[-1], LinkToPerturbationDomainInput) + else: + assert not isinstance(decomon_model.layers[-1], LinkToPerturbationDomainInput) -def test_convert_1D(n, method, mode, floatx, decimal, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") - dc_decomp = False - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - input_decomon_ = helpers.get_inputs_np_for_decomon_model_from_full_inputs(inputs=inputs_) +@parametrize( + "toy_model_name", + [ + "tutorial", + ], +) +def test_clone_final_mode( + toy_model_name, + toy_model_fn, + method, + final_ibp, + final_affine, + perturbation_domain, + simple_model_keras_symbolic_input, + simple_model_keras_input, + simple_model_decomon_input, + helpers, +): + # input shape? + input_shape = simple_model_keras_symbolic_input.shape[1:] + + # skip cnn on 0d or 1d input_shape + if toy_model_name == "cnn" and len(input_shape) == 1: + pytest.skip("cnn not possible on 0d or 1d input.") + + slope = Slope.Z_SLOPE + decimal = 4 - #  keras model and output of reference - ref_nn = helpers.toy_network_tutorial(dtype=keras_config.floatx()) - output_ref_ = helpers.predict_on_small_numpy(ref_nn, input_ref_) + # keras model to convert + keras_model = toy_model_fn(input_shape=input_shape) + + # conversion + decomon_model = clone( + model=keras_model, + slope=slope, + perturbation_domain=perturbation_domain, + method=method, + final_ibp=final_ibp, + final_affine=final_affine, + ) - # decomon conversion - decomon_model = clone(ref_nn, method=method, final_ibp=ibp, final_affine=affine) + # call on actual outputs + keras_output = keras_model(simple_model_keras_input) + decomon_output = decomon_model(simple_model_decomon_input) - #  decomon outputs - outputs_ = helpers.predict_on_small_numpy(decomon_model, input_decomon_) + assert final_ibp == decomon_model.ibp + assert final_affine == decomon_model.affine - #  check bounds consistency - helpers.assert_decomon_model_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output, + keras_input=simple_model_keras_input, + keras_output=keras_output, decimal=decimal, + ibp=final_ibp, + affine=final_affine, ) -def test_convert_1D_forward_slope(slope, helpers): - ibp = True - affine = True - dc_decomp = False +@parametrize( + "toy_model_name", + [ + "tutorial", + ], +) +@parametrize("equal_ibp, input_shape", [(False, (5, 6, 2))], ids=["multid"]) # fix some parameters of inputs +def test_clone_w_backwardbounds( + toy_model_name, + toy_model_fn, + method, + perturbation_domain, + equal_ibp, + input_shape, + simple_model_keras_symbolic_input, + simple_model_keras_input, + simple_model_decomon_input, + helpers, +): + # input shape? + input_shape = simple_model_keras_symbolic_input.shape[1:] + + slope = Slope.Z_SLOPE + decimal = 4 + + # keras model to convert: chaining 2 models + keras_model_1 = toy_model_fn(input_shape=input_shape) + output_shape_1 = keras_model_1.outputs[0].shape # only 1 output - n, method, mode = 0, "forward-hybrid", "hybrid" - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) + keras_model_2 = toy_model_fn(input_shape=output_shape_1[1:]) - ref_nn = helpers.toy_network_tutorial(dtype=keras_config.floatx()) - ref_nn(input_ref) + input_tot = keras_model_1.inputs[0] + output_tot = keras_model_2(keras_model_1(input_tot)) + keras_model_tot = Model(input_tot, output_tot) - decomon_model = clone(ref_nn, method=method, final_ibp=ibp, final_affine=affine, slope=slope) + # perturbation domain for 2nd model: computed by foward conversion of first model + forward_model_1 = clone( + model=keras_model_1, + slope=slope, + perturbation_domain=perturbation_domain, + method=ConvertMethod.FORWARD_HYBRID, + ) + decomon_input_1 = simple_model_decomon_input + decomon_output_1 = forward_model_1(decomon_input_1) + _, _, _, _, lower_ibp, upper_ibp = decomon_output_1 + decomon_input_2 = K.concatenate([lower_ibp[:, None], upper_ibp[:, None]], axis=1) + + # backward_bounds: crown on 2nd model + crown_model_2 = clone( + model=keras_model_2, + slope=slope, + perturbation_domain=BoxDomain(), + method=ConvertMethod.CROWN, + ) + symbolic_backward_bounds = crown_model_2.outputs + backward_bounds = crown_model_2(decomon_input_2) + + # conversion of first model with backward_bounds + decomon_model = clone( + model=keras_model_1, + slope=slope, + perturbation_domain=perturbation_domain, + method=method, + backward_bounds=symbolic_backward_bounds, + ) - # check slope of activation layers - for layer in decomon_model.layers: - if layer.__class__.__name__.endswith("Activation"): - assert layer.slope == Slope(slope) + # call on actual outputs + keras_output = keras_model_tot(simple_model_keras_input) + decomon_output = decomon_model([simple_model_decomon_input] + backward_bounds) + + # check output mode + ibp = decomon_model.ibp + affine = decomon_model.affine + + if method in (ConvertMethod.FORWARD_IBP, ConvertMethod.FORWARD_HYBRID): + assert ibp + else: + assert not ibp + + if method == ConvertMethod.FORWARD_IBP: + assert not affine + else: + assert affine + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output, + keras_input=simple_model_keras_input, + keras_output=keras_output, + decimal=decimal, + ibp=ibp, + affine=affine, + ) -def test_convert_1D_backward_slope(slope, helpers): - n, method, mode = 0, "crown-forward-hybrid", "hybrid" - ibp = True - affine = True - dc_decomp = False +def test_clone_2outputs( + method, + final_ibp, + final_affine, + perturbation_domain, + simple_model_keras_symbolic_input, + simple_model_keras_input, + simple_model_decomon_input, + helpers, +): + # input shape? + input_shape = simple_model_keras_symbolic_input.shape[1:] + + slope = Slope.Z_SLOPE + decimal = 4 - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) + # keras model to convert + keras_model = helpers.toy_network_2outputs(input_shape=input_shape) + + # conversion + decomon_model = clone( + model=keras_model, + slope=slope, + perturbation_domain=perturbation_domain, + method=method, + final_ibp=final_ibp, + final_affine=final_affine, + ) - ref_nn = helpers.toy_network_tutorial(dtype=keras_config.floatx()) - ref_nn(input_ref) + assert final_ibp == decomon_model.ibp + assert final_affine == decomon_model.affine + + nb_decomon_output_tensor_per_keras_output = 0 + if decomon_model.ibp: + nb_decomon_output_tensor_per_keras_output += 2 + if decomon_model.affine: + nb_decomon_output_tensor_per_keras_output += 4 + assert len(decomon_model.outputs) == len(keras_model.outputs) * nb_decomon_output_tensor_per_keras_output + + # call on actual inputs + keras_output = keras_model(simple_model_keras_input) + decomon_output = decomon_model(simple_model_decomon_input) + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + for i in range(len(keras_model.outputs)): + keras_output_i = keras_output[i] + decomon_output_i = decomon_output[ + i * nb_decomon_output_tensor_per_keras_output : (i + 1) * nb_decomon_output_tensor_per_keras_output + ] + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output_i, + keras_input=simple_model_keras_input, + keras_output=keras_output_i, + decimal=decimal, + ibp=final_ibp, + affine=final_affine, + ) + + +@parametrize( + "toy_model_name", + [ + "tutorial", + ], +) +@parametrize("equal_ibp, input_shape", [(False, (5, 6, 2))], ids=["multid"]) # fix some parameters of inputs +def test_clone_w_backwardbounds_2outputs( + toy_model_name, + toy_model_fn, + method, + perturbation_domain, + equal_ibp, + input_shape, + simple_model_keras_symbolic_input, + simple_model_keras_input, + simple_model_decomon_input, + helpers, +): + # input shape? + input_shape = simple_model_keras_symbolic_input.shape[1:] + + slope = Slope.Z_SLOPE + decimal = 4 - decomon_model = clone(ref_nn, method=method, final_ibp=ibp, final_affine=affine, slope=slope) + # keras model to convert: chaining 2 models (second one can be different for each output of the first one) + keras_model_1 = helpers.toy_network_2outputs(input_shape=input_shape) + keras_models_2 = [toy_model_fn(input_shape=output.shape[1:]) for output in keras_model_1.outputs] + + input_tot = keras_model_1.inputs[0] + outputs_tmp = keras_model_1(input_tot) + output_tot = [keras_models_2[i](outputs_tmp[i]) for i in range(len(outputs_tmp))] + keras_model_tot = Model(input_tot, output_tot) + + # perturbation domain for 2nd model: computed by forward conversion of first model + forward_model_1 = clone( + model=keras_model_1, + slope=slope, + perturbation_domain=perturbation_domain, + method=ConvertMethod.FORWARD_HYBRID, + ) + decomon_input_1 = simple_model_decomon_input + decomon_output_1 = forward_model_1(decomon_input_1) + _, _, _, _, lower_ibp_1, upper_ibp_1, _, _, _, _, lower_ibp_2, upper_ibp_2 = decomon_output_1 + decomon_inputs_2 = [ + K.concatenate([lower_ibp_1[:, None], upper_ibp_1[:, None]], axis=1), + K.concatenate([lower_ibp_2[:, None], upper_ibp_2[:, None]], axis=1), + ] + + # backward_bounds: crown on 2nd model + crown_models_2 = [ + clone( + model=keras_model_2, + slope=slope, + perturbation_domain=BoxDomain(), + method=ConvertMethod.CROWN_FORWARD_IBP, + ) + for keras_model_2 in keras_models_2 + ] + symbolic_backward_bounds_flattened = [t for crown_model_2 in crown_models_2 for t in crown_model_2.outputs] + backward_bounds_flattened = [ + t + for crown_model_2, decomon_input_2 in zip(crown_models_2, decomon_inputs_2) + for t in crown_model_2(decomon_input_2) + ] + + # conversion of first model with backward_bounds + decomon_model = clone( + model=keras_model_1, + slope=slope, + perturbation_domain=perturbation_domain, + method=method, + backward_bounds=symbolic_backward_bounds_flattened, + ) - # check slope of layers with activation - for layer in decomon_model.layers: - layer_class_name = layer.__class__.__name__ - if layer_class_name.endswith("Activation"): - assert layer.slope == Slope(slope) + # check output mode + ibp = decomon_model.ibp + affine = decomon_model.affine + + if method in (ConvertMethod.FORWARD_IBP, ConvertMethod.FORWARD_HYBRID): + assert ibp + else: + assert not ibp + + if method == ConvertMethod.FORWARD_IBP: + assert not affine + else: + assert affine + + # check number of outputs + nb_decomon_output_tensor_per_keras_output = 0 + if decomon_model.ibp: + nb_decomon_output_tensor_per_keras_output += 2 + if decomon_model.affine: + nb_decomon_output_tensor_per_keras_output += 4 + assert len(decomon_model.outputs) == len(keras_model_1.outputs) * nb_decomon_output_tensor_per_keras_output + + # call on actual inputs + keras_output = keras_model_tot(simple_model_keras_input) + decomon_output = decomon_model([simple_model_decomon_input] + backward_bounds_flattened) + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + for i in range(len(keras_model_1.outputs)): + keras_output_i = keras_output[i] + decomon_output_i = decomon_output[ + i * nb_decomon_output_tensor_per_keras_output : (i + 1) * nb_decomon_output_tensor_per_keras_output + ] + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output_i, + keras_input=simple_model_keras_input, + keras_output=keras_output_i, + decimal=decimal, + ibp=ibp, + affine=affine, + ) + + +@parametrize("equal_ibp, input_shape", [(False, (5,))], ids=["1d"]) # fix some parameters of inputs +def test_clone_2outputs_with_backwardbounds_for_adv_box( + method, + final_ibp, + final_affine, + perturbation_domain, + equal_ibp, + input_shape, + simple_model_keras_symbolic_input, + simple_model_keras_input, + simple_model_decomon_input, + helpers, +): + # input shape? + input_shape = simple_model_keras_symbolic_input.shape[1:] + + slope = Slope.Z_SLOPE + decimal = 4 + # keras model to convert + keras_model = helpers.toy_network_2outputs(input_shape=input_shape, same_output_shape=True) + output_shape = keras_model.outputs[0].shape[1:] + output_dim = int(np.prod(output_shape)) -def test_name_forward(): - layers = [] - layers.append(Input((1,))) - layers.append(Dense(1)) - layers.append(Dense(1, name="superman")) # specify the dimension of the input space - layers.append(Activation("relu")) - layers.append(Dense(1, activation="relu", name="batman")) - model = Sequential(layers) + # create C for adversarial robustnedd + symbolic_C = Input(output_shape + output_shape) + batchsize = simple_model_decomon_input.shape[0] + C = K.reshape( + K.eye(output_dim)[None] - K.eye(batchsize, output_dim)[:, :, None], (-1,) + output_shape + output_shape + ) - decomon_model_f = clone(model=model, method=ConvertMethod.FORWARD_HYBRID) - nb_superman_layers = len([layer for layer in decomon_model_f.layers if layer.name.startswith("superman_")]) - assert nb_superman_layers == 1 - nb_batman_layers = len([layer for layer in decomon_model_f.layers if layer.name.startswith("batman_")]) - assert nb_batman_layers == 2 + # conversion + decomon_model = clone( + model=keras_model, + slope=slope, + perturbation_domain=perturbation_domain, + method=method, + final_ibp=final_ibp, + final_affine=final_affine, + backward_bounds=symbolic_C, + ) + assert final_ibp == decomon_model.ibp + assert final_affine == decomon_model.affine -def test_name_backward(): - layers = [] - layers.append(Input((1,))) - layers.append(Dense(1)) - layers.append(Dense(1, name="superman")) # specify the dimension of the input space - layers.append(Activation("relu")) - layers.append(Dense(1, activation="relu", name="batman")) - model = Sequential(layers) + nb_decomon_output_tensor_per_keras_output = 0 + if decomon_model.ibp: + nb_decomon_output_tensor_per_keras_output += 2 + if decomon_model.affine: + nb_decomon_output_tensor_per_keras_output += 4 + assert len(decomon_model.outputs) == len(keras_model.outputs) * nb_decomon_output_tensor_per_keras_output - decomon_model_b = clone(model=model, method=ConvertMethod.CROWN_FORWARD_HYBRID) - nb_superman_layers = len([layer for layer in decomon_model_b.layers if layer.name.startswith("superman_")]) - assert nb_superman_layers == 2 - nb_batman_layers = len([layer for layer in decomon_model_b.layers if layer.name.startswith("batman_")]) - assert nb_batman_layers == 3 + # call on actual inputs + keras_output = keras_model(simple_model_keras_input) + decomon_output = decomon_model([simple_model_decomon_input, C]) + # todo: check to perform on bounds? -def test_convert_toy_models_1d(toy_model_1d, method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") +def test_clone_identity_model( + method, + perturbation_domain, + model_keras_symbolic_input, + model_keras_input, + model_decomon_input, + helpers, +): + slope = Slope.Z_SLOPE decimal = 4 - n = 6 - dc_decomp = False - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - input_decomon_ = helpers.get_inputs_np_for_decomon_model_from_full_inputs(inputs=inputs_) - - #  keras model and output of reference - ref_nn = toy_model_1d - output_ref_ = helpers.predict_on_small_numpy(ref_nn, input_ref_) - - # decomon conversion - if (get_direction(method) == FeedDirection.BACKWARD) and has_merge_layers(ref_nn): - # skip models with merge layers in backward direction as not yet implemented - with pytest.raises(NotImplementedError): - decomon_model = clone(ref_nn, method=method, final_ibp=ibp, final_affine=affine) - return - - decomon_model = clone(ref_nn, method=method, final_ibp=ibp, final_affine=affine) - - #  decomon outputs - outputs_ = helpers.predict_on_small_numpy(decomon_model, input_decomon_) - - #  check bounds consistency - helpers.assert_decomon_model_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) + # identity model + output_tensor = Activation(activation=None)(model_keras_symbolic_input) + keras_model = Model(model_keras_symbolic_input, output_tensor) -def test_convert_cnn(method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") + # conversion + decomon_model = clone(model=keras_model, slope=slope, perturbation_domain=perturbation_domain, method=method) - if get_direction(method) == FeedDirection.BACKWARD: - # skip as BackwardConv2D not yet ready - pytest.skip(f"BackwardConv2D not yet fully implemented") + # call on actual outputs + keras_output = keras_model(model_keras_input) + decomon_output = decomon_model(model_decomon_input) - decimal = 4 - data_format = "channels_last" - odd, m_0, m_1 = 0, 0, 1 + ibp = decomon_model.ibp + affine = decomon_model.affine - dc_decomp = False - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) + if method in (ConvertMethod.FORWARD_IBP, ConvertMethod.FORWARD_HYBRID): + assert ibp + else: + assert not ibp - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - input_ref_min_, input_ref_max_ = helpers.get_input_ref_bounds_from_full_inputs(inputs_) + if method == ConvertMethod.FORWARD_IBP: + assert not affine + else: + assert affine - # flatten inputs - preprocess_layer = Flatten(data_format=data_format) - input_ref_reshaped_ = K.convert_to_numpy(preprocess_layer(input_ref_)) - input_ref_min_reshaped_ = K.convert_to_numpy(preprocess_layer(input_ref_min_)) - input_ref_max_reshaped_ = K.convert_to_numpy(preprocess_layer(input_ref_max_)) + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output, + keras_input=model_keras_input, + keras_output=keras_output, + decimal=decimal, + ibp=ibp, + affine=affine, + ) - input_decomon_ = np.concatenate((input_ref_min_reshaped_[:, None], input_ref_max_reshaped_[:, None]), axis=1) + # check exact bounds + if affine: + # identity + w_l, b_l, w_u, b_u = decomon_output[:4] + helpers.assert_almost_equal(b_l, 0.0) + helpers.assert_almost_equal(b_u, 0.0) + helpers.assert_almost_equal(w_l, 1.0) + helpers.assert_almost_equal(w_u, 1.0) + if ibp: + # perturbation domain bounds + lower, upper = decomon_output[-2:] + helpers.assert_almost_equal(lower, perturbation_domain.get_lower_x(model_decomon_input)) + helpers.assert_almost_equal(upper, perturbation_domain.get_upper_x(model_decomon_input)) + + # check that we added a layer to insert batch axis + if method.lower().startswith("crown"): + assert isinstance(decomon_model.layers[-1], IdentityInput) + + +class MyDenseDecomonLayer(DecomonLayer): + def __init__( + self, + layer, + perturbation_domain=None, + ibp: bool = True, + affine: bool = True, + propagation=Propagation.FORWARD, + model_input_shape=None, + model_output_shape=None, + my_super_attribute=0.0, + **kwargs, + ): + super().__init__( + layer, perturbation_domain, ibp, affine, propagation, model_input_shape, model_output_shape, **kwargs + ) + self.my_super_attribute = my_super_attribute + + +def test_clone_custom_layer( + method, + perturbation_domain, + helpers, +): + decimal = 4 - #  keras model and output of reference - image_data_shape = input_ref_.shape[1:] # image shape: before flattening - ref_nn = helpers.toy_struct_cnn(dtype=keras_config.floatx(), image_data_shape=image_data_shape) - output_ref_ = helpers.predict_on_small_numpy(ref_nn, input_ref_reshaped_) + input_shape = (5,) - # decomon conversion - decomon_model = clone(ref_nn, method=method, final_ibp=ibp, final_affine=affine) + mapping_keras2decomon_classes = {Dense: MyDenseDecomonLayer} - #  decomon outputs - outputs_ = helpers.predict_on_small_numpy(decomon_model, input_decomon_) + # keras model + keras_model = helpers.toy_network_tutorial(input_shape=input_shape) - #  check bounds consistency - z_, u_c_, w_u_, b_u_, l_c_, w_l_, b_l_, h_, g_ = helpers.get_full_outputs_from_outputs_for_mode( - outputs_for_mode=outputs_, mode=mode, dc_decomp=dc_decomp, full_inputs=inputs_ - ) - helpers.assert_output_properties_box( - input_ref_reshaped_, - output_ref_, - h_, - g_, - input_ref_min_reshaped_, - input_ref_max_reshaped_, - u_c_, - w_u_, - b_u_, - l_c_, - w_l_, - b_l_, - decimal=decimal, + # conversion + decomon_model = clone( + model=keras_model, + perturbation_domain=perturbation_domain, + method=method, + mapping_keras2decomon_classes=mapping_keras2decomon_classes, + my_super_attribute=12.5, ) + + # check layers + assert any([isinstance(l, MyDenseDecomonLayer) for l in decomon_model.layers]) + assert not any([isinstance(l, DecomonDense) for l in decomon_model.layers]) + for l in decomon_model.layers: + if isinstance(l, MyDenseDecomonLayer): + assert l.my_super_attribute == 12.5 diff --git a/tests/test_clone_backward.py b/tests/test_clone_backward.py deleted file mode 100644 index 2202115e..00000000 --- a/tests/test_clone_backward.py +++ /dev/null @@ -1,68 +0,0 @@ -# creating toy network and assess that the decomposition is correct - - -import keras.config as keras_config - -from decomon.core import get_affine, get_ibp -from decomon.models.backward_cloning import convert_backward -from decomon.models.forward_cloning import convert_forward -from decomon.models.utils import ensure_functional_model - - -def test_convert_backward_1D(n, mode, floatx, decimal, helpers): - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - input_tensors = helpers.get_input_tensors_for_decomon_convert_from_full_inputs( - inputs=inputs, mode=mode, dc_decomp=dc_decomp - ) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - inputs_ = helpers.prepare_full_np_inputs_for_convert_model(inputs_, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - - # keras model and output of reference - ref_nn = helpers.toy_network_tutorial(dtype=keras_config.floatx()) - output_ref_ = helpers.predict_on_small_numpy(ref_nn, input_ref_) - - # convert to functional - ref_nn = ensure_functional_model(ref_nn) - - # decomon conversion - back_bounds = [] - _, _, _, forward_map = convert_forward( - ref_nn, - ibp=ibp, - affine=affine, - shared=True, - input_tensors=input_tensors, - back_bounds=back_bounds, - ) - _, outputs, _, _ = convert_backward( - ref_nn, - input_tensors=input_tensors, - ibp=ibp, - affine=affine, - forward_map=forward_map, - final_ibp=ibp, - final_affine=affine, - back_bounds=back_bounds, - ) - - # decomon outputs - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_model_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) diff --git a/tests/test_clone_forward.py b/tests/test_clone_forward.py deleted file mode 100644 index b7a5ce79..00000000 --- a/tests/test_clone_forward.py +++ /dev/null @@ -1,45 +0,0 @@ -# creating toy network and assess that the decomposition is correct - - -import keras.config as keras_config - -from decomon.core import get_affine, get_ibp -from decomon.models.forward_cloning import convert_forward - - -def test_convert_forward_1D(n, mode, floatx, decimal, helpers): - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - dc_decomp = False - - #  tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - input_tensors = helpers.get_input_tensors_for_decomon_convert_from_full_inputs( - inputs=inputs, mode=mode, dc_decomp=dc_decomp - ) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - inputs_ = helpers.prepare_full_np_inputs_for_convert_model(inputs_, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - - # keras model and output of reference - ref_nn = helpers.toy_network_tutorial(dtype=keras_config.floatx()) - output_ref_ = helpers.predict_on_small_numpy(ref_nn, input_ref_) - - # decomon conversion - _, outputs, _, _ = convert_forward(ref_nn, ibp=ibp, affine=affine, shared=True, input_tensors=input_tensors) - - #  decomon outputs - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_model_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) diff --git a/tests/test_conv.py b/tests/test_conv.py deleted file mode 100644 index 6317dfb5..00000000 --- a/tests/test_conv.py +++ /dev/null @@ -1,126 +0,0 @@ -# Test unit for decomon with Dense layers - - -import keras.config as keras_config -import numpy as np -import pytest -from keras.layers import Conv2D - -from decomon.core import ForwardMode, get_affine, get_ibp -from decomon.layers.convert import to_decomon -from decomon.layers.decomon_layers import DecomonConv2D - - -def test_Decomon_conv_box(data_format, mode, dc_decomp, floatx, decimal, helpers): - # skip unavailable combinations - if floatx == 16 and keras_config.backend() == "torch" and not helpers.in_GPU_mode(): - pytest.skip("Pytorch does not implement conv2d for float16 in CPU mode.") - - if data_format == "channels_first" and not helpers.in_GPU_mode() and keras_config.backend() == "tensorflow": - pytest.skip("data format 'channels first' is possible only in GPU mode for tensorflow.") - - odd, m_0, m_1 = 0, 0, 1 - kwargs_layer = dict(filters=10, kernel_size=(3, 3), dtype=keras_config.floatx(), data_format=data_format) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - - # decomon layer - decomon_layer = DecomonConv2D(dc_decomp=dc_decomp, mode=mode, **kwargs_layer) - - # original output (not computed here) - output_ref_ = None - - # decomon function - outputs = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - - # test with several kernel (negative or positive part) - W_, bias = decomon_layer.get_weights() - for np_function in [np.maximum, np.minimum]: - decomon_layer.set_weights([np_function(0.0, W_), bias]) - outputs_ = f_decomon(inputs_) - # check bounds consistency - if dc_decomp: - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - else: - helpers.assert_decomon_layer_output_properties_box_linear( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - - -def test_Decomon_conv_to_decomon_box(shared, floatx, dc_decomp, helpers): - # skip unavailable combinations - if floatx == 16 and keras_config.backend() == "torch" and not helpers.in_GPU_mode(): - pytest.skip("Pytorch does not implement conv2d for float16 in CPU mode.") - - data_format = "channels_last" - odd, m_0, m_1 = 0, 0, 1 - dc_decomp = True - mode = ForwardMode.HYBRID - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - kwargs_layer = dict(filters=10, kernel_size=(3, 3), dtype=keras_config.floatx(), data_format=data_format) - - if floatx == 16: - decimal = 1 - else: - decimal = 4 - - # tensor inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - - # keras layer & function & output - keras_layer = Conv2D(**kwargs_layer) - output_ref = keras_layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - output_ref_ = f_ref(inputs_) - - #  decomon layer & function & output via to_decomon - input_dim = helpers.get_input_dim_from_full_inputs(inputs) - decomon_layer = to_decomon(keras_layer, input_dim, dc_decomp=dc_decomp, shared=shared, ibp=ibp, affine=affine) - outputs = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - if dc_decomp: - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - else: - helpers.assert_decomon_layer_output_properties_box_linear( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) diff --git a/tests/test_convert_backward.py b/tests/test_convert_backward.py new file mode 100644 index 00000000..0079d6b2 --- /dev/null +++ b/tests/test_convert_backward.py @@ -0,0 +1,100 @@ +from keras.models import Model +from pytest_cases import fixture, parametrize + +from decomon.constants import Propagation, Slope +from decomon.models.backward_cloning import convert_backward +from decomon.models.forward_cloning import convert_forward + + +@fixture +@parametrize("name", ["tutorial", "tutorial_linear", "submodel", "submodel_linear", "add", "add_linear", "add_1layer"]) +def keras_model_fn(name, helpers): + if name == "tutorial": + return helpers.toy_network_tutorial + elif name == "tutorial_linear": + return lambda input_shape, dtype=None: helpers.toy_network_tutorial( + input_shape=input_shape, dtype=dtype, activation=None + ) + elif name == "add": + return helpers.toy_network_add + elif name == "add_linear": + return lambda input_shape, dtype=None: helpers.toy_network_add( + input_shape=input_shape, dtype=dtype, activation=None + ) + elif name == "add_1layer": + return helpers.toy_network_add_monolayer + elif name == "submodel": + return helpers.toy_network_submodel + elif name == "submodel_linear": + return lambda input_shape, dtype=None: helpers.toy_network_submodel( + input_shape=input_shape, dtype=dtype, activation=None + ) + else: + raise ValueError() + + +def test_convert_backward( + ibp, + affine, + propagation, + perturbation_domain, + input_shape, + keras_model_fn, + simple_model_decomon_symbolic_input_fn, + simple_model_keras_input_fn, + simple_model_decomon_input_fn, + helpers, +): + slope = Slope.Z_SLOPE + decimal = 4 + + # keras model to convert + keras_model = keras_model_fn(input_shape=input_shape) + + # symbolic inputs + keras_symbolic_input = keras_model.inputs[0] + decomon_symbolic_input = simple_model_decomon_symbolic_input_fn(keras_symbolic_input) + + # actual inputs + keras_input = simple_model_keras_input_fn(keras_symbolic_input) + decomon_input = simple_model_decomon_input_fn(keras_input) + + # keras output + keras_output = keras_model(keras_input) + + # forward conversion for forward oracle + if propagation == Propagation.FORWARD: + _, forward_output_map, forward_layer_map = convert_forward( + keras_model, ibp=ibp, affine=affine, perturbation_domain_input=decomon_symbolic_input, slope=slope + ) + else: + forward_output_map, forward_layer_map = None, None + + # backward conversion + decomon_symbolic_output = convert_backward( + keras_model, + perturbation_domain_input=decomon_symbolic_input, + perturbation_domain=perturbation_domain, + slope=slope, + forward_output_map=forward_output_map, + forward_layer_map=forward_layer_map, + ) + + #  decomon outputs + if None in decomon_symbolic_output[0].shape: + decomon_model = Model(inputs=decomon_symbolic_input, outputs=decomon_symbolic_output) + decomon_output = decomon_model(decomon_input) + else: + # special case for pure linear keras model => bounds not depending on batch, already computed. + decomon_output = decomon_symbolic_output + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output, + keras_input=keras_input, + keras_output=keras_output, + propagation=Propagation.BACKWARD, + decimal=decimal, + ibp=ibp, + affine=affine, + ) diff --git a/tests/test_convert_forward.py b/tests/test_convert_forward.py new file mode 100644 index 00000000..358b33fe --- /dev/null +++ b/tests/test_convert_forward.py @@ -0,0 +1,110 @@ +import pytest +from keras.models import Model +from pytest_cases import fixture, parametrize + +from decomon.constants import Propagation, Slope +from decomon.layers.activations.activation import DecomonBaseActivation +from decomon.models.forward_cloning import convert_forward + + +@fixture +@parametrize("name", ["tutorial", "tutorial_linear", "submodel", "submodel_linear", "add", "add_linear", "add_1layer"]) +def keras_model_fn(name, helpers): + if name == "tutorial": + return helpers.toy_network_tutorial + elif name == "tutorial_linear": + return lambda input_shape, dtype=None: helpers.toy_network_tutorial( + input_shape=input_shape, dtype=dtype, activation=None + ) + elif name == "add": + return helpers.toy_network_add + elif name == "add_linear": + return lambda input_shape, dtype=None: helpers.toy_network_add( + input_shape=input_shape, dtype=dtype, activation=None + ) + elif name == "add_1layer": + return helpers.toy_network_add_monolayer + elif name == "submodel": + return helpers.toy_network_submodel + elif name == "submodel_linear": + return lambda input_shape, dtype=None: helpers.toy_network_submodel( + input_shape=input_shape, dtype=dtype, activation=None + ) + else: + raise ValueError() + + +def test_convert_forward( + ibp, + affine, + propagation, + perturbation_domain, + input_shape, + keras_model_fn, + simple_model_decomon_symbolic_input_fn, + simple_model_keras_input_fn, + simple_model_decomon_input_fn, + helpers, +): + if propagation == Propagation.BACKWARD: + pytest.skip("backward propagation meaningless for convert_forward()") + + slope = Slope.Z_SLOPE + decimal = 4 + + # keras model to convert + keras_model = keras_model_fn(input_shape=input_shape) + + # symbolic inputs + keras_symbolic_input = keras_model.inputs[0] + decomon_symbolic_input = simple_model_decomon_symbolic_input_fn(keras_symbolic_input) + + # actual inputs + keras_input = simple_model_keras_input_fn(keras_symbolic_input) + decomon_input = simple_model_decomon_input_fn(keras_input) + + # keras output + keras_output = keras_model(keras_input) + + # decomon conversion + decomon_symbolic_output, output_map, layer_map = convert_forward( + keras_model, + ibp=ibp, + affine=affine, + perturbation_domain_input=decomon_symbolic_input, + perturbation_domain=perturbation_domain, + slope=slope, + ) + + # check output map + def check_maps(output_map, layer_map, keras_model): + for layer in keras_model.layers: + for node in layer._inbound_nodes: + if isinstance(layer, Model): + # submodel, iterate on its layers + check_maps(output_map, layer_map, layer) + else: + assert id(node) in output_map + if len(node.parent_nodes) > 0: # not an input node + assert id(node) in layer_map + decomon_layer = layer_map[id(node)] + assert decomon_layer.layer is layer + if isinstance(decomon_layer, DecomonBaseActivation): + assert decomon_layer.slope == slope + + check_maps(output_map, layer_map, keras_model) + + #  decomon outputs + decomon_model = Model(inputs=decomon_symbolic_input, outputs=decomon_symbolic_output) + decomon_output = decomon_model(decomon_input) + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_model( + decomon_output=decomon_output, + keras_input=keras_input, + keras_output=keras_output, + ibp=ibp, + affine=affine, + propagation=propagation, + decimal=decimal, + ) diff --git a/tests/test_crown_layers_get_config.py b/tests/test_crown_layers_get_config.py deleted file mode 100644 index e5856e4f..00000000 --- a/tests/test_crown_layers_get_config.py +++ /dev/null @@ -1,23 +0,0 @@ -from decomon.core import BoxDomain, ForwardMode -from decomon.models.crown import Convert2BackwardMode, Fuse, MergeWithPrevious - - -def test_crown_layers(): - mode = ForwardMode.AFFINE - perturbation_domain = BoxDomain() - input_shape_layer = (1, 2, 4) - backward_shape_layer = (2, 5, 10) - - layer = Fuse(mode=mode) - config = layer.get_config() - assert config["mode"] == mode - - layer = Convert2BackwardMode(mode=mode, perturbation_domain=perturbation_domain) - config = layer.get_config() - assert config["mode"] == mode - assert "perturbation_domain" in config - - layer = MergeWithPrevious(input_shape_layer=input_shape_layer, backward_shape_layer=backward_shape_layer) - config = layer.get_config() - assert config["input_shape_layer"] == input_shape_layer - assert config["backward_shape_layer"] == backward_shape_layer diff --git a/tests/test_decomon_activation_layer.py b/tests/test_decomon_activation_layer.py deleted file mode 100644 index 7c2143b5..00000000 --- a/tests/test_decomon_activation_layer.py +++ /dev/null @@ -1,43 +0,0 @@ -from decomon.core import ForwardMode, Slope -from decomon.layers.decomon_layers import DecomonActivation - - -def test_decomon_activation_slope(helpers): - mode = ForwardMode.AFFINE - activation = "relu" - n = 2 - - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=False) - ( - x_0, - y_0, - z_0, - u_c_0, - W_u_0, - b_u_0, - l_c_0, - W_l_0, - b_l_0, - ) = inputs_ # numpy values - - outputs_by_slope = {} - for slope in Slope: - layer = DecomonActivation(activation, dc_decomp=False, mode=mode, slope=slope) - assert layer.slope == slope - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=False) - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs # tensors - inputs_for_mode = [z, W_u, b_u, W_l, b_l] - output = layer(inputs_for_mode) - f_func = helpers.function(inputs, output) - outputs_by_slope[slope] = f_func(inputs_) - - # check results - # O_Slope != Z_Slope - same_outputs_O_n_Z = [ - (a == b).all() for a, b in zip(outputs_by_slope[Slope.O_SLOPE], outputs_by_slope[Slope.Z_SLOPE]) - ] - assert not all(same_outputs_O_n_Z) - - # V_Slope == Z_Slope - for a, b in zip(outputs_by_slope[Slope.V_SLOPE], outputs_by_slope[Slope.Z_SLOPE]): - assert (a == b).all() diff --git a/tests/test_decomon_compute_output_shape.py b/tests/test_decomon_compute_output_shape.py deleted file mode 100644 index c8d0d4c8..00000000 --- a/tests/test_decomon_compute_output_shape.py +++ /dev/null @@ -1,105 +0,0 @@ -import pytest - -from decomon.layers.decomon_layers import ( - DecomonBatchNormalization, - DecomonConv2D, - DecomonDense, - DecomonFlatten, -) -from decomon.layers.decomon_merge_layers import DecomonAdd, DecomonConcatenate -from decomon.layers.decomon_reshape import DecomonReshape -from decomon.layers.maxpooling import DecomonMaxPooling2D - - -@pytest.mark.parametrize( - "layer_class, layer_kwargs", - [ - (DecomonDense, dict(units=3, use_bias=True)), - (DecomonFlatten, dict()), - (DecomonBatchNormalization, dict(center=True, scale=True)), - (DecomonBatchNormalization, dict(center=False, scale=False)), - (DecomonConv2D, dict(filters=10, kernel_size=(3, 3))), - (DecomonMaxPooling2D, dict(pool_size=(2, 2), strides=(2, 2), padding="valid")), - (DecomonReshape, dict(target_shape=(1, -1, 1))), - ], -) -@pytest.mark.parametrize("n", [0]) # limit 1d cases -@pytest.mark.parametrize("odd", [0]) # limit multid cases -@pytest.mark.parametrize("data_format", ["channels_last"]) # limit images cases -def test_compute_output_shape( - helpers, - mode, - dc_decomp, - layer_class, - layer_kwargs, - inputs_for_mode, # decomon inputs: symbolic tensors - input_ref, # keras input: symbolic tensor - inputs_for_mode_, # decomon inputs: numpy arrays - inputs_metadata, # inputs metadata: data_format, ... -): - # skip nonsensical combinations - if (layer_class == DecomonBatchNormalization or layer_class == DecomonMaxPooling2D) and dc_decomp: - pytest.skip(f"{layer_class} with dc_decomp=True not yet implemented.") - if (layer_class == DecomonConv2D or layer_class == DecomonMaxPooling2D) and len(input_ref.shape) < 4: - pytest.skip(f"{layer_class} applies only on image-like inputs.") - - # add data_format for convolution and maxpooling - if layer_class in (DecomonConv2D, DecomonMaxPooling2D): - layer_kwargs["data_format"] = inputs_metadata["data_format"] - - # construct layer - layer = layer_class(mode=mode, dc_decomp=dc_decomp, **layer_kwargs) - - # check symbolic tensor output shapes - inputshapes = [i.shape for i in inputs_for_mode] - outputshapes = layer.compute_output_shape(inputshapes) - outputs = layer(inputs_for_mode) - assert [o.shape for o in outputs] == outputshapes - - # check output shapes for concrete call - outputs_ = layer(inputs_for_mode_) - # compare without batch sizes - assert [tuple(o.shape)[1:] for o in outputs_] == [s[1:] for s in outputshapes] - - -@pytest.mark.parametrize( - "layer_class, layer_kwargs", - [ - (DecomonAdd, dict()), - (DecomonConcatenate, dict()), - ], -) -@pytest.mark.parametrize("n", [0]) # limit 1d cases -@pytest.mark.parametrize("odd", [0]) # limit multid cases -@pytest.mark.parametrize("data_format", ["channels_last"]) # limit images cases -def test_merge_layers_compute_output_shape( - helpers, - mode, - dc_decomp, - layer_class, - layer_kwargs, - inputs_for_mode, # decomon inputs: symbolic tensors - inputs_for_mode_, # decomon inputs: numpy arrays -): - if dc_decomp: - pytest.skip(f"{layer_class} with dc_decomp=True not yet implemented.") - - # tensors inputs - concatenated_inputs_for_mode = inputs_for_mode + inputs_for_mode - - # numpy inputs - concatenated_inputs_for_mode_ = inputs_for_mode_ + inputs_for_mode_ - - # construct layer - layer = layer_class(mode=mode, dc_decomp=dc_decomp, **layer_kwargs) - - # check symbolic tensor output shapes - inputshapes = [i.shape for i in concatenated_inputs_for_mode] - outputshapes = layer.compute_output_shape(inputshapes) - outputs = layer(concatenated_inputs_for_mode) - assert [o.shape for o in outputs] == outputshapes - - # check output shapes for concrete call - outputs_ = layer(concatenated_inputs_for_mode_) - # compare without batch sizes - assert [tuple(o.shape)[1:] for o in outputs_] == [s[1:] for s in outputshapes] diff --git a/tests/test_decomon_layer.py b/tests/test_decomon_layer.py new file mode 100644 index 00000000..f43191e4 --- /dev/null +++ b/tests/test_decomon_layer.py @@ -0,0 +1,330 @@ +import keras.ops as K +import pytest +from keras.layers import Dense, Input + +from decomon.constants import Propagation +from decomon.layers.layer import DecomonLayer +from decomon.perturbation_domain import BoxDomain + + +def test_decomon_layer_nok_unbuilt_keras_layer(): + layer = Dense(3) + with pytest.raises(ValueError): + DecomonLayer(layer=layer) + + +def test_decomon_layer_nok_ibp_affine(): + layer = Dense(3) + layer(Input((1,))) + with pytest.raises(ValueError): + DecomonLayer(layer=layer, ibp=False, affine=False) + + +def test_decomon_layer_nok_backward_no_model_output_shape(): + layer = Dense(3) + layer(Input((1,))) + with pytest.raises(ValueError): + DecomonLayer(layer=layer, propagation=Propagation.BACKWARD) + + +def test_decomon_layer_extra_kwargs(): + layer = Dense(3) + layer(Input((1,))) + DecomonLayer(layer=layer, alpha="jet") + + +class MyLinearDecomonDense1d(DecomonLayer): + linear = True + layer: Dense + + def get_affine_representation(self): + return self.layer.kernel, self.layer.bias + + +class MyNonLinearDecomonDense1d(DecomonLayer): + linear = False + layer: Dense + + def get_affine_bounds(self, lower, upper): + batchsize = lower.shape[0] + extended_kernel = K.repeat(self.layer.kernel[None], batchsize, axis=0) + extended_bias = K.repeat(self.layer.bias[None], batchsize, axis=0) + return extended_kernel, extended_bias, extended_kernel, extended_bias + + def forward_ibp_propagate(self, lower, upper): + z_value = K.cast(0.0, dtype=lower.dtype) + kernel_pos = K.maximum(z_value, self.layer.kernel) + kernel_neg = K.minimum(z_value, self.layer.kernel) + u_c = K.dot(upper, kernel_pos) + K.dot(lower, kernel_neg) + l_c = K.dot(lower, kernel_pos) + K.dot(upper, kernel_neg) + return l_c, u_c + + +@pytest.mark.parametrize("singlelayer_model", [False, True]) +def test_my_decomon_dense_1d(singlelayer_model, ibp, affine, propagation, helpers): + # input/output shapes + batchsize = 10 + layer_input_dim = 2 + layer_output_dim = 7 + perturbation_domain = BoxDomain() + model_output_shape_if_no_singlelayer_model = (3, 5) + model_input_dim_if_no_singlelayer_model = 9 + + layer_input_shape = (layer_input_dim,) + layer_output_shape = (layer_output_dim,) + + if singlelayer_model: + model_input_dim = layer_input_dim + model_output_shape = layer_output_shape + else: + model_input_dim = model_input_dim_if_no_singlelayer_model + model_output_shape = model_output_shape_if_no_singlelayer_model + + model_input_shape = (model_input_dim,) + + # keras layer + layer = Dense(units=layer_output_dim) + layer(Input((layer_input_dim,))) + + # decomon layers + linear_decomon_layer = MyLinearDecomonDense1d( + layer=layer, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + model_output_shape=model_output_shape, + model_input_shape=model_input_shape, + ) + nonlinear_decomon_layer = MyNonLinearDecomonDense1d( + layer=layer, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + model_output_shape=model_output_shape, + model_input_shape=model_input_shape, + ) + + # symbolic inputs + decomon_inputs_linear = helpers.get_decomon_symbolic_inputs( + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + layer_input_shape=layer_input_shape, + layer_output_shape=layer_output_shape, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + for_linear_layer=True, + ) + decomon_inputs_nonlinear = helpers.get_decomon_symbolic_inputs( + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + layer_input_shape=layer_input_shape, + layer_output_shape=layer_output_shape, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + for_linear_layer=False, + ) + + # actual (random) tensors + expected output shapes + ( + affine_bounds_to_propagate, + constant_oracle_bounds, + perturbation_domain_inputs, + ) = nonlinear_decomon_layer.inputs_outputs_spec.split_inputs(decomon_inputs_nonlinear) + perturbation_domain_inputs_val = [ + helpers.generate_random_tensor(x.shape[1:], batchsize=batchsize) for x in perturbation_domain_inputs + ] + + if affine: + if propagation == Propagation.FORWARD: + b_out_shape = layer_output_shape + w_out_shape = model_input_shape + layer_output_shape + else: + b_out_shape = model_output_shape + w_out_shape = layer_input_shape + model_output_shape + affine_bounds_to_propagate_val = [ + helpers.generate_random_tensor(tensor.shape[1:], batchsize=batchsize) + for tensor in affine_bounds_to_propagate + ] + propagated_affine_bounds_expected_shape = [w_out_shape, b_out_shape, w_out_shape, b_out_shape] + else: + propagated_affine_bounds_expected_shape = [] + affine_bounds_to_propagate_val = [] + + if ibp: + constant_oracle_bounds_val = [ + helpers.generate_random_tensor(tensor.shape[1:], batchsize=batchsize) for tensor in constant_oracle_bounds + ] + propagated_ibp_bounds_expected_shape = [layer_output_shape, layer_output_shape] + else: + propagated_ibp_bounds_expected_shape = [] + constant_oracle_bounds_val = [] + + # linear case only: no constant bounds in backward + if linear_decomon_layer.inputs_outputs_spec.needs_constant_bounds_inputs(): + constant_oracle_bounds_val_linear = constant_oracle_bounds_val + else: + constant_oracle_bounds_val_linear = [] + + decomon_inputs_val_linear = linear_decomon_layer.inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate_val, + constant_oracle_bounds=constant_oracle_bounds_val_linear, + perturbation_domain_inputs=perturbation_domain_inputs_val, + ) + decomon_inputs_val_nonlinear = nonlinear_decomon_layer.inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate_val, + constant_oracle_bounds=constant_oracle_bounds_val, + perturbation_domain_inputs=perturbation_domain_inputs_val, + ) + + if propagation == Propagation.FORWARD: + decomon_output_expected_shapes = propagated_affine_bounds_expected_shape + propagated_ibp_bounds_expected_shape + else: + decomon_output_expected_shapes = propagated_affine_bounds_expected_shape + + # symbolic call + linear_decomon_output = linear_decomon_layer(decomon_inputs_linear) + nonlinear_decomon_output = nonlinear_decomon_layer(decomon_inputs_nonlinear) + + # shapes ok ? + linear_decomon_output_shape_from_call = [tensor.shape[1:] for tensor in linear_decomon_output] + assert linear_decomon_output_shape_from_call == decomon_output_expected_shapes + nonlinear_decomon_output_shape_from_call = [tensor.shape[1:] for tensor in nonlinear_decomon_output] + assert nonlinear_decomon_output_shape_from_call == decomon_output_expected_shapes + + # actual call + linear_decomon_output_val = linear_decomon_layer(decomon_inputs_val_linear) + nonlinear_decomon_output_val = nonlinear_decomon_layer(decomon_inputs_val_nonlinear) + + # shapes ok ? + linear_decomon_output_shape_from_call = [tensor.shape[1:] for tensor in linear_decomon_output_val] + assert linear_decomon_output_shape_from_call == decomon_output_expected_shapes + nonlinear_decomon_output_shape_from_call = [tensor.shape[1:] for tensor in linear_decomon_output_val] + assert nonlinear_decomon_output_shape_from_call == decomon_output_expected_shapes + + # same values ? + helpers.assert_decomon_outputs_equal(linear_decomon_output_val, nonlinear_decomon_output_val) + + # inequalities hold ? + # We only check it in case of a single-layer model => model shapes == layer shapes + # And with identity affine bounds + constant bounds = x = [keras_input, keras_input] + if model_input_shape == layer_input_shape and model_output_shape == layer_output_shape: + # keras input + keras_input_val = helpers.generate_random_tensor(layer_input_shape, batchsize=batchsize) + + # new decomon inputs values + decomon_inputs_val = helpers.generate_simple_decomon_layer_inputs_from_keras_input( + keras_input=keras_input_val, + layer_output_shape=layer_output_shape, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + for_linear_layer=False, + ) + + # linear case only: no constant bounds in backward + if linear_decomon_layer.inputs_outputs_spec.needs_constant_bounds_inputs(): + decomon_inputs_val_linear = decomon_inputs_val + else: + ( + affine_bounds_to_propagate_val, + constant_oracle_bounds_val, + perturbation_domain_inputs_val, + ) = nonlinear_decomon_layer.inputs_outputs_spec.split_inputs(decomon_inputs_val) + decomon_inputs_val_linear = linear_decomon_layer.inputs_outputs_spec.flatten_inputs( + affine_bounds_to_propagate=affine_bounds_to_propagate_val, + constant_oracle_bounds=[], + perturbation_domain_inputs=perturbation_domain_inputs_val, + ) + + # decomon call + linear_decomon_output_val = linear_decomon_layer(decomon_inputs_val_linear) + nonlinear_decomon_output_val = nonlinear_decomon_layer(decomon_inputs_val) + + # keras call + keras_output_val = layer(keras_input_val) + + # comparison + helpers.assert_decomon_output_compare_with_keras_input_output_single_layer( + decomon_output=linear_decomon_output_val, + keras_output=keras_output_val, + keras_input=keras_input_val, + ibp=ibp, + affine=affine, + propagation=propagation, + ) + helpers.assert_decomon_outputs_equal(linear_decomon_output_val, nonlinear_decomon_output_val) + + +@pytest.mark.parametrize("affine", [True]) +def test_check_affine_bounds_characteristics( + ibp, + affine, + propagation, + perturbation_domain, + empty, + diag, + nobatch, + simple_keras_symbolic_model_input_fn, + simple_keras_symbolic_layer_input_fn, + simple_decomon_symbolic_input_fn, + simple_keras_model_input_fn, + simple_keras_layer_input_fn, + simple_decomon_input_fn, + helpers, +): + units = 7 + + # init + build keras layer + keras_symbolic_model_input = simple_keras_symbolic_model_input_fn() + keras_symbolic_layer_input = simple_keras_symbolic_layer_input_fn(keras_symbolic_model_input) + layer = Dense(units=units) + layer(keras_symbolic_layer_input) + + # init + build decomon layer + output_shape = layer.output.shape[1:] + model_output_shape = output_shape + decomon_symbolic_inputs = simple_decomon_symbolic_input_fn(output_shape=output_shape, linear=False) + decomon_layer = DecomonLayer( + layer=layer, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + model_output_shape=model_output_shape, + ) + + # actual inputs + keras_model_input = simple_keras_model_input_fn() + keras_layer_input = simple_keras_layer_input_fn(keras_model_input) + decomon_inputs = simple_decomon_input_fn( + keras_model_input=keras_model_input, + keras_layer_input=keras_layer_input, + output_shape=output_shape, + linear=False, + ) + + if affine: + affine_bounds, _, _ = decomon_layer.inputs_outputs_spec.split_inputs(inputs=decomon_symbolic_inputs) + affine_bounds_shape = [t.shape for t in affine_bounds] + assert decomon_layer.inputs_outputs_spec.is_identity_bounds(affine_bounds) is empty + assert decomon_layer.inputs_outputs_spec.is_diagonal_bounds(affine_bounds) is diag + assert decomon_layer.inputs_outputs_spec.is_wo_batch_bounds(affine_bounds) is nobatch + assert decomon_layer.inputs_outputs_spec.is_identity_bounds_shape(affine_bounds_shape) is empty + assert decomon_layer.inputs_outputs_spec.is_diagonal_bounds_shape(affine_bounds_shape) is diag + assert decomon_layer.inputs_outputs_spec.is_wo_batch_bounds_shape(affine_bounds_shape) is nobatch + + affine_bounds, _, _ = decomon_layer.inputs_outputs_spec.split_inputs(inputs=decomon_inputs) + affine_bounds_shape = [t.shape for t in affine_bounds] + assert decomon_layer.inputs_outputs_spec.is_identity_bounds(affine_bounds) is empty + assert decomon_layer.inputs_outputs_spec.is_diagonal_bounds(affine_bounds) is diag + assert decomon_layer.inputs_outputs_spec.is_wo_batch_bounds(affine_bounds) is nobatch + assert decomon_layer.inputs_outputs_spec.is_identity_bounds_shape(affine_bounds_shape) is empty + assert decomon_layer.inputs_outputs_spec.is_diagonal_bounds_shape(affine_bounds_shape) is diag + assert decomon_layer.inputs_outputs_spec.is_wo_batch_bounds_shape(affine_bounds_shape) is nobatch diff --git a/tests/test_decomon_reset_layer.py b/tests/test_decomon_reset_layer.py deleted file mode 100644 index ce0634f4..00000000 --- a/tests/test_decomon_reset_layer.py +++ /dev/null @@ -1,218 +0,0 @@ -import keras.ops as K -import numpy as np -import pytest -from keras.layers import BatchNormalization, Conv2D, Dense, Flatten -from numpy.testing import assert_almost_equal - -from decomon.core import ForwardMode -from decomon.layers.decomon_layers import ( - DecomonBatchNormalization, - DecomonConv2D, - DecomonDense, - DecomonFlatten, -) - - -def test_decomondense_reset_layer(helpers, use_bias): - dc_decomp = False - input_dim = 1 - units = 3 - mode = ForwardMode.HYBRID - layer = Dense(units=units, use_bias=use_bias) - layer(K.zeros((2, input_dim))) - decomon_layer = DecomonDense(units=units, use_bias=use_bias, mode=mode, dc_decomp=dc_decomp) - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - decomon_layer(inputs_for_mode) - - kernel = layer.kernel - layer.kernel.assign(2 * np.ones_like(kernel)) - decomon_layer.kernel.assign(np.zeros_like(kernel)) - if use_bias: - bias = layer.bias - layer.bias.assign(np.ones_like(bias)) - decomon_layer.bias.assign(np.zeros_like(bias)) - - decomon_layer.reset_layer(layer) - assert decomon_layer.kernel is not layer.kernel - assert_almost_equal(K.convert_to_numpy(decomon_layer.kernel), K.convert_to_numpy(layer.kernel)) - if use_bias: - assert len(layer.weights) == 2 - assert decomon_layer.bias is not layer.bias - assert_almost_equal(K.convert_to_numpy(decomon_layer.bias), K.convert_to_numpy(layer.bias)) - else: - assert len(layer.weights) == 1 - - -def test_decomondense_reset_layer_decomon_with_new_weights(helpers): - dc_decomp = False - input_dim = 1 - units = 3 - mode = ForwardMode.HYBRID - layer = Dense(units=units) - layer(K.zeros((2, input_dim))) - decomon_layer = DecomonDense(units=units, mode=mode, dc_decomp=dc_decomp) - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - # Add new variables - decomon_layer.add_weight(shape=(input_dim, units), initializer="ones", name="alpha", trainable=True) - decomon_layer.add_weight(shape=(input_dim, units), initializer="ones", name="beta", trainable=False) - # Build layer - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - decomon_layer(inputs_for_mode) - assert len(decomon_layer.weights) == 4 - assert len(decomon_layer.trainable_weights) == 3 - - kernel, bias = layer.get_weights() - layer.set_weights([2 * np.ones_like(kernel), np.ones_like(bias)]) - decomon_layer.kernel.assign(np.zeros_like(kernel)) - decomon_layer.bias.assign(np.zeros_like(bias)) - - decomon_layer.reset_layer(layer) - assert decomon_layer.kernel is not layer.kernel - assert decomon_layer.bias is not layer.bias - assert_almost_equal(K.convert_to_numpy(decomon_layer.kernel), K.convert_to_numpy(layer.kernel)) - assert_almost_equal(K.convert_to_numpy(decomon_layer.bias), K.convert_to_numpy(layer.bias)) - - -def test_decomondense_reset_layer_keras_with_new_weights(helpers): - dc_decomp = False - input_dim = 1 - units = 3 - mode = ForwardMode.HYBRID - layer = Dense(units=units) - # Add new variables - layer.add_weight(shape=(input_dim, units), initializer="ones", name="alpha", trainable=False) - layer(K.zeros((2, input_dim))) - decomon_layer = DecomonDense(units=units, mode=mode, dc_decomp=dc_decomp) - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - decomon_layer(inputs_for_mode) - assert len(layer.weights) == 3 - - kernel = layer.kernel - layer.kernel.assign(2 * np.ones_like(kernel)) - decomon_layer.kernel.assign(np.zeros_like(kernel)) - bias = layer.bias - layer.bias.assign(np.ones_like(bias)) - decomon_layer.bias.assign(np.zeros_like(bias)) - - decomon_layer.reset_layer(layer) - assert decomon_layer.kernel is not layer.kernel - assert decomon_layer.bias is not layer.bias - assert_almost_equal(K.convert_to_numpy(decomon_layer.kernel), K.convert_to_numpy(layer.kernel)) - assert_almost_equal(K.convert_to_numpy(decomon_layer.bias), K.convert_to_numpy(layer.bias)) - - -def test_decomondense_reset_layer_ko_keraslayer_not_nuilt(): - dc_decomp = False - input_dim = 1 - units = 3 - mode = ForwardMode.HYBRID - layer = Dense(units=units) - decomon_layer = DecomonDense(units=units, mode=mode, dc_decomp=dc_decomp) - with pytest.raises(ValueError): - decomon_layer.reset_layer(layer) - - -def test_decomondense_reset_layer_ko_decomonlayer_not_nuilt(): - dc_decomp = False - input_dim = 1 - units = 3 - mode = ForwardMode.HYBRID - layer = Dense(units=units) - layer(K.zeros((2, input_dim))) - decomon_layer = DecomonDense(units=units, mode=mode, dc_decomp=dc_decomp) - with pytest.raises(ValueError): - decomon_layer.reset_layer(layer) - - -def test_decomonconv2d_reset_layer(helpers, use_bias): - dc_decomp = False - odd = 0 - data_format = "channels_last" - filters = 3 - kernel_size = (3, 3) - mode = ForwardMode.HYBRID - - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=False) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - layer = Conv2D(filters=filters, kernel_size=kernel_size, use_bias=use_bias) - layer(input_ref) - decomon_layer = DecomonConv2D( - filters=filters, kernel_size=kernel_size, use_bias=use_bias, mode=mode, dc_decomp=dc_decomp - ) - decomon_layer(inputs_for_mode) - - kernel = layer.kernel - layer.kernel.assign(2 * np.ones_like(kernel)) - decomon_layer.kernel.assign(np.zeros_like(kernel)) - if use_bias: - bias = layer.bias - layer.bias.assign(np.ones_like(bias)) - decomon_layer.bias.assign(np.zeros_like(bias)) - - decomon_layer.reset_layer(layer) - assert decomon_layer.kernel is not layer.kernel - assert_almost_equal(K.convert_to_numpy(decomon_layer.kernel), K.convert_to_numpy(layer.kernel)) - if use_bias: - assert decomon_layer.bias is not layer.bias - assert_almost_equal(K.convert_to_numpy(decomon_layer.bias), K.convert_to_numpy(layer.bias)) - - -@pytest.mark.parametrize( - "center, scale", - [ - (True, True), - (False, False), - ], -) -def test_decomonbacthnormalization_reset_layer(helpers, center, scale): - dc_decomp = False - odd = 0 - mode = ForwardMode.HYBRID - inputs = helpers.get_tensor_decomposition_multid_box(odd=odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - layer = BatchNormalization(center=center, scale=scale) - layer(input_ref) - decomon_layer = DecomonBatchNormalization(center=center, scale=scale, mode=mode, dc_decomp=dc_decomp) - decomon_layer(inputs_for_mode) - - moving_mean = layer.moving_mean - layer.moving_mean.assign(np.ones_like(moving_mean)) - decomon_layer.moving_mean.assign(np.zeros_like(moving_mean)) - moving_variance = layer.moving_variance - layer.moving_variance.assign(np.ones_like(moving_variance)) - decomon_layer.moving_variance.assign(np.zeros_like(moving_variance)) - if center: - beta = layer.beta - layer.beta.assign(np.ones_like(beta)) - decomon_layer.beta.assign(np.zeros_like(beta)) - if scale: - gamma = layer.gamma - layer.gamma.assign(np.ones_like(gamma)) - decomon_layer.gamma.assign(np.zeros_like(gamma)) - - decomon_layer.reset_layer(layer) - - keras_weights = layer.get_weights() - decomon_weights = layer.get_weights() - for i in range(len(decomon_weights)): - assert_almost_equal(decomon_weights[i], keras_weights[i]) - - -def test_decomonflatten_reset_layer(helpers): - dc_decomp = False - input_dim = 1 - mode = ForwardMode.HYBRID - layer = Flatten() - layer(K.zeros((2, input_dim))) - decomon_layer = DecomonFlatten(mode=mode, dc_decomp=dc_decomp) - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - decomon_layer(inputs_for_mode) - - decomon_layer.reset_layer(layer) diff --git a/tests/test_dense_layer.py b/tests/test_dense_layer.py deleted file mode 100644 index 896fd151..00000000 --- a/tests/test_dense_layer.py +++ /dev/null @@ -1,291 +0,0 @@ -# Test unit for decomon with Dense layers - - -import keras.config as keras_config -import numpy as np -from keras.layers import Dense -from numpy.testing import assert_almost_equal - -from decomon.core import ForwardMode, get_affine, get_ibp -from decomon.layers.convert import to_decomon -from decomon.layers.decomon_layers import DecomonDense -from decomon.models.utils import split_activation - - -def test_DecomonDense_1D_box(n, mode, shared, floatx, decimal, helpers): - dc_decomp = True - kwargs_layer = dict(units=1, use_bias=True, dtype=keras_config.floatx()) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # keras layer & function - keras_layer = Dense(**kwargs_layer) - output_ref = keras_layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - - # decomon layer & function - decomon_layer = DecomonDense(dc_decomp=dc_decomp, shared=shared, mode=mode, **kwargs_layer) - decomon_layer.share_weights(keras_layer) - outputs = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - - # test with several kernel values - kernel_coeffs = [2, -3] - W_, bias = decomon_layer.get_weights() - - for kernel_coeff in kernel_coeffs: - # set weights - if not shared: - decomon_layer.set_weights([2 * np.ones_like(W_), np.ones_like(bias)]) - keras_layer.set_weights([2 * np.ones_like(W_), np.ones_like(bias)]) - - # keras & decomon outputs - output_ref_ = f_ref(inputs_) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - - -def test_DecomonDense_1D_box_nodc(n, helpers): - dc_decomp = False - shared = False - mode = ForwardMode.HYBRID - decimal = 5 - kwargs_layer = dict(units=1, use_bias=True, dtype=keras_config.floatx()) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # keras layer & function - keras_layer = Dense(**kwargs_layer) - output_ref = keras_layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - - #  decomon layer & function - decomon_layer = DecomonDense(dc_decomp=dc_decomp, shared=shared, mode=mode, **kwargs_layer) - decomon_layer.share_weights(keras_layer) - outputs = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - - #  test with several kernel values - kernel_coeffs = [2, -3] - W_, bias = decomon_layer.get_weights() - - for kernel_coeff in kernel_coeffs: - # set weights - if not shared: - decomon_layer.set_weights([2 * np.ones_like(W_), np.ones_like(bias)]) - keras_layer.set_weights([2 * np.ones_like(W_), np.ones_like(bias)]) - - # keras & decomon outputs - output_ref_ = f_ref(inputs_) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box_linear( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - - -def test_DecomonDense_multiD_box(odd, mode, dc_decomp, helpers): - shared = False - kwargs_layer = dict(units=1, use_bias=True, dtype=keras_config.floatx()) - decimal = 5 - - # tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # keras layer & function - keras_layer = Dense(**kwargs_layer) - output_ref = keras_layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - - # decomon layer & function - decomon_layer = DecomonDense(dc_decomp=dc_decomp, shared=shared, mode=mode, **kwargs_layer) - decomon_layer.share_weights(keras_layer) - outputs = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - - # test with several kernel values - kernel_coeffs = [2, -3] - W_, bias = decomon_layer.get_weights() - - for kernel_coeff in kernel_coeffs: - # set weights - if not shared: - decomon_layer.set_weights([2 * np.ones_like(W_), np.ones_like(bias)]) - keras_layer.set_weights([2 * np.ones_like(W_), np.ones_like(bias)]) - - # keras & decomon outputs - output_ref_ = f_ref(inputs_) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - if dc_decomp: - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - else: - helpers.assert_decomon_layer_output_properties_box_linear( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - - -def test_DecomonDense_1D_to_decomon_box(n, activation, mode, shared, helpers): - dc_decomp = True - kwargs_layer = dict(units=1, use_bias=True, dtype=keras_config.floatx()) - decimal = 5 - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - # keras layer & function & output - keras_layer = Dense(**kwargs_layer) - output_ref = keras_layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - output_ref_ = f_ref(inputs_) - - # decomon layer & function & output via to_decomon - input_dim = helpers.get_input_dim_from_full_inputs(inputs) - layers_ref = split_activation(keras_layer) - decomon_layers = [] - for layer in layers_ref: - decomon_layers.append(to_decomon(layer, input_dim, dc_decomp=dc_decomp, ibp=ibp, affine=affine, shared=shared)) - outputs = decomon_layers[0](inputs_for_mode) - if len(decomon_layers) > 1: - outputs = decomon_layers[1](outputs) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - - # check same weights - W_, bias = decomon_layers[0].get_weights() - W_0, b_0 = keras_layer.get_weights() - assert_almost_equal(W_, W_0, decimal=decimal, err_msg="wrong decomposition") - assert_almost_equal(bias, b_0, decimal=decimal, err_msg="wrong decomposition") - - -def test_DecomonDense_multiD_to_decomon_box(odd, activation, mode, dc_decomp, helpers): - shared = False - kwargs_layer = dict(units=1, use_bias=True, dtype=keras_config.floatx()) - decimal = 5 - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # keras layer & function & output - keras_layer = Dense(**kwargs_layer) - output_ref = keras_layer(input_ref) - W_0, b_0 = keras_layer.get_weights() - if odd == 0: - W_0[0] = 1.037377 - W_0[1] = -0.7575816 - - keras_layer.set_weights([W_0, b_0]) - if odd == 1: - W_0[0] = -0.1657672 - W_0[1] = -0.2613032 - W_0[2] = 0.08437371 - keras_layer.set_weights([W_0, b_0]) - f_ref = helpers.function(inputs, output_ref) - output_ref_ = f_ref(inputs_) - - #  decomon layer & function via to_decomon - input_dim = helpers.get_input_dim_from_full_inputs(inputs) - layers_ref = split_activation(keras_layer) - decomon_layers = [] - for layer in layers_ref: - decomon_layers.append(to_decomon(layer, input_dim, dc_decomp=dc_decomp, ibp=ibp, affine=affine, shared=shared)) - outputs = decomon_layers[0](inputs_for_mode) - if len(decomon_layers) > 1: - outputs = decomon_layers[1](outputs) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - if dc_decomp: - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - else: - helpers.assert_decomon_layer_output_properties_box_linear( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) - - # check same weights - W_, bias = decomon_layers[0].get_weights() - W_0, b_0 = keras_layer.get_weights() - assert_almost_equal(W_, W_0, decimal=decimal, err_msg="wrong decomposition") - assert_almost_equal(bias, b_0, decimal=decimal, err_msg="wrong decomposition") diff --git a/tests/test_fuse.py b/tests/test_fuse.py new file mode 100644 index 00000000..e97a0e06 --- /dev/null +++ b/tests/test_fuse.py @@ -0,0 +1,299 @@ +import keras.ops as K +import numpy as np +import pytest +from pytest_cases import parametrize + +from decomon.constants import Propagation +from decomon.layers.fuse import Fuse +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec + + +def generate_simple_inputs(ibp, affine, input_shape, output_shape, batchsize, diag, nobatch): + if ibp: + x = K.convert_to_tensor(2.0 * np.random.random((batchsize,) + output_shape) - 1.0) + lower = x - 0.5 + upper = x + 0.5 + constant_bounds = [lower, upper] + else: + constant_bounds = [] + if affine: + if diag: + w = K.ones(input_shape) + else: + w = K.reshape(K.eye(int(np.prod(input_shape)), int(np.prod(output_shape))), input_shape + output_shape) + b = 0.1 * K.ones(output_shape) + if not nobatch: + w = K.repeat(w[None], batchsize, axis=0) + b = K.repeat(b[None], batchsize, axis=0) + affine_bounds = [w, -b, w, b] + else: + affine_bounds = [] + + return affine_bounds + constant_bounds + + +@parametrize("ibp1, affine1", [(True, False), (False, True), (True, True)], ids=["ibp", "affine", "hybrid"]) +@parametrize("ibp2, affine2", [(True, False), (False, True), (True, True)], ids=["ibp", "affine", "hybrid"]) +@parametrize( + "diag1, nobatch1", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], + ids=["diagonal-nobatch", "diagonal", "nobatch", "generic"], +) +@parametrize( + "diag2, nobatch2", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], + ids=["diagonal-nobatch", "diagonal", "nobatch", "generic"], +) +@parametrize("input_shape1", [(3,), (5, 6, 2)], ids=["1d", "multid"]) +@parametrize("output_shape1", [(2,), (4, 5, 1)], ids=["1d", "multid"]) +@parametrize("output_shape2", [(4,), (3, 4, 3)], ids=["1d", "multid"]) +def test_fuse_1output( + ibp1, + affine1, + ibp2, + affine2, + diag1, + nobatch1, + diag2, + nobatch2, + input_shape1, + output_shape1, + output_shape2, + batchsize, + helpers, +): + if not affine1: + if diag1 or nobatch1: + pytest.skip("diag and nobatch have no meaning when affine is False") + if not affine2: + if diag2 or nobatch2: + pytest.skip("diag and nobatch have no meaning when affine is False") + if diag1: + if len(input_shape1) != len(output_shape1): + pytest.skip("we need input_shape==output_shape if diag") + else: + output_shape1 = input_shape1 + if diag2: + if len(output_shape1) != len(output_shape2): + pytest.skip("we need input_shape==output_shape if diag") + else: + output_shape2 = output_shape1 + + input_shape2 = output_shape1 + + symbolic_input_1 = helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape1, + model_output_shape=None, + layer_input_shape=output_shape1, + layer_output_shape=None, + ibp=ibp1, + affine=affine1, + propagation=Propagation.FORWARD, + perturbation_domain=None, + diag=diag1, + nobatch=nobatch1, + remove_perturbation_domain_inputs=True, + ) + symbolic_input_2 = helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape2, + model_output_shape=None, + layer_input_shape=output_shape2, + layer_output_shape=None, + ibp=ibp2, + affine=affine2, + propagation=Propagation.FORWARD, + perturbation_domain=None, + diag=diag2, + nobatch=nobatch2, + remove_perturbation_domain_inputs=True, + ) + + input_1 = generate_simple_inputs( + ibp=ibp1, + affine=affine1, + input_shape=input_shape1, + output_shape=output_shape1, + batchsize=batchsize, + diag=diag1, + nobatch=nobatch1, + ) + input_2 = generate_simple_inputs( + ibp=ibp2, + affine=affine2, + input_shape=input_shape2, + output_shape=output_shape2, + batchsize=batchsize, + diag=diag2, + nobatch=nobatch2, + ) + + layer = Fuse( + ibp_1=ibp1, + affine_1=affine1, + ibp_2=ibp2, + affine_2=affine2, + m1_input_shape=input_shape1, + m_1_output_shapes=[output_shape1], + from_linear_2=[nobatch2], + ) + + symbolic_output = layer((symbolic_input_1, symbolic_input_2)) + output = layer((input_1, input_2)) + + # check shapes + output_shape = [t.shape for t in output] + expected_output_shape = [t.shape for t in symbolic_output] + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape + + inputs_outputs_spec_fused = InputsOutputsSpec( + ibp=layer.ibp_fused, affine=layer.affine_fused, layer_input_shape=input_shape1 + ) + assert len(output_shape) == inputs_outputs_spec_fused.nb_output_tensors + + +@parametrize( + "diag1_1, nobatch1_1", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], + ids=["diagonal-nobatch", "diagonal", "nobatch", "generic"], +) +@parametrize( + "diag2_2, nobatch2_2", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], + ids=["diagonal-nobatch", "diagonal", "nobatch", "generic"], +) +@parametrize("input_shape1", [(3,), (5, 6, 2)], ids=["1d", "multid"]) +@parametrize("output_shape1_1", [(2,), (4, 5, 1)], ids=["1d", "multid"]) +@parametrize("output_shape1_2", [(3,), (2, 5, 1)], ids=["1d", "multid"]) +@parametrize("output_shape2", [(4,), (3, 4, 3)], ids=["1d", "multid"]) +def test_fuse_2outputs( + diag1_1, + nobatch1_1, + diag2_2, + nobatch2_2, + input_shape1, + output_shape1_1, + output_shape1_2, + output_shape2, + batchsize, + helpers, +): + if diag1_1: + if len(input_shape1) != len(output_shape1_1): + pytest.skip("we need input_shape==output_shape if diag") + else: + output_shape1_1 = input_shape1 + if diag2_2: + if len(output_shape1_2) != len(output_shape2): + pytest.skip("we need input_shape==output_shape if diag") + else: + output_shape2 = output_shape1_2 + + ibp1, affine1, ibp2, affine2 = True, True, False, True + diag1_2, nobatch1_2 = False, False + diag2_1, nobatch2_1 = False, False + diag1 = [diag1_1, diag1_2] + diag2 = [diag2_1, diag2_2] + nobatch1 = [nobatch1_1, nobatch1_2] + nobatch2 = [nobatch2_1, nobatch2_2] + output_shape1 = [output_shape1_1, output_shape1_2] + input_shape2 = output_shape1 + nb_m1_outputs = len(output_shape1) + + symbolic_input_1 = [] + for output_shape1_i, diag1_i, nobatch1_i in zip(output_shape1, diag1, nobatch1): + symbolic_input_1 += helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape1, + model_output_shape=None, + layer_input_shape=output_shape1_i, + layer_output_shape=None, + ibp=ibp1, + affine=affine1, + propagation=Propagation.FORWARD, + perturbation_domain=None, + diag=diag1_i, + nobatch=nobatch1_i, + remove_perturbation_domain_inputs=True, + ) + symbolic_input_2 = [] + for input_shape2_i, diag2_i, nobatch2_i in zip(input_shape2, diag2, nobatch2): + symbolic_input_2 += helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape2_i, + model_output_shape=None, + layer_input_shape=output_shape2, + layer_output_shape=None, + ibp=ibp2, + affine=affine2, + propagation=Propagation.FORWARD, + perturbation_domain=None, + diag=diag2_i, + nobatch=nobatch2_i, + remove_perturbation_domain_inputs=True, + ) + + input_1 = [] + for output_shape1_i, diag1_i, nobatch1_i in zip(output_shape1, diag1, nobatch1): + input_1 += generate_simple_inputs( + ibp=ibp1, + affine=affine1, + input_shape=input_shape1, + output_shape=output_shape1_i, + batchsize=batchsize, + diag=diag1_i, + nobatch=nobatch1_i, + ) + input_2 = [] + for input_shape2_i, diag2_i, nobatch2_i in zip(input_shape2, diag2, nobatch2): + input_2 += generate_simple_inputs( + ibp=ibp2, + affine=affine2, + input_shape=input_shape2_i, + output_shape=output_shape2, + batchsize=batchsize, + diag=diag2_i, + nobatch=nobatch2_i, + ) + + layer = Fuse( + ibp_1=ibp1, + affine_1=affine1, + ibp_2=ibp2, + affine_2=affine2, + m1_input_shape=input_shape1, + m_1_output_shapes=output_shape1, + from_linear_2=nobatch2, + ) + + symbolic_output = layer((symbolic_input_1, symbolic_input_2)) + output = layer((input_1, input_2)) + + # check shapes + output_shape = [t.shape for t in output] + expected_output_shape = [t.shape for t in symbolic_output] + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape + + inputs_outputs_spec_fused = InputsOutputsSpec( + ibp=layer.ibp_fused, affine=layer.affine_fused, layer_input_shape=input_shape1 + ) + assert len(output_shape) == inputs_outputs_spec_fused.nb_output_tensors * nb_m1_outputs diff --git a/tests/test_inputs.py b/tests/test_inputs.py new file mode 100644 index 00000000..4cdfe655 --- /dev/null +++ b/tests/test_inputs.py @@ -0,0 +1,115 @@ +import pytest +from pytest_cases import parametrize + +from decomon.constants import Propagation +from decomon.layers.input import BackwardInput, ForwardInput + + +def test_forward_input( + ibp, + affine, + propagation, + perturbation_domain, + simple_model_decomon_symbolic_input, + simple_model_decomon_input, + batchsize, + helpers, +): + if propagation == Propagation.BACKWARD: + pytest.skip("backward propagation meaningless for ForwardInput") + + layer = ForwardInput(perturbation_domain=perturbation_domain, ibp=ibp, affine=affine) + symbolic_output = layer(simple_model_decomon_symbolic_input) + output = layer(simple_model_decomon_input) + + # check shapes + output_shape = [t.shape for t in output] + expected_output_shape = [t.shape for t in symbolic_output] + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape + + +@parametrize("m1_output_shape", [(2,), (4, 5, 1)], ids=["1d", "multid"]) +@parametrize("m2_output_shape", [(4,), (3, 4, 3)], ids=["1d", "multid"]) +@parametrize("bb_nb_tensors", [1, 2, 3, 4, 8]) +@parametrize("nb_outputs", [1, 2]) +def test_backward_input( + empty, diag, nobatch, bb_nb_tensors, m1_output_shape, m2_output_shape, nb_outputs, batchsize, helpers +): + if empty: + pytest.skip("backward bounds cannot be empty for BackwardInput") + if diag: + if len(m1_output_shape) != len(m2_output_shape): + pytest.skip("we need output_shape1==output_shape2 if diag") + else: + m2_output_shape = m1_output_shape + if bb_nb_tensors > 4 and nb_outputs == 1: + pytest.skip("no meaning for more than 4 tensors if only 1 output to keras model") + + # for other outputs after the first + if bb_nb_tensors <= 4: + # same output shapes + m1_output_shape_2 = m1_output_shape + m2_output_shape_2 = m2_output_shape + else: + # can be different + m1_output_shape_2 = (2, 3) + m2_output_shape_2 = (3,) + + from_linear = [nobatch] + (nb_outputs - 1) * [False] + model_output_shapes = [m1_output_shape] + (nb_outputs - 1) * [m1_output_shape_2] + layer = BackwardInput(model_output_shapes=model_output_shapes, from_linear=from_linear) + + symbolic_input = helpers.get_decomon_symbolic_inputs( + model_input_shape=m1_output_shape, + model_output_shape=m2_output_shape, + layer_input_shape=m2_output_shape, + layer_output_shape=m2_output_shape, + perturbation_domain=None, + ibp=False, + affine=True, + propagation=Propagation.FORWARD, + empty=empty, + diag=diag, + nobatch=nobatch, + remove_perturbation_domain_inputs=True, + ) + for model_output_shape in model_output_shapes[1:]: + symbolic_input += helpers.get_decomon_symbolic_inputs( + model_input_shape=model_output_shape, + model_output_shape=m2_output_shape_2, + layer_input_shape=m2_output_shape_2, + layer_output_shape=m2_output_shape_2, + perturbation_domain=None, + ibp=False, + affine=True, + propagation=Propagation.FORWARD, + remove_perturbation_domain_inputs=True, + ) + symbolic_input = symbolic_input[:bb_nb_tensors] + if nobatch: + # nobatch only for 4 first tensors + random_input = [ + helpers.generate_random_tensor(t.shape, batchsize=batchsize, nobatch=nobatch) for t in symbolic_input[:4] + ] + # add batchsize for following tensors + random_input += [ + helpers.generate_random_tensor(t.shape[1:], batchsize=batchsize, nobatch=False) for t in symbolic_input[4:] + ] + else: + random_input = [ + helpers.generate_random_tensor(t.shape[1:], batchsize=batchsize, nobatch=nobatch) for t in symbolic_input + ] + + if bb_nb_tensors == 3: + with pytest.raises(ValueError): + symbolic_output = layer(symbolic_input) + else: + symbolic_output = layer(symbolic_input) + output = layer(random_input) + + # check shapes + output_shape = [t.shape for t in output] + expected_output_shape = [t.shape for t in symbolic_output] + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape diff --git a/tests/test_keras_utils.py b/tests/test_keras_utils.py index c65ad19a..8a705263 100644 --- a/tests/test_keras_utils.py +++ b/tests/test_keras_utils.py @@ -2,91 +2,295 @@ import keras.ops as K import numpy as np import pytest -from keras.layers import Dense, Input +from keras.layers import Input, Layer from numpy.testing import assert_almost_equal -from decomon.keras_utils import ( - BACKEND_PYTORCH, - BACKEND_TENSORFLOW, - LinalgSolve, - get_weight_index_from_name, - share_layer_all_weights, -) +from decomon.keras_utils import add_tensors, batch_multid_dot, is_a_merge_layer +from decomon.types import BackendTensor + +class MyLayer(Layer): + """Mock layer unknown from decomon.""" -def test_get_weight_index_from_name_nok_attribute(): - layer = Dense(3) - layer(K.zeros((2, 1))) - with pytest.raises(AttributeError): - get_weight_index_from_name(layer=layer, weight_name="toto") + ... -def test_get_weight_index_from_name_nok_index(): - layer = Dense(3, use_bias=False) - layer(K.zeros((2, 1))) - with pytest.raises(IndexError): - get_weight_index_from_name(layer=layer, weight_name="bias") +class MyMerge(Layer): + """Mock merge layer unknown from decomon.""" + def _merge_function(self, inputs): + return inputs -def test_get_weight_index_from_name_ok(): - layer = Dense(3) - layer(K.zeros((2, 1))) - assert get_weight_index_from_name(layer=layer, weight_name="bias") in [0, 1] +def test_is_merge_layer(): + layer = MyMerge() + assert is_a_merge_layer(layer) + layer = MyLayer() + assert not is_a_merge_layer(layer) -def test_linalgsolve(floatx, decimal): - if keras.config.backend() in (BACKEND_TENSORFLOW, BACKEND_PYTORCH) and floatx == 16: - pytest.skip("LinalgSolve not implemented for float16 on torch and tensorflow") - dtype = f"float{floatx}" +def test_batch_multid_dot_symbolic_nok(): + with pytest.raises(ValueError): + batch_multid_dot(Input((2, 5, 1)), Input((4, 8, 2, 9)), nb_merging_axes=2) - matrix = np.array([[1, 0, 0], [2, 1, 0], [3, 2, 1]]) - matrix = np.repeat(matrix[None, None], 2, axis=0) - matrix_symbolic_tensor = keras.KerasTensor(shape=matrix.shape, dtype=dtype) - matrix_tensor = keras.ops.convert_to_tensor(matrix, dtype=dtype) - rhs = np.array([[1, 0], [0, 0], [0, 1]]) - rhs = np.repeat(rhs[None, None], 2, axis=0) - rhs_symbolic_tensor = keras.KerasTensor(shape=rhs.shape, dtype=dtype) - rhs_tensor = keras.ops.convert_to_tensor(rhs, dtype=dtype) +def test_batch_multid_dot_symbolic_ok(): + output = batch_multid_dot(Input((2, 5, 1)), Input((5, 1, 8, 2, 9)), nb_merging_axes=2) + assert output.shape == (None, 2, 8, 2, 9) - expected_sol = np.array([[1, 0], [-2, 0], [1, 1]]) - expected_sol = np.repeat(expected_sol[None, None], 2, axis=0) - sol_symbolic_tensor = LinalgSolve()(matrix_symbolic_tensor, rhs_symbolic_tensor) - assert tuple(sol_symbolic_tensor.shape) == tuple(expected_sol.shape) +def test_batch_multid_dot_nok(): + with pytest.raises(ValueError): + batch_multid_dot(K.ones((10, 2, 5, 1)), K.ones((1, 4, 8, 2, 9)), nb_merging_axes=2) + - sol_tensor = LinalgSolve()(matrix_tensor, rhs_tensor) - assert keras.backend.standardize_dtype(sol_tensor.dtype) == dtype - assert_almost_equal(expected_sol, keras.ops.convert_to_numpy(sol_tensor), decimal=decimal) +def test_batch_multid_dot_ok(): + x = K.repeat(K.reshape(K.eye(5), (1, 5, 5, 1)), 10, axis=0) + y = K.ones((10, 5, 1, 8, 2, 9)) + output = batch_multid_dot(x, y, nb_merging_axes=2) + expected_shape = (10, 5, 8, 2, 9) + assert output.shape == expected_shape + assert K.convert_to_numpy(output == K.ones(expected_shape)).all() -def test_share_layer_all_weights_nok_original_layer_unbuilt(): - original_layer = Dense(3) - new_layer = original_layer.__class__.from_config(original_layer.get_config()) +def test_batch_multid_dot_missing_y_batch_nok(): + x = K.repeat(K.reshape(K.eye(5), (1, 5, 5, 1)), 10, axis=0) + y = K.ones((10, 5, 1, 8, 2, 9)) with pytest.raises(ValueError): - share_layer_all_weights(original_layer=original_layer, new_layer=new_layer) + batch_multid_dot(x, y, nb_merging_axes=2, missing_batchsize=(False, True)) + + +def test_batch_multid_dot_missing_y_batch_ok(helpers): + batchsize = 10 + x_shape_wo_batch = (4, 5, 2) + y_shape_wo_batch = (5, 2, 3, 7) + nb_merging_axes = 2 + expected_res_shape_wo_batchsize = (4, 3, 7) + x_shape = (batchsize,) + x_shape_wo_batch + + x = K.convert_to_tensor(np.random.random(x_shape)) + y_wo_batch = K.convert_to_tensor(np.random.random(y_shape_wo_batch)) + y_w_batch = K.repeat(y_wo_batch[None], batchsize, axis=0) + + res = batch_multid_dot(x, y_wo_batch, nb_merging_axes=nb_merging_axes, missing_batchsize=(False, True)) + assert tuple(res.shape)[1:] == expected_res_shape_wo_batchsize + res_w_all_batch = batch_multid_dot(x, y_w_batch, nb_merging_axes=nb_merging_axes) + helpers.assert_almost_equal(res, res_w_all_batch) + + +def test_batch_multid_dot_missing_x_batch_ok(helpers): + batchsize = 10 + x_shape_wo_batch = (4, 5, 2) + y_shape_wo_batch = (5, 2, 3, 7) + nb_merging_axes = 2 + expected_res_shape_wo_batchsize = (4, 3, 7) + y_shape = (batchsize,) + y_shape_wo_batch + + y = K.convert_to_tensor(np.random.random(y_shape)) + x_wo_batch = K.convert_to_tensor(np.random.random(x_shape_wo_batch)) + x_w_batch = K.repeat(x_wo_batch[None], batchsize, axis=0) + + res = batch_multid_dot(x_wo_batch, y, nb_merging_axes=nb_merging_axes, missing_batchsize=(True, False)) + assert tuple(res.shape)[1:] == expected_res_shape_wo_batchsize + res_w_all_batch = batch_multid_dot(x_w_batch, y, nb_merging_axes=nb_merging_axes) + helpers.assert_almost_equal(res, res_w_all_batch) + + +@pytest.mark.parametrize("missing_batchsize", [(False, False), (True, False), (False, True)]) +def test_batch_multid_dot_default_nb_merging_axes(missing_batchsize, helpers): + batchsize = 10 + x_shape_wo_batch = (4, 5, 2) + y_shape_wo_batch = (4, 5, 2, 3, 7) + nb_merging_axes = len(x_shape_wo_batch) + expected_res_shape_wo_batchsize = (3, 7) + x_shape = (batchsize,) + x_shape_wo_batch + y_shape = (batchsize,) + y_shape_wo_batch + + x_missing_batchsize, y_missing_batchsize = missing_batchsize + if x_missing_batchsize: + x = K.convert_to_tensor(np.random.random(x_shape_wo_batch)) + else: + x = K.convert_to_tensor(np.random.random(x_shape)) + if y_missing_batchsize: + y = K.convert_to_tensor(np.random.random(y_shape_wo_batch)) + else: + y = K.convert_to_tensor(np.random.random(y_shape)) + + res_default = batch_multid_dot(x, y, missing_batchsize=missing_batchsize) + res = batch_multid_dot(x, y, nb_merging_axes=nb_merging_axes, missing_batchsize=missing_batchsize) + helpers.assert_almost_equal(res, res_default) + + +def generate_tensor_full_n_diag( + batchsize: int, + diag_shape: tuple[int, ...], + other_shape: tuple[int, ...], + diag: bool, + missing_batchsize: bool, + left: bool, +) -> tuple[BackendTensor, BackendTensor]: + batchshape = (batchsize,) + flatten_diag_shape = (int(np.prod(diag_shape)),) + full_shape = diag_shape + diag_shape + if diag: + if missing_batchsize: + x_diag_flatten = K.convert_to_tensor(np.random.random(flatten_diag_shape), dtype=float) + x_diag = K.reshape(x_diag_flatten, diag_shape) + x_full = K.reshape(K.diag(x_diag_flatten), full_shape) + else: + x_diag_flatten = K.convert_to_tensor(np.random.random(batchshape + flatten_diag_shape), dtype=float) + x_diag = K.reshape(x_diag_flatten, batchshape + diag_shape) + x_full = K.concatenate( + [K.reshape(K.diag(x_diag_flatten[i]), full_shape)[None] for i in range(batchsize)], axis=0 + ) + else: + if left: + x_shape = other_shape + diag_shape + else: + x_shape = diag_shape + other_shape + if not missing_batchsize: + x_shape = batchshape + x_shape + x = K.convert_to_tensor(np.random.random(x_shape), dtype=float) -def test_share_layer_all_weights_nok_new_layer_built(): - original_layer = Dense(3) - inp = Input((1,)) - original_layer(inp) - new_layer = original_layer.__class__.from_config(original_layer.get_config()) - new_layer(inp) + x_full = x + x_diag = x + + if missing_batchsize: + # reconstruct a batch axis + x_full = K.repeat(x_full[None], batchsize, axis=0) + + return x_full, x_diag + + +@pytest.mark.parametrize("missing_batchsize", [(False, False), (True, False), (False, True), (True, True)]) +@pytest.mark.parametrize("diagonal", [(True, True), (True, False), (False, True), (False, False)]) +def test_batch_multi_dot_diag_missing_batchsize(missing_batchsize, diagonal, helpers): + batchsize = 10 + diag_shape = (4, 5, 2) + other_shape = (3, 7) + nb_merging_axes = len(diag_shape) + + diag_x, diag_y = diagonal + missing_batchsize_x, missing_batchsize_y = missing_batchsize + + x_full, x_simplified = generate_tensor_full_n_diag( + batchsize=batchsize, + diag_shape=diag_shape, + other_shape=other_shape, + diag=diag_x, + missing_batchsize=missing_batchsize_x, + left=True, + ) + y_full, y_simplified = generate_tensor_full_n_diag( + batchsize=batchsize, + diag_shape=diag_shape, + other_shape=other_shape, + diag=diag_y, + missing_batchsize=missing_batchsize_y, + left=False, + ) + + res_full = batch_multid_dot(x_full, y_full, nb_merging_axes=nb_merging_axes) + res_simplified = batch_multid_dot( + x_simplified, + y_simplified, + nb_merging_axes=nb_merging_axes, + missing_batchsize=missing_batchsize, + diagonal=diagonal, + ) + + if missing_batchsize_x and missing_batchsize_y: + # the result stayed w/o batch axis, needs to be added to be compared with full result + res_simplified = K.repeat(res_simplified[None], batchsize, axis=0) + + if diag_x and diag_y: + # the result stayed diagonal, needs to be reworked to be compared with full result + assert res_simplified.shape == (batchsize,) + diag_shape + res_simplified = K.concatenate( + [ + K.reshape(K.diag(K.ravel(res_simplified[i])), diag_shape + diag_shape)[None] + for i in range(len(res_simplified)) + ], + axis=0, + ) + elif diag_x: + assert res_simplified.shape == (batchsize,) + diag_shape + other_shape + elif diag_y: + assert res_simplified.shape == (batchsize,) + other_shape + diag_shape + + helpers.assert_almost_equal( + res_full, + res_simplified, + ) + + +@pytest.mark.parametrize( + "x_shape, y_shape, missing_batchsize, diagonal", + [ + ((4, 6), (6,), (True, True), (False, False)), + ((4, 6), (6,), (False, False), (False, False)), + ((4, 6), (6, 6), (False, False), (True, False)), + ], +) +def test_add_tensors_nok_incompatible_shapes(x_shape, y_shape, missing_batchsize, diagonal): + x = K.ones(x_shape) + y = K.ones(y_shape) with pytest.raises(ValueError): - share_layer_all_weights(original_layer=original_layer, new_layer=new_layer) + add_tensors(x, y, missing_batchsize=missing_batchsize, diagonal=diagonal) + + +@pytest.mark.parametrize("missing_batchsize", [(False, False), (True, False), (False, True), (True, True)]) +@pytest.mark.parametrize("diagonal", [(True, True), (True, False), (False, True), (False, False)]) +def test_add_tensors_ok(missing_batchsize, diagonal, helpers): + batchsize = 10 + diag_shape = (4, 5, 2) + other_shape = diag_shape + + diag_x, diag_y = diagonal + missing_batchsize_x, missing_batchsize_y = missing_batchsize + + x_full, x_simplified = generate_tensor_full_n_diag( + batchsize=batchsize, + diag_shape=diag_shape, + other_shape=other_shape, + diag=diag_x, + missing_batchsize=missing_batchsize_x, + left=True, + ) + y_full, y_simplified = generate_tensor_full_n_diag( + batchsize=batchsize, + diag_shape=diag_shape, + other_shape=other_shape, + diag=diag_y, + missing_batchsize=missing_batchsize_y, + left=True, + ) + + res_full = x_full + y_full + res_simplified = add_tensors( + x_simplified, + y_simplified, + missing_batchsize=missing_batchsize, + diagonal=diagonal, + ) + if missing_batchsize_x and missing_batchsize_y: + # the result stayed w/o batch axis, needs to be added to be compared with full result + res_simplified = K.repeat(res_simplified[None], batchsize, axis=0) -def test_share_layer_all_weights_ok(): - original_layer = Dense(3) - inp = Input((1,)) - original_layer(inp) - new_layer = original_layer.__class__.from_config(original_layer.get_config()) - share_layer_all_weights(original_layer=original_layer, new_layer=new_layer) + if diag_x and diag_y: + # the result stayed diagonal, needs to be reworked to be compared with full result + assert res_simplified.shape == (batchsize,) + diag_shape + res_simplified = K.concatenate( + [ + K.reshape(K.diag(K.ravel(res_simplified[i])), diag_shape + diag_shape)[None] + for i in range(len(res_simplified)) + ], + axis=0, + ) - # check same weights - assert len(original_layer.weights) == len(new_layer.weights) - for w in original_layer.weights: - new_w = [ww for ww in new_layer.weights if ww.name == w.name][0] - assert w is new_w + helpers.assert_almost_equal( + res_full, + res_simplified, + ) diff --git a/tests/test_layers_convert_utils.py b/tests/test_layers_convert_utils.py deleted file mode 100644 index e318086d..00000000 --- a/tests/test_layers_convert_utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -from keras.layers import Dense, Input - -from decomon.layers.convert import get_layer_input_shape - - -def test_get_layer_input_shape_nok_uncalled(): - layer = Dense(3) - with pytest.raises(AttributeError): - get_layer_input_shape(layer) - - -def test_get_layer_input_shape_nok_multiple_shapes(): - layer = Dense(3) - layer(Input((1,))) - layer(Input((1, 1))) - with pytest.raises(AttributeError): - get_layer_input_shape(layer) - - -def test_get_layer_input_shape_ok(): - layer = Dense(3) - layer(Input((1,))) - input_shape = get_layer_input_shape(layer) - assert input_shape == [(None, 1)] diff --git a/tests/test_layers_get_config.py b/tests/test_layers_get_config.py deleted file mode 100644 index ba672ca8..00000000 --- a/tests/test_layers_get_config.py +++ /dev/null @@ -1,111 +0,0 @@ -import pytest - -from decomon.core import ForwardMode -from decomon.layers.decomon_layers import ( - DecomonActivation, - DecomonBatchNormalization, - DecomonConv2D, - DecomonDense, - DecomonDropout, - DecomonFlatten, - DecomonInputLayer, -) -from decomon.layers.decomon_merge_layers import ( - DecomonAdd, - DecomonAverage, - DecomonConcatenate, - DecomonDot, - DecomonMaximum, - DecomonMinimum, - DecomonMultiply, - DecomonSubtract, -) -from decomon.layers.decomon_reshape import DecomonPermute, DecomonReshape -from decomon.layers.maxpooling import DecomonMaxPooling2D - - -def test_decomon_reshape(): - dims = (1, 2, 3) - mode = ForwardMode.AFFINE - layer = DecomonPermute(dims=dims, mode=mode) - config = layer.get_config() - assert config["dims"] == dims - assert config["mode"] == mode - - shape = (1, 2, 3) - layer = DecomonReshape(target_shape=shape, mode=mode) - config = layer.get_config() - assert config["target_shape"] == shape - assert config["mode"] == mode - - -def test_maxpooling(): - layer = DecomonMaxPooling2D() - config = layer.get_config() - assert "pool_size" in config - - -def test_decomon_merge_layers(): - for cls in [ - DecomonAdd, - DecomonAverage, - DecomonConcatenate, - DecomonDot, - DecomonMaximum, - DecomonMinimum, - DecomonMultiply, - DecomonSubtract, - ]: - layer = cls() - config = layer.get_config() - assert "mode" in config - if layer == DecomonConcatenate: - assert "axis" in config - elif layer == DecomonDot: - assert "axes" in config - - -def test_decomon_layers(): - activation_mapping = { - "relu": "relu", - "softmax": "softmax", - "linear": "linear", - None: "linear", - } - for activation_arg, activation_res in activation_mapping.items(): - layer = DecomonActivation(activation=activation_arg) - config = layer.get_config() - print(config) - print(layer.activation) - assert config["activation"] == activation_res - - units = 2 - layer = DecomonDense(units=units) - config = layer.get_config() - assert config["units"] == units - - filters = 2 - kernel_size = 3, 3 - layer = DecomonConv2D(filters=filters, kernel_size=kernel_size) - config = layer.get_config() - assert config["filters"] == filters - assert config["kernel_size"] == kernel_size - - rate = 0.9 - layer = DecomonDropout(rate=rate) - config = layer.get_config() - assert config["rate"] == rate - - shape = (2, 5) - layer = DecomonInputLayer(shape=shape) - config = layer.get_config() - assert config["batch_shape"] == (None,) + shape - - for cls in [DecomonBatchNormalization, DecomonFlatten]: - layer = cls() - config = layer.get_config() - assert "mode" in config - if layer == DecomonBatchNormalization: - assert "axis" in config - elif layer == DecomonFlatten: - assert "data_format" in config diff --git a/tests/test_merge.py b/tests/test_merge.py deleted file mode 100644 index c3514ca5..00000000 --- a/tests/test_merge.py +++ /dev/null @@ -1,246 +0,0 @@ -# Test unit for decomon with Dense layers - - -import keras.config as keras_config -import numpy as np -import pytest -from keras.layers import Add, Average, Concatenate, Maximum, Minimum, Multiply, Subtract - -from decomon.core import ForwardMode, get_affine, get_ibp -from decomon.layers.convert import to_decomon -from decomon.layers.decomon_merge_layers import ( - DecomonAdd, - DecomonAverage, - DecomonConcatenate, - DecomonMaximum, - DecomonMinimum, - DecomonMultiply, - DecomonSubtract, -) - - -def add_op(x, y): - return x + y - - -def subtract_op(x, y): - return x - y - - -def multiply_op(x, y): - return x * y - - -def average_op(x, y): - return (x + y) / 2.0 - - -def concatenate_op(x, y): - return np.concatenate([x, y], -1) - - -@pytest.mark.parametrize( - "decomon_op_class, tensor_op, decomon_op_kwargs", - [ - (DecomonAdd, add_op, {}), - (DecomonSubtract, subtract_op, {}), - (DecomonAverage, average_op, {}), - (DecomonMaximum, np.maximum, {}), - (DecomonMinimum, np.minimum, {}), - (DecomonConcatenate, concatenate_op, {"axis": -1}), - (DecomonMultiply, multiply_op, {}), - ], -) -def test_DecomonOp_1D_box(decomon_op_class, tensor_op, decomon_op_kwargs, n, mode, floatx, decimal, helpers): - dc_decomp = False - - #  tensor inputs - inputs_0 = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_1 = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs=inputs_) - - # original output - output_ref_ = tensor_op(input_ref_, input_ref_) - - # decomon output - decomon_op = decomon_op_class(dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx(), **decomon_op_kwargs) - output = decomon_op(inputs_for_mode_0 + inputs_for_mode_1) - f_decomon = helpers.function(inputs_0 + inputs_1, output) - outputs_ = f_decomon(inputs_ + inputs_) - - #  check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) - - -@pytest.mark.parametrize( - "decomon_op_class, tensor_op, decomon_op_kwargs", - [ - (DecomonAdd, add_op, {}), - (DecomonSubtract, subtract_op, {}), - (DecomonAverage, average_op, {}), - (DecomonMaximum, np.maximum, {}), - (DecomonMinimum, np.minimum, {}), - (DecomonConcatenate, concatenate_op, {"axis": -1}), - (DecomonMultiply, multiply_op, {}), - ], -) -def test_DecomonOp_multiD_box(decomon_op_class, tensor_op, decomon_op_kwargs, odd, mode, floatx, decimal, helpers): - dc_decomp = False - - #  tensor inputs - inputs_0 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_1 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs=inputs_) - - # original output - output_ref_ = tensor_op(input_ref_, input_ref_) - - # decomon output - decomon_op = decomon_op_class(dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx(), **decomon_op_kwargs) - output = decomon_op(inputs_for_mode_0 + inputs_for_mode_1) - f_decomon = helpers.function(inputs_0 + inputs_1, output) - outputs_ = f_decomon(inputs_ + inputs_) - - #  check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) - - -@pytest.mark.parametrize( - "layer_class, tensor_op, layer_kwargs", - [ - (Add, add_op, {}), - (Average, average_op, {}), - (Subtract, subtract_op, {}), - (Maximum, np.maximum, {}), - (Minimum, np.minimum, {}), - (Concatenate, concatenate_op, {"axis": -1}), - (Multiply, multiply_op, {}), - ], -) -def test_Decomon_1D_box_to_decomon(layer_class, tensor_op, layer_kwargs, n, helpers): - dc_decomp = False - mode = ForwardMode.HYBRID - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - decimal = 5 - - #  tensor inputs - inputs_0 = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_1 = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - input_ref_0 = helpers.get_input_ref_from_full_inputs(inputs=inputs_0) - input_ref_1 = helpers.get_input_ref_from_full_inputs(inputs=inputs_1) - - # numpy inputs - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs=inputs_) - - # original output - output_ref_ = tensor_op(input_ref_, input_ref_) - - # to_decomon - ref_op = layer_class(dtype=keras_config.floatx(), **layer_kwargs) - ref_op([input_ref_0, input_ref_1]) - decomon_op = to_decomon( - ref_op, input_dim=helpers.get_input_dim_from_full_inputs(inputs_0), dc_decomp=dc_decomp, affine=affine, ibp=ibp - ) - - # decomon output - output = decomon_op(inputs_for_mode_0 + inputs_for_mode_1) - f_decomon = helpers.function(inputs_0 + inputs_1, output) - outputs_ = f_decomon(inputs_ + inputs_) - - #  check bounds consistency - helpers.assert_decomon_layer_output_properties_box_linear( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) - - -#### to_decomon multiD - - -@pytest.mark.parametrize( - "layer_class, tensor_op, layer_kwargs", - [ - (Add, add_op, {}), - (Average, average_op, {}), - (Subtract, subtract_op, {}), - (Maximum, np.maximum, {}), - (Minimum, np.minimum, {}), - (Concatenate, concatenate_op, {"axis": -1}), - (Multiply, multiply_op, {}), - ], -) -def test_Decomon_multiD_box_to_decomon(layer_class, tensor_op, layer_kwargs, odd, helpers): - dc_decomp = False - mode = ForwardMode.HYBRID - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - decimal = 5 - - #  tensor inputs - inputs_0 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_1 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - input_ref_0 = helpers.get_input_ref_from_full_inputs(inputs=inputs_0) - input_ref_1 = helpers.get_input_ref_from_full_inputs(inputs=inputs_1) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs=inputs_) - - # original output - output_ref_ = tensor_op(input_ref_, input_ref_) - - #  to_decomon - ref_op = layer_class(dtype=keras_config.floatx(), **layer_kwargs) - ref_op([input_ref_0, input_ref_1]) - decomon_op = to_decomon( - ref_op, input_dim=helpers.get_input_dim_from_full_inputs(inputs_0), dc_decomp=dc_decomp, affine=affine, ibp=ibp - ) - - # decomon output - output = decomon_op(inputs_for_mode_0 + inputs_for_mode_1) - f_decomon = helpers.function(inputs_0 + inputs_1, output) - outputs_ = f_decomon(inputs_ + inputs_) - - #  check bounds consistency - helpers.assert_decomon_layer_output_properties_box_linear( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) diff --git a/tests/test_merge_layers.py b/tests/test_merge_layers.py new file mode 100644 index 00000000..06bfd8a3 --- /dev/null +++ b/tests/test_merge_layers.py @@ -0,0 +1,231 @@ +from typing import TypeVar + +import keras.ops as K +import numpy as np +import pytest +from keras.layers import Add + +from decomon.keras_utils import batch_multid_dot +from decomon.layers.merging.add import DecomonAdd, DecomonMerge +from decomon.types import Tensor + + +# Defining non-linear and/or non-diagonal versions of DecomonAdd +class DecomonNonDiagAdd(DecomonMerge): + layer: Add + linear = True + diagonal = False + + def get_affine_representation(self) -> tuple[list[Tensor], Tensor]: + w = [] + for input_i in self.keras_layer_input: + diag_shape = input_i.shape[1:] + full_shape = diag_shape + diag_shape + flattened_diag_shape = (int(np.prod(diag_shape)),) + w.append(K.reshape(K.diag(K.ones(flattened_diag_shape)), full_shape)) + + b = K.zeros(self.layer.output.shape[1:]) + + return w, b + + +class DecomonNonLinearAdd(DecomonMerge): + layer: Add + linear = False + diagonal = True + + def get_affine_bounds( + self, lower: list[Tensor], upper: list[Tensor] + ) -> tuple[list[Tensor], Tensor, list[Tensor], Tensor]: + w = [] + batchsize = lower[0].shape[0] + for input_i in self.keras_layer_input: + diag_shape = input_i.shape[1:] + w.append(K.repeat(K.ones(diag_shape)[None], batchsize, axis=0)) + + b = K.zeros_like(lower[0]) + + return w, b, w, b + + def forward_ibp_propagate(self, lower: list[Tensor], upper: list[Tensor]) -> tuple[Tensor, Tensor]: + return sum(lower), sum(upper) + + +class DecomonNonLinearNonDiagAdd(DecomonMerge): + layer: Add + linear = False + diagonal = False + + def get_affine_bounds( + self, lower: list[Tensor], upper: list[Tensor] + ) -> tuple[list[Tensor], Tensor, list[Tensor], Tensor]: + w = [] + batchsize = lower[0].shape[0] + for input_i in self.keras_layer_input: + diag_shape = input_i.shape[1:] + full_shape = diag_shape + diag_shape + flattened_diag_shape = (int(np.prod(diag_shape)),) + w.append(K.repeat(K.reshape(K.diag(K.ones(flattened_diag_shape)), full_shape)[None], batchsize, axis=0)) + + b = K.zeros_like(lower[0]) + + return w, b, w, b + + def forward_ibp_propagate(self, lower: list[Tensor], upper: list[Tensor]) -> tuple[Tensor, Tensor]: + return sum(lower), sum(upper) + + +T = TypeVar("T") + + +def double_input(input: T) -> list[T]: + return [input] * 2 + + +@pytest.mark.parametrize( + "decomon_layer_class, decomon_layer_kwargs, keras_layer_class, keras_layer_kwargs, is_actually_linear", + [ + (DecomonAdd, {}, Add, {}, True), + (DecomonNonDiagAdd, {}, Add, {}, True), + (DecomonNonLinearAdd, {}, Add, {}, True), + (DecomonNonLinearNonDiagAdd, {}, Add, {}, True), + ], +) +def test_decomon_merge( + decomon_layer_class, + decomon_layer_kwargs, + keras_layer_class, + keras_layer_kwargs, + is_actually_linear, + ibp, + affine, + propagation, + perturbation_domain, + batchsize, + keras_symbolic_model_input_fn, + keras_symbolic_layer_input_fn, + decomon_symbolic_input_fn, + keras_model_input_fn, + keras_layer_input_fn, + decomon_input_fn, + equal_ibp_bounds, + equal_affine_bounds, + helpers, +): + decimal = 4 + if is_actually_linear is None: + is_actually_linear = decomon_layer_class.linear + + # init + build keras layer + keras_symbolic_model_input = keras_symbolic_model_input_fn() + keras_symbolic_layer_input_0 = keras_symbolic_layer_input_fn(keras_symbolic_model_input) + # we merge twice the same input + keras_symbolic_layer_input = double_input(keras_symbolic_layer_input_0) + layer = keras_layer_class(**keras_layer_kwargs) + layer(keras_symbolic_layer_input) + + # init + build decomon layer + output_shape = layer.output.shape[1:] + model_output_shape = output_shape + model_input_shape = keras_symbolic_model_input.shape[1:] + + decomon_layer = decomon_layer_class( + layer=layer, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + model_output_shape=model_output_shape, + model_input_shape=model_input_shape, + **decomon_layer_kwargs, + ) + + decomon_symbolic_inputs_0 = decomon_symbolic_input_fn(output_shape=output_shape, linear=decomon_layer.linear) + decomon_symbolic_inputs = helpers.generate_merging_decomon_input_from_single_decomon_inputs( + decomon_inputs=double_input(decomon_symbolic_inputs_0), + ibp=ibp, + affine=affine, + propagation=propagation, + linear=decomon_layer.linear, + ) + + # skip if empty affine bounds in forward propagation (it would generate issues to split inputs) + if ( + decomon_layer.inputs_outputs_spec.cannot_have_empty_affine_inputs() + and decomon_layer.inputs_outputs_spec.nb_input_tensors > len(decomon_symbolic_inputs) + ): + pytest.skip("Skip cases with empty (meaning identity) affine inputs that cannot be properly split") + + decomon_layer(decomon_symbolic_inputs) + + # call on actual inputs + keras_model_input = keras_model_input_fn() + keras_layer_input_0 = keras_layer_input_fn(keras_model_input) + decomon_inputs_0 = decomon_input_fn( + keras_model_input=keras_model_input, + keras_layer_input=keras_layer_input_0, + output_shape=output_shape, + linear=decomon_layer.linear, + ) + keras_layer_input = double_input(keras_layer_input_0) + decomon_inputs = helpers.generate_merging_decomon_input_from_single_decomon_inputs( + decomon_inputs=double_input(decomon_inputs_0), + ibp=ibp, + affine=affine, + propagation=propagation, + linear=decomon_layer.linear, + ) + + keras_output = layer(keras_layer_input) + decomon_output = decomon_layer(decomon_inputs) + + # check affine representation is ok + if decomon_layer.linear: + w, b = decomon_layer.get_affine_representation() + keras_output_2 = b + for w_i, keras_layer_input_i in zip(w, keras_layer_input): + diagonal = (False, w_i.shape == b.shape) + missing_batchsize = (False, True) + keras_output_2 = keras_output_2 + batch_multid_dot( + keras_layer_input_i, w_i, missing_batchsize=missing_batchsize, diagonal=diagonal + ) # += does not work well with broadcasting on pytorch backend + np.testing.assert_almost_equal( + K.convert_to_numpy(keras_output), + K.convert_to_numpy(keras_output_2), + decimal=decimal, + err_msg="wrong affine representation", + ) + + # check output shapes + input_shape = [t.shape for t in decomon_inputs] + output_shape = [t.shape for t in decomon_output] + expected_output_shape = decomon_layer.compute_output_shape(input_shape) + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_layer( + decomon_output=decomon_output, + keras_model_input=keras_model_input, + keras_layer_input=keras_layer_input, + keras_model_output=keras_output, + keras_layer_output=keras_output, + ibp=ibp, + affine=affine, + propagation=propagation, + decimal=decimal, + is_merging_layer=True, + ) + + # before propagation through linear layer lower == upper => lower == upper after propagation + if is_actually_linear: + helpers.assert_decomon_output_lower_equal_upper( + decomon_output, + ibp=ibp, + affine=affine, + propagation=propagation, + decimal=decimal, + check_ibp=equal_ibp_bounds, + check_affine=equal_affine_bounds, + is_merging_layer=True, + ) diff --git a/tests/test_metric.py b/tests/test_metric.py deleted file mode 100644 index a7af674a..00000000 --- a/tests/test_metric.py +++ /dev/null @@ -1,34 +0,0 @@ -import keras.ops as K - -from decomon.metrics.utils import categorical_cross_entropy - - -def test_categorical_cross_entropy(odd, mode, floatx, decimal, helpers): - dc_decomp = False - - # tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # original output - f_ref = helpers.function(inputs, -input_ref + K.log(K.sum(K.exp(input_ref), -1))[:, None]) - output_ref_ = f_ref(inputs_) - - # decomon output - output = categorical_cross_entropy(inputs_for_mode, dc_decomp=dc_decomp, mode=mode) - f_entropy = helpers.function(inputs, output) - outputs_ = f_entropy(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - mode=mode, - decimal=decimal, - ) diff --git a/tests/test_metrics_get_config.py b/tests/test_metrics_get_config.py deleted file mode 100644 index 5d7fde03..00000000 --- a/tests/test_metrics_get_config.py +++ /dev/null @@ -1,34 +0,0 @@ -from decomon.core import ForwardMode -from decomon.metrics.loss import DecomonLossFusion, DecomonRadiusRobust -from decomon.metrics.metric import ( - AdversarialCheck, - AdversarialScore, - MetricMode, - UpperScore, -) - - -def test_decomon_loss(): - backward = True - mode = ForwardMode.AFFINE - - layer = DecomonLossFusion(mode=mode, backward=backward) - config = layer.get_config() - assert config["backward"] == backward - assert config["mode"] == mode - - layer = DecomonRadiusRobust(backward=backward, mode=mode) - config = layer.get_config() - assert config["backward"] == backward - assert config["mode"] == mode - - -def test_metric(): - ibp, affine, mode, perturbation_domain = True, False, MetricMode.BACKWARD, {} - for cls in [AdversarialCheck, AdversarialScore, UpperScore]: - layer = cls(ibp=ibp, affine=affine, mode=mode, perturbation_domain=perturbation_domain) - config = layer.get_config() - assert config["ibp"] == ibp - assert config["affine"] == affine - assert config["mode"] == mode - assert "perturbation_domain" in config diff --git a/tests/test_models_get_config.py b/tests/test_models_get_config.py deleted file mode 100644 index 43174d20..00000000 --- a/tests/test_models_get_config.py +++ /dev/null @@ -1,28 +0,0 @@ -from keras.layers import Dense, Input - -from decomon.models import DecomonModel - - -def test_get_config(): - input = Input((1,)) - output = Dense(2)(Dense(3)(input)) - decomon_model = DecomonModel(input, output) - config = decomon_model.get_config() - print(config) - expected_keys = [ - "layers", - "name", - "input_layers", - "output_layers", - "perturbation_domain", - "dc_decomp", - "method", - "ibp", - "affine", - "finetune", - "shared", - "backward_bounds", - ] - for k in expected_keys: - assert k in config - assert len(config["layers"]) == len(decomon_model.layers) diff --git a/tests/test_oracle.py b/tests/test_oracle.py new file mode 100644 index 00000000..7c208d33 --- /dev/null +++ b/tests/test_oracle.py @@ -0,0 +1,92 @@ +from typing import TypeVar + +import pytest +from pytest_cases import parametrize + +from decomon.constants import Propagation +from decomon.layers.oracle import DecomonOracle + +T = TypeVar("T") + + +def double_input(input: T) -> list[T]: + return [input] * 2 + + +@parametrize("is_merging_layer", [False, True]) +def test_decomon_oracle( + ibp, + affine, + propagation, + perturbation_domain, + is_merging_layer, + input_shape, + empty, + batchsize, + simple_decomon_symbolic_input_fn, + simple_decomon_input_fn, + simple_keras_model_input_fn, + simple_keras_layer_input_fn, + helpers, +): + if propagation == Propagation.BACKWARD: + pytest.skip( + "We skip backward propagation as we need same inputs for DecomonOracle as for a forward layer, even for crown." + ) + if empty: + pytest.skip("Empty inputs without meaning for DecomonOracle.") + + output_shape = None + linear = False + + decomon_symbolic_input = simple_decomon_symbolic_input_fn(output_shape, linear) + keras_model_input = simple_keras_model_input_fn() + keras_layer_input = simple_keras_layer_input_fn(keras_model_input) + decomon_input = simple_decomon_input_fn( + keras_model_input=keras_model_input, + keras_layer_input=keras_layer_input, + output_shape=output_shape, + linear=linear, + ) + + if is_merging_layer: + decomon_symbolic_input = helpers.generate_merging_decomon_input_from_single_decomon_inputs( + decomon_inputs=double_input(decomon_symbolic_input), + ibp=ibp, + affine=affine, + propagation=propagation, + linear=linear, + ) + decomon_input = helpers.generate_merging_decomon_input_from_single_decomon_inputs( + decomon_inputs=double_input(decomon_input), + ibp=ibp, + affine=affine, + propagation=propagation, + linear=linear, + ) + input_shape = double_input(input_shape) + + layer = DecomonOracle( + perturbation_domain=perturbation_domain, + ibp=ibp, + affine=affine, + is_merging_layer=is_merging_layer, + layer_input_shape=input_shape, + ) + symbolic_output = layer(decomon_symbolic_input) + output = layer(decomon_input) + + # check shapes + if is_merging_layer: + output_shape = [[t.shape for t in output_i] for output_i in output] + expected_output_shape = [ + helpers.replace_none_by_batchsize(shapes=[t.shape for t in symbolic_output_i], batchsize=batchsize) + for symbolic_output_i in symbolic_output + ] + assert output_shape == expected_output_shape + + else: + output_shape = [t.shape for t in output] + expected_output_shape = [t.shape for t in symbolic_output] + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..2323eef1 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,254 @@ +import keras.ops as K +import numpy as np +import pytest +from pytest_cases import parametrize + +from decomon.constants import Propagation +from decomon.layers.inputs_outputs_specs import InputsOutputsSpec +from decomon.layers.output import ConvertOutput +from decomon.perturbation_domain import BoxDomain + + +def generate_simple_inputs( + ibp, + affine, + input_shape, + output_shape, + batchsize, + diag, + nobatch, + perturbation_domain, + needs_perturbation_domain_inputs=False, +): + if ibp: + x = K.convert_to_tensor(2.0 * np.random.random((batchsize,) + output_shape) - 1.0) + lower = x - 0.5 + upper = x + 0.5 + constant_bounds = [lower, upper] + else: + constant_bounds = [] + if affine: + if diag: + w = K.ones(input_shape) + else: + w = K.reshape(K.eye(int(np.prod(input_shape)), int(np.prod(output_shape))), input_shape + output_shape) + b = 0.1 * K.ones(output_shape) + if not nobatch: + w = K.repeat(w[None], batchsize, axis=0) + b = K.repeat(b[None], batchsize, axis=0) + affine_bounds = [w, -b, w, b] + else: + affine_bounds = [] + + if needs_perturbation_domain_inputs: + if isinstance(perturbation_domain, BoxDomain): + keras_input = K.convert_to_tensor(2.0 * np.random.random((batchsize,) + input_shape) - 1.0) + perturbation_domain_inputs = [ + K.concatenate([keras_input[:, None] - 0.1, keras_input[:, None] + 0.1], axis=1) + ] + else: + raise NotImplementedError + else: + perturbation_domain_inputs = [] + + return affine_bounds + constant_bounds + perturbation_domain_inputs + + +@parametrize("ibp_from, affine_from", [(True, False), (False, True), (True, True)], ids=["ibp", "affine", "hybrid"]) +@parametrize("ibp_to, affine_to", [(True, False), (False, True), (True, True)], ids=["ibp", "affine", "hybrid"]) +@parametrize( + "diag, nobatch", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], + ids=["diagonal-nobatch", "diagonal", "nobatch", "generic"], +) +@parametrize("input_shape", [(3,), (5, 6, 2)], ids=["1d", "multid"]) +@parametrize("output_shape", [(2,), (4, 5, 1)], ids=["1d", "multid"]) +def test_convert_1output( + ibp_from, + affine_from, + ibp_to, + affine_to, + diag, + nobatch, + input_shape, + output_shape, + batchsize, + perturbation_domain, + helpers, +): + if not affine_from: + if diag or nobatch: + pytest.skip("diag and nobatch have no meaning when affine is False") + if diag: + if len(input_shape) != len(output_shape): + pytest.skip("we need input_shape==output_shape if diag") + else: + output_shape = input_shape + + layer = ConvertOutput( + perturbation_domain=perturbation_domain, + ibp_from=ibp_from, + affine_from=affine_from, + ibp_to=ibp_to, + affine_to=affine_to, + model_output_shapes=[output_shape], + ) + + symbolic_input = helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape, + model_output_shape=None, + layer_input_shape=output_shape, + layer_output_shape=None, + ibp=ibp_from, + affine=affine_from, + propagation=Propagation.FORWARD, + perturbation_domain=perturbation_domain, + diag=diag, + nobatch=nobatch, + remove_perturbation_domain_inputs=True, + add_perturbation_domain_inputs=layer.needs_perturbation_domain_inputs(), + ) + + input = generate_simple_inputs( + ibp=ibp_from, + affine=affine_from, + input_shape=input_shape, + output_shape=output_shape, + batchsize=batchsize, + diag=diag, + nobatch=nobatch, + perturbation_domain=perturbation_domain, + needs_perturbation_domain_inputs=layer.needs_perturbation_domain_inputs(), + ) + + symbolic_output = layer(symbolic_input) + output = layer(input) + + # check shapes + output_shape = [t.shape for t in output] + expected_output_shape = [t.shape for t in symbolic_output] + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape + + inputs_outputs_spec_fused = InputsOutputsSpec( + ibp=ibp_to, affine=layer.affine_to, layer_input_shape=layer.model_output_shapes, is_merging_layer=True + ) + assert len(output_shape) == inputs_outputs_spec_fused.nb_output_tensors * layer.nb_outputs_keras_model + + +@parametrize("ibp_from, affine_from", [(True, False), (False, True), (True, True)], ids=["ibp", "affine", "hybrid"]) +@parametrize("ibp_to, affine_to", [(True, False), (False, True), (True, True)], ids=["ibp", "affine", "hybrid"]) +@parametrize( + "diag_1, nobatch_1", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], + ids=["diagonal-nobatch", "diagonal", "nobatch", "generic"], +) +@parametrize("input_shape", [(3,), (5, 6, 2)], ids=["1d", "multid"]) +@parametrize("output_shape_1", [(2,), (4, 5, 1)], ids=["1d", "multid"]) +@parametrize("output_shape_2", [(3,), (2, 5, 1)], ids=["1d", "multid"]) +def test_convert_2outputs( + ibp_from, + affine_from, + ibp_to, + affine_to, + diag_1, + nobatch_1, + input_shape, + output_shape_1, + output_shape_2, + batchsize, + perturbation_domain, + helpers, +): + if not affine_from: + if diag_1 or nobatch_1: + pytest.skip("diag and nobatch have no meaning when affine is False") + if diag_1: + if len(input_shape) != len(output_shape_1): + pytest.skip("we need input_shape==output_shape if diag") + else: + output_shape_1 = input_shape + + model_output_shapes = [output_shape_1, output_shape_2] + layer = ConvertOutput( + perturbation_domain=perturbation_domain, + ibp_from=ibp_from, + affine_from=affine_from, + ibp_to=ibp_to, + affine_to=affine_to, + model_output_shapes=model_output_shapes, + ) + + symbolic_input_1 = helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape, + model_output_shape=None, + layer_input_shape=output_shape_1, + layer_output_shape=None, + ibp=ibp_from, + affine=affine_from, + propagation=Propagation.FORWARD, + perturbation_domain=perturbation_domain, + diag=diag_1, + nobatch=nobatch_1, + remove_perturbation_domain_inputs=True, + ) + symbolic_input_2 = helpers.get_decomon_symbolic_inputs( + model_input_shape=input_shape, + model_output_shape=None, + layer_input_shape=output_shape_2, + layer_output_shape=None, + ibp=ibp_from, + affine=affine_from, + propagation=Propagation.FORWARD, + perturbation_domain=perturbation_domain, + remove_perturbation_domain_inputs=True, + add_perturbation_domain_inputs=layer.needs_perturbation_domain_inputs(), + ) + symbolic_input = symbolic_input_1 + symbolic_input_2 + + input_1 = generate_simple_inputs( + ibp=ibp_from, + affine=affine_from, + input_shape=input_shape, + output_shape=output_shape_1, + batchsize=batchsize, + diag=diag_1, + nobatch=nobatch_1, + perturbation_domain=perturbation_domain, + ) + input_2 = generate_simple_inputs( + ibp=ibp_from, + affine=affine_from, + input_shape=input_shape, + output_shape=output_shape_2, + batchsize=batchsize, + perturbation_domain=perturbation_domain, + needs_perturbation_domain_inputs=layer.needs_perturbation_domain_inputs(), + diag=False, + nobatch=False, + ) + input = input_1 + input_2 + + symbolic_output = layer(symbolic_input) + output = layer(input) + + # check shapes + output_shape = [t.shape for t in output] + expected_output_shape = [t.shape for t in symbolic_output] + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape + + inputs_outputs_spec_fused = InputsOutputsSpec( + ibp=ibp_to, affine=layer.affine_to, layer_input_shape=layer.model_output_shapes, is_merging_layer=True + ) + assert len(output_shape) == inputs_outputs_spec_fused.nb_output_tensors * layer.nb_outputs_keras_model diff --git a/tests/test_pooling.py b/tests/test_pooling.py deleted file mode 100644 index aaa45296..00000000 --- a/tests/test_pooling.py +++ /dev/null @@ -1,49 +0,0 @@ -import keras.config as keras_config -import pytest -from keras.layers import MaxPooling2D - -from decomon.layers.maxpooling import DecomonMaxPooling2D - - -def test_MaxPooling2D_box(mode, floatx, decimal, helpers): - # skip unavailable combinations - if floatx == 16 and keras_config.backend() == "torch" and not helpers.in_GPU_mode(): - pytest.skip("Pytorch does not implement maxpooling for float16 in CPU mode.") - - odd, m_0, m_1 = 0, 0, 1 - data_format = "channels_last" - dc_decomp = True - fast = False - kwargs_layer = dict(pool_size=(2, 2), strides=(2, 2), padding="valid", dtype=keras_config.floatx()) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - - # keras & decomon layer - keras_layer = MaxPooling2D(**kwargs_layer) - decomon_layer = DecomonMaxPooling2D(dc_decomp=dc_decomp, fast=fast, mode=mode, **kwargs_layer) - - # original output - output_ref = keras_layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - output_ref_ = f_ref(inputs_) - - # decomon outputs - outputs = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - decimal=decimal, - mode=mode, - ) diff --git a/tests/test_models_utils.py b/tests/test_preprocess_keras_layer.py similarity index 98% rename from tests/test_models_utils.py rename to tests/test_preprocess_keras_layer.py index d15c344c..a2efedb3 100644 --- a/tests/test_models_utils.py +++ b/tests/test_preprocess_keras_layer.py @@ -1,4 +1,3 @@ -import keras import keras.ops as K import numpy as np import pytest @@ -6,7 +5,6 @@ from keras.layers import Activation, Conv2D, Dense, Layer, Permute, PReLU from numpy.testing import assert_almost_equal -from decomon.keras_utils import get_weight_index_from_name from decomon.models.utils import preprocess_layer, split_activation diff --git a/tests/test_preprocess_keras_model.py b/tests/test_preprocess_keras_model.py index 7c40b5fe..bed2c226 100644 --- a/tests/test_preprocess_keras_model.py +++ b/tests/test_preprocess_keras_model.py @@ -1,7 +1,8 @@ import keras.ops as K import numpy as np import pytest -from keras.layers import Activation, Conv2D, Dense, Flatten, Input, PReLU +from keras.activations import softmax +from keras.layers import Activation, Conv2D, Dense, Input, PReLU from keras.models import Model, Sequential from numpy.testing import assert_almost_equal @@ -11,23 +12,12 @@ ) -def test_split_activations_in_keras_model_no_inputshape_ko(): - layers = [ - Conv2D( - 10, - kernel_size=(3, 3), - activation="relu", - data_format="channels_last", - ), - Flatten(), - Dense(1), - ] - model = Sequential(layers) - with pytest.raises(ValueError): - converted_model = split_activations_in_keras_model(model) - +def test_split_activations_in_keras_model(toy_model_fn, input_shape, toy_model_name): + # skip cnn on 0d or 1d input_shape + if toy_model_name == "cnn" and len(input_shape) == 1: + pytest.skip("cnn not possible on 0d or 1d input.") -def test_split_activations_in_keras_model(toy_model): + toy_model = toy_model_fn(input_shape=input_shape) converted_model = split_activations_in_keras_model(toy_model) assert isinstance(converted_model, Model) # check no more activation functions in non-activation layers @@ -93,3 +83,20 @@ def test_preprocess( output_np_ref = K.convert_to_numpy(model(inputs_np)) output_np_new = K.convert_to_numpy(converted_model(inputs_np)) assert_almost_equal(output_np_new, output_np_ref, decimal=4) + + +def test_preprocess_keras_model_with_softmax(): + input_tensor = Input((1,)) + + output_tensor = Dense(3, activation="relu")(input_tensor) + + output_tensor1 = Dense(2)(output_tensor) + output_tensor2 = Activation(activation="softmax")(Dense(3)(output_tensor)) + output_tensor3 = Dense(4, activation="softmax")(output_tensor) + + model = Model(input_tensor, [output_tensor1, output_tensor2, output_tensor3]) + + new_model = preprocess_keras_model(model=model, rm_last_softmax=True) + + assert len(new_model.layers) == 6 + assert not any([(isinstance(layer, Activation) and layer.activation == softmax) for layer in new_model.layers]) diff --git a/tests/test_reshape.py b/tests/test_reshape.py deleted file mode 100644 index 105337db..00000000 --- a/tests/test_reshape.py +++ /dev/null @@ -1,137 +0,0 @@ -import keras.config as keras_config -import numpy as np -import pytest -from keras.layers import Permute, Reshape - -from decomon.core import ForwardMode, get_affine, get_ibp -from decomon.layers.convert import to_decomon -from decomon.layers.decomon_reshape import DecomonPermute, DecomonReshape - - -def keras_target_shape_reshape(input_ref): - return (int(np.prod(input_ref.shape[1:])),) - - -def target_shape_keras2np_reshape(target_shape): - return (-1, target_shape[0]) - - -def keras_target_shape_permute(input_ref): - n_dim = len(input_ref.shape) - 1 - return np.random.permutation(n_dim) + 1 - - -def target_shape_keras2np_permute(target_shape): - return tuple([0] + list(target_shape)) - - -@pytest.mark.parametrize( - "decomon_layer_class, keras_target_shape_func, target_shape_keras2np_func, np_func", - [ - (DecomonReshape, keras_target_shape_reshape, target_shape_keras2np_reshape, np.reshape), - (DecomonPermute, keras_target_shape_permute, target_shape_keras2np_permute, np.transpose), - ], -) -def test_Decomon_reshape_n_permute_box( - decomon_layer_class, - keras_target_shape_func, - target_shape_keras2np_func, - np_func, - mode, - dc_decomp, - floatx, - decimal, - helpers, -): - odd, m_0, m_1 = 0, 0, 1 - data_format = "channels_last" - - # tensor inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs=inputs_) - - # target shape - target_shape = keras_target_shape_func(input_ref) - target_shape_ = target_shape_keras2np_func(target_shape) - - # original output - output_ref_ = np_func(input_ref_, target_shape_) - - # decomon layer - decomon_layer = decomon_layer_class(target_shape, dc_decomp=dc_decomp, mode=mode, dtype=keras_config.floatx()) - - # decomon output - output = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, output) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) - - -@pytest.mark.parametrize( - "keras_target_shape_func, keras_layer_class", - [ - (keras_target_shape_reshape, Reshape), - (keras_target_shape_permute, Permute), - ], -) -def test_Decomon_reshape_n_permute_to_decomon_box( - keras_target_shape_func, keras_layer_class, shared, floatx, decimal, helpers -): - odd, m_0, m_1 = 0, 0, 1 - dc_decomp = True - data_format = "channels_last" - mode = ForwardMode.HYBRID - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - - #  target shape - target_shape = keras_target_shape_func(input_ref) - - # keras layer - keras_layer = keras_layer_class(target_shape, dtype=keras_config.floatx()) - - # original output - output_ref = keras_layer(input_ref) - f_ref = helpers.function(inputs, output_ref) - output_ref_ = f_ref(inputs_) - - # conversion with to_decomon - input_dim = helpers.get_input_dim_from_full_inputs(inputs_) - decomon_layer = to_decomon(keras_layer, input_dim, dc_decomp=dc_decomp, shared=shared, ibp=ibp, affine=affine) - - # decomon outputs - outputs = decomon_layer(inputs_for_mode) - f_decomon = helpers.function(inputs, outputs) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - dc_decomp=dc_decomp, - mode=mode, - decimal=decimal, - ) diff --git a/tests/test_to_decomon.py b/tests/test_to_decomon.py index c6218d07..ef19c263 100644 --- a/tests/test_to_decomon.py +++ b/tests/test_to_decomon.py @@ -1,78 +1,185 @@ +import logging +from importlib import reload +from typing import Any, Optional + import pytest -from keras.layers import Add, Conv2D, Dense, Input, Layer, Reshape +from keras.layers import Activation, Dense, Input, Layer +import decomon.layers +import decomon.layers.convert +from decomon.constants import Propagation, Slope +from decomon.layers import DecomonActivation, DecomonDense, DecomonLayer from decomon.layers.convert import to_decomon -from decomon.layers.utils import is_a_merge_layer +from decomon.perturbation_domain import BoxDomain, PerturbationDomain +# Add a new class in decomon.layers namespace for testing conversion by name +# We must do it *before* importing decomon.layers.convert +decomon.layers.DecomonToto = DecomonDense +reload(decomon.layers.convert) +from decomon.layers.convert import to_decomon -class MyLayer(Layer): - """Mock layer unknown from decomon.""" +class Toto(Dense): ... -class MyMerge(Layer): - """Mock merge layer unknown from decomon.""" - - def _merge_function(self, inputs): - return inputs - - -def test_is_merge_layer(): - layer = MyMerge() - assert is_a_merge_layer(layer) - layer = MyLayer() - assert not is_a_merge_layer(layer) - - -def test_to_decomon_merge_not_built_ko(): - layer = MyMerge() - with pytest.raises(ValueError): - to_decomon(layer, input_dim=1) - - -def test_to_decomon_not_built_ko(): - layer = MyLayer() - with pytest.raises(ValueError): - to_decomon(layer, input_dim=1) +class MyBoxDomain(BoxDomain): + ... -def test_to_decomon_merge_not_implemented_ko(): - layer = MyMerge() - layer.built = True - with pytest.raises(NotImplementedError): - to_decomon(layer, input_dim=1) +class MyDenseDecomonLayer(DecomonLayer): + def __init__( + self, + layer: Layer, + perturbation_domain: Optional[PerturbationDomain] = None, + ibp: bool = True, + affine: bool = True, + propagation: Propagation = Propagation.FORWARD, + model_input_shape: Optional[tuple[int, ...]] = None, + model_output_shape: Optional[tuple[int, ...]] = None, + my_super_attribute: float = 0.0, + **kwargs: Any, + ): + super().__init__( + layer, perturbation_domain, ibp, affine, propagation, model_input_shape, model_output_shape, **kwargs + ) + self.my_super_attribute = my_super_attribute + + +class MyKerasDenseLayer(Dense): + ... -def test_to_decomon_not_implemented_ko(): - layer = MyLayer() - layer.built = True +ibp = True +affine = False +propagation = Propagation.BACKWARD +perturbation_domain = MyBoxDomain() +slope = Slope.Z_SLOPE +model_input_shape = (1,) +model_output_shape = (1,) + + +def test_to_decomon_userdefined(): + mymapping = {Dense: MyDenseDecomonLayer} + my_super_attribute = 123.0 + layer = Dense(3) + layer(Input((1,))) + decomon_layer = to_decomon( + layer=layer, + perturbation_domain=perturbation_domain, + ib=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + my_super_attribute=my_super_attribute, + mapping_keras2decomon_classes=mymapping, + ) + assert isinstance(decomon_layer, MyDenseDecomonLayer) + + assert decomon_layer.ibp == ibp + assert decomon_layer.affine == affine + assert decomon_layer.propagation == propagation + assert decomon_layer.perturbation_domain == perturbation_domain + assert decomon_layer.model_input_shape == model_input_shape + assert decomon_layer.model_output_shape == model_output_shape + + assert decomon_layer.my_super_attribute == my_super_attribute + + +def test_to_decomon_default(caplog): + layer = Dense(3) + layer(Input((1,))) + + with caplog.at_level(logging.WARNING): + decomon_layer = to_decomon( + layer=layer, + perturbation_domain=perturbation_domain, + ib=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + ) + + assert isinstance(decomon_layer, DecomonDense) + + assert decomon_layer.ibp == ibp + assert decomon_layer.affine == affine + assert decomon_layer.propagation == propagation + assert decomon_layer.perturbation_domain == perturbation_domain + assert decomon_layer.model_input_shape == model_input_shape + assert decomon_layer.model_output_shape == model_output_shape + + assert "using class name" not in caplog.text + + +def test_to_decomon_slope(): + layer = Activation(activation=None) + layer(Input((1,))) + decomon_layer = to_decomon( + layer=layer, + perturbation_domain=perturbation_domain, + ib=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + ) + assert isinstance(decomon_layer, DecomonActivation) + + assert decomon_layer.ibp == ibp + assert decomon_layer.affine == affine + assert decomon_layer.propagation == propagation + assert decomon_layer.perturbation_domain == perturbation_domain + assert decomon_layer.model_input_shape == model_input_shape + assert decomon_layer.model_output_shape == model_output_shape + + assert decomon_layer.slope == slope + + +def test_to_decomon_by_name(caplog): + layer = Toto(3) + layer(Input((1,))) + + with caplog.at_level(logging.WARNING): + decomon_layer = to_decomon( + layer=layer, + perturbation_domain=perturbation_domain, + ib=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + ) + + assert isinstance(decomon_layer, DecomonDense) + + assert decomon_layer.ibp == ibp + assert decomon_layer.affine == affine + assert decomon_layer.propagation == propagation + assert decomon_layer.perturbation_domain == perturbation_domain + assert decomon_layer.model_input_shape == model_input_shape + assert decomon_layer.model_output_shape == model_output_shape + + assert "using class name" in caplog.text + + +def test_to_decomon_nok(): + layer = MyKerasDenseLayer(3) + layer(Input((1,))) with pytest.raises(NotImplementedError): - to_decomon(layer, input_dim=1) - - -@pytest.mark.parametrize( - "layer_class, layer_kwargs, input_shape_wo_batchsize, nb_inputs", - [ - (Dense, {"units": 3, "use_bias": True}, (1,), 1), - (Conv2D, {"filters": 2, "kernel_size": (3, 3), "use_bias": True}, (64, 64, 3), 1), - (Dense, {"units": 3, "use_bias": False}, (1,), 1), - (Conv2D, {"filters": 2, "kernel_size": (3, 3), "use_bias": False}, (64, 64, 3), 1), - (Reshape, {"target_shape": (72,)}, (6, 6, 2), 1), - (Add, {}, (1,), 2), - ], -) -def test_to_decomon_ok(layer_class, layer_kwargs, input_shape_wo_batchsize, nb_inputs): - layer = layer_class(**layer_kwargs) - # init input_shape and weights - # input_tensors + build layer - if nb_inputs == 1: - input_tensor = Input(input_shape_wo_batchsize) - layer(input_tensor) - else: - input_tensors = [Input(input_shape_wo_batchsize) for _ in range(nb_inputs)] - layer(input_tensors) - decomon_layer = to_decomon(layer, input_dim=1) - # check trainable weights - for i in range(len(layer.weights)): - assert layer.weights[i] is decomon_layer.weights[i] + to_decomon( + layer=layer, + perturbation_domain=perturbation_domain, + ib=ibp, + affine=affine, + propagation=propagation, + model_input_shape=model_input_shape, + model_output_shape=model_output_shape, + slope=slope, + ) diff --git a/tests/test_unary_layers.py b/tests/test_unary_layers.py new file mode 100644 index 00000000..ecbedce7 --- /dev/null +++ b/tests/test_unary_layers.py @@ -0,0 +1,167 @@ +import keras.ops as K +import numpy as np +from keras.layers import Activation, Dense +from pytest_cases import fixture, fixture_union, parametrize, unpack_fixture + +from decomon.keras_utils import batch_multid_dot +from decomon.layers import DecomonActivation, DecomonDense +from decomon.layers.activations.activation import DecomonLinear + + +@fixture +def dense_keras_kwargs(use_bias): + return dict(units=7, use_bias=use_bias) + + +def _activation_kwargs(activation, slope=None): + keras_kwargs = dict(activation=activation) + if activation == "relu": + decomon_kwargs = dict(slope=slope) + else: + decomon_kwargs = {} + return keras_kwargs, decomon_kwargs + + +@fixture +@parametrize("activation", [None, "softsign"]) +def non_relu_activation_kwargs(activation): + return _activation_kwargs(activation) + + +@fixture +def relu_activation_kwargs(slope): + return _activation_kwargs(activation="relu", slope=slope) + + +activation_kwargs = fixture_union( + "activation_kwargs", + [non_relu_activation_kwargs, relu_activation_kwargs], +) +activation_keras_kwargs, activation_decomon_kwargs = unpack_fixture( + "activation_keras_kwargs, activation_decomon_kwargs", activation_kwargs +) + + +@parametrize( + "decomon_layer_class, decomon_layer_kwargs, keras_layer_class, keras_layer_kwargs, is_actually_linear", + [ + (DecomonDense, {}, Dense, dense_keras_kwargs, None), + (DecomonActivation, activation_decomon_kwargs, Activation, activation_keras_kwargs, None), + ], +) +def test_decomon_unary_layer( + decomon_layer_class, + decomon_layer_kwargs, + keras_layer_class, + keras_layer_kwargs, + is_actually_linear, + ibp, + affine, + propagation, + perturbation_domain, + batchsize, + keras_symbolic_model_input_fn, + keras_symbolic_layer_input_fn, + decomon_symbolic_input_fn, + keras_model_input_fn, + keras_layer_input_fn, + decomon_input_fn, + equal_ibp_bounds, + equal_affine_bounds, + helpers, +): + decimal = 4 + if is_actually_linear is None: + is_actually_linear = decomon_layer_class.linear + + # init + build keras layer + keras_symbolic_model_input = keras_symbolic_model_input_fn() + keras_symbolic_layer_input = keras_symbolic_layer_input_fn(keras_symbolic_model_input) + layer = keras_layer_class(**keras_layer_kwargs) + layer(keras_symbolic_layer_input) + + # randomize weights between -1 and 1 => non-zero biases + for w in layer.weights: + w.assign(2.0 * np.random.random(w.shape) - 1.0) + + # init + build decomon layer + output_shape = layer.output.shape[1:] + model_output_shape = output_shape + model_input_shape = keras_symbolic_model_input.shape[1:] + + decomon_layer = decomon_layer_class( + layer=layer, + ibp=ibp, + affine=affine, + propagation=propagation, + perturbation_domain=perturbation_domain, + model_output_shape=model_output_shape, + model_input_shape=model_input_shape, + **decomon_layer_kwargs, + ) + + decomon_symbolic_inputs = decomon_symbolic_input_fn(output_shape=output_shape, linear=decomon_layer.linear) + decomon_layer(decomon_symbolic_inputs) + + # call on actual inputs + keras_model_input = keras_model_input_fn() + keras_layer_input = keras_layer_input_fn(keras_model_input) + decomon_inputs = decomon_input_fn( + keras_model_input=keras_model_input, + keras_layer_input=keras_layer_input, + output_shape=output_shape, + linear=decomon_layer.linear, + ) + + keras_output = layer(keras_layer_input) + decomon_output = decomon_layer(decomon_inputs) + + # check affine representation is ok (except for linear activation, undefined) + if decomon_layer.linear and not ( + (isinstance(decomon_layer, DecomonActivation) and isinstance(decomon_layer.decomon_activation, DecomonLinear)) + or isinstance(decomon_layer, DecomonLinear) + ): + w, b = decomon_layer.get_affine_representation() + diagonal = (False, w.shape == b.shape) + missing_batchsize = (False, True) + keras_output_2 = ( + batch_multid_dot(keras_layer_input, w, missing_batchsize=missing_batchsize, diagonal=diagonal) + b + ) + np.testing.assert_almost_equal( + K.convert_to_numpy(keras_output), + K.convert_to_numpy(keras_output_2), + decimal=decimal, + err_msg="wrong affine representation", + ) + + # check output shapes + input_shape = [t.shape for t in decomon_inputs] + output_shape = [t.shape for t in decomon_output] + expected_output_shape = decomon_layer.compute_output_shape(input_shape) + expected_output_shape = helpers.replace_none_by_batchsize(shapes=expected_output_shape, batchsize=batchsize) + assert output_shape == expected_output_shape + + # check ibp and affine bounds well ordered w.r.t. keras inputs/outputs + helpers.assert_decomon_output_compare_with_keras_input_output_layer( + decomon_output=decomon_output, + keras_model_input=keras_model_input, + keras_layer_input=keras_layer_input, + keras_model_output=keras_output, + keras_layer_output=keras_output, + ibp=ibp, + affine=affine, + propagation=propagation, + decimal=decimal, + ) + + # before propagation through linear layer lower == upper => lower == upper after propagation + if is_actually_linear: + helpers.assert_decomon_output_lower_equal_upper( + decomon_output, + ibp=ibp, + affine=affine, + propagation=propagation, + decimal=decimal, + check_ibp=equal_ibp_bounds, + check_affine=equal_affine_bounds, + ) diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 9856401a..00000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,503 +0,0 @@ -# Test unit for decomon with Dense layers - - -import keras.ops as K -import numpy as np -import pytest -from numpy.testing import assert_allclose, assert_almost_equal - -from decomon.core import BoxDomain, ForwardMode -from decomon.layers.utils import add, max_, maximum, minus, relu_ -from decomon.utils import subtract - - -def test_get_upper_multi_box(odd, floatx, decimal, helpers): - inputs = helpers.get_tensor_decomposition_multid_box(odd) - inputs_ = helpers.get_standard_values_multid_box(odd) - - x, y, x_0, u_c, W_u, b_u, _, _, _, _, _ = inputs - x_, y_, x_0_, u_c_, W_u_, b_u_, _, _, _, _, _ = inputs_ - - # compute maximum - x_min_ = x_0_[:, 0][:, :, None] - x_max_ = x_0_[:, 1][:, :, None] - upper_pred = np.sum(np.minimum(W_u_, 0) * x_min_ + np.maximum(W_u_, 0) * x_max_, 1) + b_u_ - - upper = BoxDomain().get_upper(x_0, W_u, b_u) - - f_upper = helpers.function([x_0, W_u, b_u], upper) - upper_ = f_upper([x_0_, W_u_, b_u_]) - - assert_allclose(upper_pred, upper_) - - -def test_get_upper_box_numpy(n, floatx, decimal, helpers): - inputs = helpers.get_tensor_decomposition_1d_box() - inputs_ = helpers.get_standard_values_1d_box(n) - - x, y, x_0, u_c, W_u, b_u, _, _, _, _, _ = inputs - x_, y_, x_0_, u_c_, W_u_, b_u_, _, _, _, _, _ = inputs_ - - x_expand = x_ + np.zeros_like(x_) - n_expand = len(W_u_.shape) - len(x_expand.shape) - for i in range(n_expand): - x_expand = np.expand_dims(x_expand, -1) - - upper_pred = np.sum(W_u_ * x_expand, 1) + b_u_ - upper_pred = upper_pred.max(0) - - upper = BoxDomain().get_upper(x_0, W_u, b_u) - - f_upper = helpers.function([x_0, W_u, b_u], upper) - upper_ = f_upper([x_0_, W_u_, b_u_]).max() - - assert_allclose(upper_pred, upper_) - - -def test_get_upper_box(n, floatx, decimal, helpers): - inputs = helpers.get_tensor_decomposition_1d_box() - inputs_ = helpers.get_standard_values_1d_box(n) - - x, y, x_0, u_c, W_u, b_u, _, _, _, _, _ = inputs - _, _, x_0_, u_c_, W_u_, b_u_, _, _, _, _, _ = inputs_ - - upper = BoxDomain().get_upper(x_0, W_u, b_u) - - f_upper = helpers.function([x_0, W_u, b_u], upper) - f_u = helpers.function(inputs, u_c) - - output = inputs_[1] - - upper_ = f_upper([x_0_, W_u_, b_u_]) - u_c_ = f_u(inputs_) - - assert_almost_equal( - np.clip(output - upper_, 0, np.inf), - np.zeros_like(output), - decimal=decimal, - err_msg="upper_= 0: - # check that we find this case ! - if mode in [ForwardMode.AFFINE, ForwardMode.HYBRID]: - assert_almost_equal( - w_u_output, - W_u_input, - decimal=decimal, - err_msg="w_u_!=W_u but lower_>=0 in call {}".format(n), - ) - assert_almost_equal( - b_u_output, - b_u_input, - decimal=decimal, - err_msg="b_u_!=b_u but lower_>=0 in call {}".format(n), - ) - assert_almost_equal( - w_l_output, - W_l_input, - decimal=decimal, - err_msg="w_l_!=W_l but lower_>=0 upper_<=0 in call {}".format(n), - ) - assert_almost_equal( - b_l_output, - b_l_input, - decimal=decimal, - err_msg="b_l_!=b_l but lower_>=0 upper_<=0 in call {}".format(n), - ) - if mode in [ForwardMode.IBP, ForwardMode.HYBRID]: - assert_almost_equal( - u_c_output, - u_c_input, - decimal=decimal, - err_msg="u_c_!=u_c but lower_>=0 in call {}".format(n), - ) - assert_almost_equal( - l_c_output, - l_c_input, - decimal=decimal, - err_msg="l_c_!=l_c but lower_>=0 in call {}".format(n), - ) - - if mode != ForwardMode.IBP: - assert_almost_equal( - z_input[:, 0], z_output[:, 0], decimal=decimal, err_msg="the lower bound should be unchanged" - ) - assert_almost_equal( - z_input[:, 1], z_output[:, 1], decimal=decimal, err_msg="the upper bound should be unchanged" - ) - - -def add_op(x, y): - return x + y - - -def subtract_op(x, y): - return x - y - - -def minus_op(x): - return -x - - -@pytest.mark.parametrize( - "decomon_func, tensor_func", - [ - (add, add_op), - (subtract, subtract_op), - (maximum, K.maximum), - ], -) -def test_func_with_2_inputs(decomon_func, tensor_func, odd, mode, floatx, decimal, helpers): - dc_decomp = True - - #  tensor inputs - inputs_0 = helpers.get_tensor_decomposition_multid_box(odd) - inputs_1 = helpers.get_tensor_decomposition_multid_box(odd) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - input_ref_0 = helpers.get_input_ref_from_full_inputs(inputs=inputs_0) - input_ref_1 = helpers.get_input_ref_from_full_inputs(inputs=inputs_1) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd) - - # original output - f_ref = helpers.function(inputs_0 + inputs_1, tensor_func(input_ref_0, input_ref_1)) - output_ref_ = f_ref(inputs_ + inputs_) - - # decomon output - output = decomon_func(inputs_for_mode_0, inputs_for_mode_1, dc_decomp=dc_decomp, mode=mode) - f_decomon = helpers.function(inputs_0 + inputs_1, output) - outputs_ = f_decomon(inputs_ + inputs_) - - #  check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) - - -@pytest.mark.parametrize( - "decomon_func, tensor_func, tensor_func_kwargs", - [ - (minus, minus_op, None), - (max_, K.max, {"axis": -1}), - ], -) -def test_func_with_1_input(decomon_func, tensor_func, tensor_func_kwargs, odd, mode, floatx, decimal, helpers): - dc_decomp = True - if tensor_func_kwargs is None: - tensor_func_kwargs = {} - - # tensor inputs - inputs = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - # original output - f_ref = helpers.function(inputs, tensor_func(input_ref, **tensor_func_kwargs)) - output_ref_ = f_ref(inputs_) - - # decomon output - output = decomon_func(inputs_for_mode, dc_decomp=dc_decomp, mode=mode) - f_decomon = helpers.function(inputs, output) - outputs_ = f_decomon(inputs_) - - # check bounds consistency - helpers.assert_decomon_layer_output_properties_box( - full_inputs=inputs_, - output_ref=output_ref_, - outputs_for_mode=outputs_, - mode=mode, - dc_decomp=dc_decomp, - decimal=decimal, - ) - - -# DC_DECOMP = FALSE -def test_max_nodc(odd, helpers): - dc_decomp = False - mode = ForwardMode.HYBRID - - inputs = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - output = max_(inputs_for_mode, dc_decomp=dc_decomp, mode=mode) - f_max = helpers.function(inputs, output) - assert len(f_max(inputs_)) == 7 - - -def test_maximum_nodc(odd, helpers): - dc_decomp = False - mode = ForwardMode.HYBRID - - inputs_0 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_1 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - input_ref_0 = helpers.get_input_ref_from_full_inputs(inputs=inputs_0) - input_ref_1 = helpers.get_input_ref_from_full_inputs(inputs=inputs_1) - - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - output = maximum(inputs_for_mode_0, inputs_for_mode_1, dc_decomp=dc_decomp, mode=mode) - - f_ref = helpers.function(inputs_0 + inputs_1, K.maximum(input_ref_0, input_ref_1)) - f_maximum = helpers.function(inputs_0 + inputs_1, output) - - assert len(f_maximum(inputs_ + inputs_)) == 7 - f_ref(inputs_ + inputs_) - - -def test_minus_nodc(odd, helpers): - dc_decomp = False - mode = ForwardMode.HYBRID - - inputs = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - output = minus(inputs_for_mode, dc_decomp=dc_decomp, mode=mode) - - f_ref = helpers.function(inputs, -inputs[1]) - f_minus = helpers.function(inputs, output) - - assert len(f_minus(inputs_)) == 7 - f_ref(inputs_) - - -def test_add_nodc(odd, helpers): - dc_decomp = False - mode = ForwardMode.HYBRID - - inputs_0 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_1 = helpers.get_tensor_decomposition_multid_box(odd, dc_decomp=dc_decomp) - inputs_for_mode_0 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_0, mode=mode, dc_decomp=dc_decomp) - inputs_for_mode_1 = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs_1, mode=mode, dc_decomp=dc_decomp) - input_ref_0 = helpers.get_input_ref_from_full_inputs(inputs=inputs_0) - input_ref_1 = helpers.get_input_ref_from_full_inputs(inputs=inputs_1) - - inputs_ = helpers.get_standard_values_multid_box(odd, dc_decomp=dc_decomp) - - output = add(inputs_for_mode_0, inputs_for_mode_1, dc_decomp=dc_decomp, mode=mode) - f_ref = helpers.function(inputs_0 + inputs_1, input_ref_0 + input_ref_1) - f_add = helpers.function(inputs_0 + inputs_1, output) - assert len(f_add(inputs_ + inputs_)) == 7 - f_ref(inputs_ + inputs_) - - -def test_relu_1D_box_nodc(n, helpers): - dc_decomp = False - mode = ForwardMode.HYBRID - - inputs = helpers.get_tensor_decomposition_1d_box(dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs - - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=dc_decomp) - - output = relu_(inputs_for_mode, dc_decomp=dc_decomp, mode=mode) - lower = BoxDomain().get_lower(z, W_l, b_l) - upper = BoxDomain().get_upper(z, W_u, b_u) - - f_lower = helpers.function(inputs, lower) - f_upper = helpers.function(inputs, upper) - - np.min(f_lower(inputs_)) - np.max(f_upper(inputs_)) - - f_relu_ = helpers.function(inputs, output) - assert len(f_relu_(inputs_)) == 7 diff --git a/tests/test_utils_conv.py b/tests/test_utils_conv.py deleted file mode 100644 index bbd1eb57..00000000 --- a/tests/test_utils_conv.py +++ /dev/null @@ -1,141 +0,0 @@ -# Test unit for decomon with Dense layers -import keras.config -import keras.ops as K -import numpy as np -import pytest -from keras.layers import Conv2D -from numpy.testing import assert_almost_equal - -from decomon.backward_layers.utils_conv import get_toeplitz -from decomon.core import get_affine, get_ibp -from decomon.layers.convert import to_decomon - - -def test_toeplitz_from_Keras(channels, filter_size, strides, flatten, data_format, padding, floatx, decimal, helpers): - # skip unavailable combinations - if floatx == 16 and keras.config.backend() == "torch": - pytest.skip("Pytorch does not implement conv2d for float16") - - # filter_size, strides, flatten, - if floatx == 16: - decimal = 0 - - if data_format == "channels_first" and keras.config.backend() == "tensorflow" and not helpers.in_GPU_mode(): - pytest.skip("data format 'channels first' is possible only in GPU mode for tensorflow") - - if data_format == "channels_first": - pytest.xfail("get_toeplitz with channels_first is bugged for now.") - - dc_decomp = False - odd, m_0, m_1 = 0, 0, 1 - - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - - # should be working either with convolution of conv2D - kwargs_layer = dict( - data_format=data_format, - filters=channels, - kernel_size=(filter_size, filter_size), - strides=strides, - padding=padding, - use_bias=False, - dtype=input_ref.dtype, - ) - - layer = Conv2D(**kwargs_layer) - - result_ref = layer(input_ref) - W = get_toeplitz(layer, flatten) - - if not flatten: - w_in, h_in, c_in, w_out, h_out, c_out = W.shape - W = K.reshape(W, (w_in * h_in * c_in, w_out * h_out * c_out)) - - n_in, n_out = W.shape - y_flat = K.reshape(input_ref, (-1, n_in, 1)) - result_flat = K.reshape(result_ref, (-1, n_out)) - result_toeplitz = K.sum(W[None] * y_flat, 1) - output_test = K.sum((result_toeplitz - result_flat) ** 2) - f_test = helpers.function(inputs, output_test) - output_test_ = f_test(inputs_) - - assert_almost_equal( - output_test_, - np.zeros_like(output_test_), - decimal=decimal, - err_msg="wrong toeplitz matrix", - ) - - -def test_toeplitz_from_Decomon( - floatx, decimal, mode, channels, filter_size, strides, flatten, data_format, padding, helpers -): - # skip unavailable combinations - if floatx == 16 and keras.config.backend() == "torch" and not helpers.in_GPU_mode(): - pytest.skip("Pytorch does not implement conv2d for float16 in CPU mode.") - - if data_format == "channels_first" and keras.config.backend() == "tensorflow" and not helpers.in_GPU_mode(): - pytest.skip("data format 'channels first' is possible only in GPU mode for tensorflow") - - if data_format == "channels_first": - pytest.xfail("get_toeplitz with channels_first is bugged for now.") - - odd, m_0, m_1 = 0, 0, 1 - if floatx == 16: - decimal = 0 - - dc_decomp = False - ibp = get_ibp(mode=mode) - affine = get_affine(mode=mode) - - # tensor inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - input_ref = helpers.get_input_ref_from_full_inputs(inputs=inputs) - - # numpy inputs - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - kwargs_layer = dict( - filters=channels, - kernel_size=(filter_size, filter_size), - strides=strides, - padding=padding, - use_bias=False, - dtype=input_ref.dtype, - data_format=data_format, - ) - - # should be working either with convolution of conv2D - layer = Conv2D(**kwargs_layer) - result_ref = layer(input_ref) - - # toeplitz matrix should be compatible with a DecomonLayer - input_dim = input_ref.shape[-1] - - decomon_layer = to_decomon(layer, input_dim, ibp=ibp, affine=affine) - - W = get_toeplitz(decomon_layer, flatten) - - if not flatten: - w_in, h_in, c_in, w_out, h_out, c_out = W.shape - W = K.reshape(W, (w_in * h_in * c_in, w_out * h_out * c_out)) - - n_in, n_out = W.shape - y_flat = K.reshape(input_ref, (-1, n_in, 1)) - result_flat = K.reshape(result_ref, (-1, n_out)) - result_toeplitz = K.sum(W[None] * y_flat, 1) - - output_test = K.sum((result_toeplitz - result_flat) ** 2) - - f_test = helpers.function(inputs, output_test) - output_test_ = f_test(inputs_) - - assert_almost_equal( - output_test_, - np.zeros_like(output_test_), - decimal=decimal, - err_msg="wrong toeplitz matrix", - ) diff --git a/tests/test_utils_pooling.py b/tests/test_utils_pooling.py deleted file mode 100644 index 3f776367..00000000 --- a/tests/test_utils_pooling.py +++ /dev/null @@ -1,54 +0,0 @@ -import keras.ops as K -import numpy as np -import pytest -from numpy.testing import assert_almost_equal - -from decomon.layers.utils_pooling import ( - get_lower_linear_hull_max, - get_upper_linear_hull_max, -) - - -@pytest.mark.parametrize( - "func, minmax, clipmin, clipmax", - [ - (get_lower_linear_hull_max, np.max, -np.inf, 0.0), - (get_upper_linear_hull_max, np.min, 0.0, np.inf), - ], -) -def test_get_lower_upper_linear_hull_max( - func, minmax, clipmin, clipmax, mode, floatx, decimal, axis, finetune_odd, helpers -): - if finetune_odd is not None and func is not get_lower_linear_hull_max: - # skip test with finetune if not get_lower - pytest.skip("finetune_odd is only intended for get_lower_linear_hull_max()") - if func is get_lower_linear_hull_max: - pytest.xfail("get_lower_linear_hull_max() is bugged") - - odd, m_0, m_1 = 0, 0, 1 - data_format = "channels_last" - dc_decomp = True - - # inputs - inputs = helpers.get_tensor_decomposition_images_box(data_format, odd, dc_decomp=dc_decomp) - inputs_for_mode = helpers.get_inputs_for_mode_from_full_inputs(inputs=inputs, mode=mode, dc_decomp=dc_decomp) - inputs_ = helpers.get_standard_values_images_box(data_format, odd, m0=m_0, m1=m_1, dc_decomp=dc_decomp) - input_ref_ = helpers.get_input_ref_from_full_inputs(inputs_) - - if finetune_odd is not None: - finetune_odd = K.convert_to_tensor(finetune_odd, dtype="float{}".format(floatx)) - - # decomon output - output = func(inputs_for_mode, mode=mode, axis=axis, finetune_lower=finetune_odd) - f_pooling = helpers.function(inputs, output) - w_, b_ = f_pooling(inputs_) - - # reference output - output_ref_ = np.max(input_ref_, axis=axis) - - assert_almost_equal( - minmax(np.clip(np.sum(w_ * input_ref_, axis) + b_ - output_ref_, clipmin, clipmax)), - 0.0, - decimal=decimal, - err_msg=f"linear hull for bounding max", - ) diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py index 8103d889..f0d38cd4 100644 --- a/tests/test_wrapper.py +++ b/tests/test_wrapper.py @@ -1,3 +1,4 @@ +import keras.ops as K import numpy as np import pytest from keras.layers import Activation, Dense, Input @@ -5,12 +6,13 @@ from numpy.testing import assert_almost_equal from decomon import get_adv_box, get_lower_box, get_range_box, get_upper_box -from decomon.core import get_affine, get_ibp from decomon.models import clone +from decomon.perturbation_domain import BallDomain +from decomon.wrapper import check_adv_box, get_range_noise @pytest.fixture() -def toy_model_1d(): +def toy_model_0d(): sequential = Sequential() sequential.add(Input((1,))) sequential.add(Dense(1, activation="linear")) @@ -20,8 +22,8 @@ def toy_model_1d(): @pytest.fixture() -def toy_model_multid(odd, helpers): - input_dim = helpers.get_input_dim_multid_box(odd) +def toy_model_1d(odd, helpers): + input_dim = helpers.get_input_dim_1d_box(odd) sequential = Sequential() sequential.add(Input((input_dim,))) sequential.add(Dense(1, activation="linear")) @@ -30,123 +32,83 @@ def toy_model_multid(odd, helpers): return sequential -def test_get_adv_box_1d(toy_model_1d, helpers): - inputs_ = helpers.get_standard_values_1d_box(n=0, dc_decomp=False) +def test_get_adv_box_0d(toy_model_0d, helpers): + inputs_ = helpers.get_standard_values_0d_box(n=0) x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - score = get_adv_box(toy_model_1d, z[:, 0], z[:, 1], source_labels=0) + score = get_adv_box(toy_model_0d, z[:, 0], z[:, 1], source_labels=0) -def test_get_upper_1d_box(toy_model_1d, n, method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") - - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=False) +def test_check_adv_box_0d(toy_model_0d, helpers): + inputs_ = helpers.get_standard_values_0d_box(n=0) x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - ibp = get_ibp(mode) - affine = get_affine(mode) - backward_model = clone(toy_model_1d, method=method, ibp=ibp, affine=affine, mode=mode) - upper = get_upper_box(backward_model, z[:, 0], z[:, 1]) - y_ref = helpers.predict_on_small_numpy(toy_model_1d, y) + score = check_adv_box(toy_model_0d, z[:, 0], z[:, 1], source_labels=0) - try: - assert (upper - y_ref).min() + 1e-6 >= 0.0 - except AssertionError: - toy_model_1d.save_weights("get_upper_1d_box_fail_{}_{}_{}.hd5".format(n, method, mode)) - raise AssertionError +def test_get_upper_0d_box(toy_model_0d, n, method, final_ibp, final_affine, helpers): + inputs_ = helpers.get_standard_values_0d_box(n) + x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ -def test_get_lower_1d_box(toy_model_1d, n, method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") + decomon_model = clone(toy_model_0d, method=method, final_ibp=final_ibp, final_affine=final_affine) + upper = get_upper_box(decomon_model, z[:, 0], z[:, 1]) + y_ref = helpers.predict_on_small_numpy(toy_model_0d, x) - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=False) - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ + assert (upper - y_ref).min() + 1e-6 >= 0.0 - ibp = get_ibp(mode) - affine = get_affine(mode) - backward_model = clone(toy_model_1d, method=method, final_ibp=ibp, final_affine=affine) - lower = get_lower_box(backward_model, z[:, 0], z[:, 1]) - y_ref = helpers.predict_on_small_numpy(toy_model_1d, y) - try: - assert (y_ref - lower).min() + 1e-6 >= 0.0 - except AssertionError: - toy_model_1d.save_weights("get_lower_1d_box_fail_{}_{}_{}.hd5".format(n, method, mode)) - raise AssertionError +def test_get_lower_0d_box(toy_model_0d, n, method, final_ibp, final_affine, helpers): + inputs_ = helpers.get_standard_values_0d_box(n) + x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ + decomon_model = clone(toy_model_0d, method=method, final_ibp=final_ibp, final_affine=final_affine) + lower = get_lower_box(decomon_model, z[:, 0], z[:, 1]) + y_ref = helpers.predict_on_small_numpy(toy_model_0d, x) -def test_get_range_1d_box(toy_model_1d, n, method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") + assert (y_ref - lower).min() + 1e-6 >= 0.0 - inputs_ = helpers.get_standard_values_1d_box(n, dc_decomp=False) - x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - ibp = get_ibp(mode) - affine = get_affine(mode) - backward_model = clone(toy_model_1d, method=method, final_ibp=ibp, final_affine=affine) - upper, lower = get_range_box(backward_model, z[:, 0], z[:, 1]) - y_ref = helpers.predict_on_small_numpy(toy_model_1d, y) +def test_get_range_0d_box(toy_model_0d, n, method, final_ibp, final_affine, helpers): + inputs_ = helpers.get_standard_values_0d_box(n) + x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - try: - assert (upper - y_ref).min() + 1e-6 >= 0.0 - assert (y_ref - lower).min() + 1e-6 >= 0.0 - except AssertionError: - toy_model_1d.save_weights("get_range_1d_box_fail_{}_{}_{}.hd5".format(n, method, mode)) - raise AssertionError + decomon_model = clone(toy_model_0d, method=method, final_ibp=final_ibp, final_affine=final_affine) + upper, lower = get_range_box(decomon_model, z[:, 0], z[:, 1]) + y_ref = helpers.predict_on_small_numpy(toy_model_0d, x) + assert (upper - y_ref).min() + 1e-6 >= 0.0 + assert (y_ref - lower).min() + 1e-6 >= 0.0 -def test_get_upper_multid_box(toy_model_multid, odd, method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") - inputs_ = helpers.get_standard_values_multid_box_convert(odd, dc_decomp=False) +def test_get_upper_1d_box(toy_model_1d, odd, method, final_ibp, final_affine, helpers): + inputs_ = helpers.get_standard_values_1d_box(odd) x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - ibp = get_ibp(mode) - affine = get_affine(mode) - backward_model = clone(toy_model_multid, method=method, final_ibp=ibp, final_affine=affine) - upper = get_upper_box(backward_model, z[:, 0], z[:, 1]) - y_ref = helpers.predict_on_small_numpy(toy_model_multid, y) - assert (upper - y_ref).min() + 1e-6 >= 0.0 + decomon_model = clone(toy_model_1d, method=method, final_ibp=final_ibp, final_affine=final_affine) + upper = get_upper_box(decomon_model, z[:, 0], z[:, 1]) + y_ref = helpers.predict_on_small_numpy(toy_model_1d, x) + assert (upper - y_ref).min() + 1e-6 >= 0.0 -def test_get_lower_multid_box(toy_model_multid, odd, method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") - inputs_ = helpers.get_standard_values_multid_box_convert(odd, dc_decomp=False) +def test_get_lower_1d_box(toy_model_1d, odd, method, final_ibp, final_affine, helpers): + inputs_ = helpers.get_standard_values_1d_box(odd) x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - ibp = get_ibp(mode) - affine = get_affine(mode) - backward_model = clone(toy_model_multid, method=method, final_ibp=ibp, final_affine=affine) - lower = get_lower_box(backward_model, z[:, 0], z[:, 1]) - y_ref = helpers.predict_on_small_numpy(toy_model_multid, y) + decomon_model = clone(toy_model_1d, method=method, final_ibp=final_ibp, final_affine=final_affine) + lower = get_lower_box(decomon_model, z[:, 0], z[:, 1]) + y_ref = helpers.predict_on_small_numpy(toy_model_1d, x) assert (y_ref - lower).min() + 1e-6 >= 0.0 -def test_get_range_multid_box(toy_model_multid, odd, method, mode, helpers): - if not helpers.is_method_mode_compatible(method=method, mode=mode): - # skip method=ibp/crown-ibp with mode=affine/hybrid - pytest.skip(f"output mode {mode} is not compatible with convert method {method}") - - inputs_ = helpers.get_standard_values_multid_box_convert(odd, dc_decomp=False) +def test_get_range_1d_box(toy_model_1d, odd, method, final_ibp, final_affine, helpers): + inputs_ = helpers.get_standard_values_1d_box(odd) x, y, z, u_c, W_u, b_u, l_c, W_l, b_l = inputs_ - ibp = get_ibp(mode) - affine = get_affine(mode) - backward_model = clone(toy_model_multid, method=method, final_ibp=ibp, final_affine=affine) - upper, lower = get_range_box(backward_model, z[:, 0], z[:, 1]) - y_ref = helpers.predict_on_small_numpy(toy_model_multid, y) + decomon_model = clone(toy_model_1d, method=method, final_ibp=final_ibp, final_affine=final_affine) + upper, lower = get_range_box(decomon_model, z[:, 0], z[:, 1]) + y_ref = helpers.predict_on_small_numpy(toy_model_1d, x) assert (upper - y_ref).min() + 1e-6 >= 0.0 assert (y_ref - lower).min() + 1e-6 >= 0.0 diff --git a/tests/visu-decomon.ipynb b/tests/visu-decomon.ipynb new file mode 100644 index 00000000..75912136 --- /dev/null +++ b/tests/visu-decomon.ipynb @@ -0,0 +1,678 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "08f5910b-8cbc-4094-84f4-e066cd28b1be", + "metadata": {}, + "source": [ + "# Visualization of Decomon Models" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "caa9eb31-b384-4351-9f73-c73d5ffd39ef", + "metadata": {}, + "outputs": [], + "source": [ + "from conftest import *\n", + "\n", + "helpers = Helpers()\n", + "from keras import Model, Sequential\n", + "from keras.activations import linear, serialize\n", + "from keras.layers import Activation, Add, Dense, Identity, Input, Layer, Subtract\n", + "\n", + "from decomon.layers.core.dense import DecomonDense\n", + "from decomon.layers.utils.batchsize import InsertBatchAxis\n", + "from decomon.models.convert import clone\n", + "from decomon.visualization.model_visualization import plot_model" + ] + }, + { + "cell_type": "markdown", + "id": "1d87858c-3624-4e6e-a952-ac2af71b8879", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "## Linear model\n", + "\n", + "All activations are linear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4eb3df47-8aca-48ba-8027-31f4a829d309", + "metadata": {}, + "outputs": [], + "source": [ + "input_shape = (5, 2, 3)\n", + "dtype = \"float32\"\n", + "toy_model = Helpers.toy_network_tutorial(input_shape=input_shape, dtype=dtype, activation=None)\n", + "plot_model(toy_model)" + ] + }, + { + "cell_type": "markdown", + "id": "4c259a7d-e898-4fef-a95b-a52a9dbcf1a5", + "metadata": {}, + "source": [ + "### forward ibp" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11bce920-f435-41eb-873b-bc5fee101584", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_IBP\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "8e642dc6-6736-4ab0-9757-3acacbfe72b6", + "metadata": {}, + "source": [ + "### forward affine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6b20dc58-febb-4463-ad03-d22921978d0c", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_AFFINE\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "69c34829-9aa8-41eb-a7a5-4cc8d7506585", + "metadata": {}, + "source": [ + "### forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e264918-53e7-45ee-89ac-13c8b1fb2e34", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "2a3feddf-d25f-45fc-9f74-182a785bb5db", + "metadata": {}, + "source": [ + "### CROWN - forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "829c119f-d7bd-42ec-8f1a-a5d4354758d1", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN_FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "075d5534-805c-4904-ab3f-78d63eabd649", + "metadata": {}, + "source": [ + "### CROWN - full recursive" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32113da8-efba-4c37-b9e2-05fe19267bd3", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "313de968-53e4-4eed-a364-22451072f76c", + "metadata": {}, + "source": [ + "## Tutorial model\n", + "\n", + "Some activations are `relu`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c2cfb12-fded-41e9-83f6-2e3432b006b8", + "metadata": {}, + "outputs": [], + "source": [ + "input_shape = (5, 2, 3)\n", + "dtype = \"float32\"\n", + "toy_model = Helpers.toy_network_tutorial(input_shape=input_shape, dtype=dtype, activation=\"relu\")\n", + "plot_model(toy_model)" + ] + }, + { + "cell_type": "markdown", + "id": "359f85d7-e80f-4dab-91f7-f67f3ffbe8b3", + "metadata": {}, + "source": [ + "### forward ibp" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35b26ba6-7856-400e-8d6d-15bbbb2849f3", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_IBP\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "6be26887-31af-4ef6-b303-03e243f960a5", + "metadata": {}, + "source": [ + "### forward affine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb8e8c3f-cd32-4e03-b8da-d955d1412b92", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_AFFINE\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "1557848d-2359-4c75-9f45-eb319b9042ad", + "metadata": {}, + "source": [ + "### forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce38468a-03e2-4430-8101-b73e5386aab6", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "a05cf391-9229-4bf5-8571-c3a2fdf45d91", + "metadata": {}, + "source": [ + "### CROWN - forward affine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72724491-545b-4d81-89e2-5cb6e7ed65db", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN_FORWARD_AFFINE\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "5bb7d6e2-3a40-4da8-abf7-e0232680ab03", + "metadata": {}, + "source": [ + "### CROWN - forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26f89451-8c5e-485c-beb7-903584d95f98", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN_FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "13c6da86-5f49-4657-9444-b248fd0c63c2", + "metadata": {}, + "source": [ + "### CROWN - full recursive" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb70b268-7124-492f-87d2-3f78d5a0ba4a", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "86fa0adb-bafd-4f30-ab8e-5c39a1e27ac6", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "## Merge v0 model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9c020e6-9f88-41e2-bd6d-fd628fee66d2", + "metadata": {}, + "outputs": [], + "source": [ + "input_shape = (5, 2, 3)\n", + "dtype = \"float32\"\n", + "toy_model = Helpers.toy_struct_v0(\n", + " input_shape=input_shape, dtype=dtype, activation=\"relu\", archi=[2, 3, 2], use_bias=True\n", + ")\n", + "plot_model(toy_model)" + ] + }, + { + "cell_type": "markdown", + "id": "84d8e914-bb62-42ff-92ea-504614f41526", + "metadata": {}, + "source": [ + "### forward ibp" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d41346eb-c043-446c-b31a-996234ab5239", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_IBP\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "1d9b03ab-08e7-4eb6-9f92-538afe74d41d", + "metadata": {}, + "source": [ + "### forward affine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5cac93d-9cc4-4d8b-a783-83da3ff425ad", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_AFFINE\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "c69afa79-e788-4ec7-8a0a-f3f04fec19c7", + "metadata": {}, + "source": [ + "### forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "756ce062-9ad8-4dd5-9898-11a328b7a254", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "03c4fe9e-102b-4b2a-bec5-e5a88a2a37af", + "metadata": {}, + "source": [ + "### CROWN - forward affine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78f8b820-654b-4708-9eca-60d9e7dcb276", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN_FORWARD_AFFINE\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "14b5631b-3010-4066-a71c-c15fa6015141", + "metadata": {}, + "source": [ + "### CROWN - forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83e938db-e051-4450-9f85-4e08f7200ad3", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN_FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "2ff11f24-11bd-404a-9b59-d4a3ee098965", + "metadata": {}, + "source": [ + "### CROWN - full recursive" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9004b065-1b20-4d07-b704-527d39b9fbc2", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN\n", + "decomon_model = clone(toy_model, method=method)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "10c352a9-a534-48d0-9299-cb97cd597ac5", + "metadata": {}, + "source": [ + "## Merge v0 model + precomputed backward_bounds" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e9c6482-0793-4e49-98bb-79833e32a387", + "metadata": {}, + "outputs": [], + "source": [ + "input_shape = (5, 2, 3)\n", + "dtype = \"float32\"\n", + "toy_model = Helpers.toy_struct_v0(\n", + " input_shape=input_shape, dtype=dtype, activation=\"relu\", archi=[2, 3, 2], use_bias=True\n", + ")\n", + "plot_model(toy_model)\n", + "\n", + "# backward_bounds: single tensor, diagonal representation of the weights (same for lower and upper bound, no bias)\n", + "backward_bounds = Input(toy_model.outputs[0].shape[1:], name=\"backward_bounds\")" + ] + }, + { + "cell_type": "markdown", + "id": "4dfe5ac2-5a5b-4873-bb70-6e26d734c028", + "metadata": {}, + "source": [ + "### forward ibp" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86600707-b827-4622-a1a4-a37007ac355a", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_IBP\n", + "decomon_model = clone(toy_model, method=method, backward_bounds=backward_bounds)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "c30bf36a-8896-414b-bd5b-545e4bc49112", + "metadata": {}, + "source": [ + "### forward affine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d4965005-7283-439b-9b59-25da706566cd", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_AFFINE\n", + "decomon_model = clone(toy_model, method=method, backward_bounds=backward_bounds)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "e71307af-3542-42a6-95be-5b31fb24cc40", + "metadata": {}, + "source": [ + "### forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b790f7c9-c7bb-4b3f-bfa3-b26eeae9b826", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method, backward_bounds=backward_bounds)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "34fbb661-71ad-44fe-b5a3-ee34a81608d4", + "metadata": {}, + "source": [ + "### CROWN - forward affine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ecb862c9-0229-4adb-8a13-d2e4c26a10af", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN_FORWARD_AFFINE\n", + "decomon_model = clone(toy_model, method=method, backward_bounds=backward_bounds)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "00fa9db4-1599-4c46-94d5-e9027126d63d", + "metadata": {}, + "source": [ + "### CROWN - forward hybrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "607d010d-45a9-4562-8016-bb7ea231d3e1", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN_FORWARD_HYBRID\n", + "decomon_model = clone(toy_model, method=method, backward_bounds=backward_bounds)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "1d5930b4-22a8-4617-ab0a-53e0edc15a5f", + "metadata": {}, + "source": [ + "### CROWN - full recursive" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e40c9b81-a8c6-439d-b3fe-0b04ba134a57", + "metadata": {}, + "outputs": [], + "source": [ + "method = ConvertMethod.CROWN\n", + "decomon_model = clone(toy_model, method=method, backward_bounds=backward_bounds)\n", + "plot_model(decomon_model)" + ] + }, + { + "cell_type": "markdown", + "id": "9ddd1d2d-6a0f-4483-9ef7-463d7bf3963f", + "metadata": {}, + "source": [ + "## Example of customization of `plot_model()`\n", + "\n", + "NB: colors can be set as in html code. See for instance https://htmlcolorcodes.com/ to pick colors.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87995d1c-d88d-42ae-809f-d1c00c31a033", + "metadata": {}, + "outputs": [], + "source": [ + "input_shape = (5, 2, 3)\n", + "dtype = \"float32\"\n", + "toy_model = Helpers.toy_struct_v0(\n", + " input_shape=input_shape, dtype=dtype, activation=\"relu\", archi=[2, 3, 2], use_bias=True\n", + ")\n", + "method = ConvertMethod.CROWN_FORWARD_IBP\n", + "decomon_model = clone(toy_model, method=method)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0f7d946-4a21-4c39-8d5a-018a6e2c97ee", + "metadata": {}, + "outputs": [], + "source": [ + "from keras.activations import relu\n", + "\n", + "from decomon.layers.oracle import DecomonOracle\n", + "from decomon.visualization.model_visualization import (\n", + " decomon_utilitary_layers_styles,\n", + " show_decomon_layer_attributes,\n", + ")\n", + "\n", + "# layer styles\n", + "layer_styles = dict(decomon_utilitary_layers_styles) # start from default styles for decomon layers\n", + "layer_styles[DecomonOracle] = dict(bgcolor=\"#A569BD \", color=\"white\") # show DecomonOracle in purple/white\n", + "\n", + "# extra attributes to show\n", + "show_layer_attributes = dict(\n", + " show_decomon_layer_attributes\n", + ") # start with default decomon attributes (propagation, keras layer name)\n", + "show_layer_attributes[\"slope\"] = {None: {}} # display slope with default style\n", + "show_layer_attributes[\"layer.activation.__name__\"] = { # display keras layer activation name\n", + " None: dict(bgcolor=\"white\", color=\"black\"), # default: white/black\n", + " \"relu\": dict(bgcolor=\"#F5B7B1\", color=\"black\"), # relu: red/black\n", + "}\n", + "\n", + "plot_model(decomon_model, layer_styles=layer_styles, show_layer_attributes=show_layer_attributes)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9bc27f6-1a42-4e0f-a383-61b530820903", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tox.ini b/tox.ini index 514286aa..e9574275 100644 --- a/tox.ini +++ b/tox.ini @@ -15,15 +15,12 @@ deps = pytest<8 pytest-cases py39-linux-tf: pytest-cov - tf: tensorflow>=2.15 # backend for keras 3 + tf: tensorflow>=2.16 # backend for keras 3 torch: torch>=2.1.0 # backend for keras 3 setenv = tf: KERAS_BACKEND=tensorflow torch: KERAS_BACKEND=torch commands = - # be sure to get keras 3 instead of keras 2 brought back by tensorflow 2.15 - pip uninstall keras -y - pip install "keras>=3" pip list python -c 'import keras; print(keras.config.backend())' pytest -v \ diff --git a/tutorials/tutorial1_sinus-interactive.ipynb b/tutorials/tutorial1_sinus-interactive.ipynb index 024c4bb5..de531bd0 100644 --- a/tutorials/tutorial1_sinus-interactive.ipynb +++ b/tutorials/tutorial1_sinus-interactive.ipynb @@ -56,10 +56,7 @@ " !{sys.executable} -m pip install -U pip\n", " !{sys.executable} -m pip install git+https://github.com/airbus/decomon@main#egg=decomon\n", " # install desired backend (by default tensorflow)\n", - " !{sys.executable} -m pip install \"tensorflow>=2.15\"\n", - " # ensure having keras 3 and not keras 2.15\n", - " !{sys.executable} -m pip uninstall -y keras\n", - " !{sys.executable} -m pip install \"keras>=3\"" + " !{sys.executable} -m pip install \"tensorflow>=2.16\"" ] }, { @@ -255,9 +252,11 @@ "\n", "Depending on the inner recipes, those methods will output different informations:\n", "\n", - "- **forward-ibp, crown-forward-ibp, crown**: constant upper and lower bounds that outer-approximate every output neurons\n", - "- **forward-affine, crown-forward-affine**: affine upper bound and affine lower bound given the input of the network that outer-approximate every output neurons\n", - "- **forward-hybrid, crown-forward-hybrid**: both types of bounds\n", + "- **forward-ibp**: constant upper and lower bounds that outer-approximate every output neurons\n", + "- **forward-affine, crown-forward-affine, crown-forward-ibp, crown**: affine upper bound and affine lower bound given the input of the network that outer-approximate every output neurons\n", + "- **forward-hybrid**: both types of bounds\n", + "\n", + "We can also override this default output format by setting `final_ibp` (for constant bounds) and `final_affine` (for affine bounds).\n", " \n", "By default, the convert method converts the Keras model in **crown**." ] @@ -423,7 +422,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.12" + "version": "3.9.18" }, "toc": { "base_numbering": 1, diff --git a/tutorials/tutorial2_noise_sensor.ipynb b/tutorials/tutorial2_noise_sensor.ipynb index e1c62b4d..5ca3c377 100644 --- a/tutorials/tutorial2_noise_sensor.ipynb +++ b/tutorials/tutorial2_noise_sensor.ipynb @@ -60,10 +60,7 @@ " !{sys.executable} -m pip install -U pip\n", " !{sys.executable} -m pip install git+https://github.com/airbus/decomon@main#egg=decomon\n", " # install desired backend (by default tensorflow)\n", - " !{sys.executable} -m pip install \"tensorflow>=2.15\"\n", - " # ensure having keras 3 and not keras 2.15\n", - " !{sys.executable} -m pip uninstall -y keras\n", - " !{sys.executable} -m pip install \"keras>=3\"" + " !{sys.executable} -m pip install \"tensorflow>=2.16\"" ] }, { @@ -85,7 +82,7 @@ "+ i_q: Current q-component\n", "\n", "\n", - "The recorded temperature refers to the Permanent Magnet surface temperature (pm) representing the rotor temperature. This was measured with an infrared with 140 hrs recordings. Distinctive sessions are identified with \"profile_id\". You will find additional information in the [official data repository](https://www.kaggle.com/wkirgsn/electric-motor-temperature)" + "The recorded temperature refers to the Permanent Magnet surface temperature (pm) representing the rotor temperature. This was measured with an infrared with 140 hrs recordings. Distinctive sessions are identified with \"profile_id\". You will find additional information in the [official data repository](https://www.kaggle.com/datasets/wkirgsn/electric-motor-temperature)" ] }, { @@ -94,7 +91,7 @@ "source": [ "### Download the dataset locally\n", "\n", - "To download the data you need a [Kaggle](https://www.kaggle.com) account. Then you can download the dataset by clicking on the \"download\" button on the [official data repository](https://www.kaggle.com/wkirgsn/electric-motor-temperature). Unzip the file in the same directory as this notebook.\n", + "To download the data you need a [Kaggle](https://www.kaggle.com) account. Then you can download the dataset by clicking on the \"download\" button on the [official data repository](https://www.kaggle.com/datasets/wkirgsn/electric-motor-temperature). Unzip the file in the same directory as this notebook.\n", "\n", "\n", "You can also use the method described for Binder and Colab below." @@ -274,8 +271,8 @@ "outputs": [], "source": [ "from decomon import get_lower_noise, get_range_noise, get_upper_noise\n", - "from decomon.core import BallDomain\n", - "from decomon.models import clone" + "from decomon.models import clone\n", + "from decomon.perturbation_domain import BallDomain" ] }, { @@ -492,7 +489,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.12" + "version": "3.9.18" }, "toc": { "base_numbering": 1, diff --git a/tutorials/tutorial3_adversarial_attack.ipynb b/tutorials/tutorial3_adversarial_attack.ipynb index 3c8af6ca..a5eabfeb 100644 --- a/tutorials/tutorial3_adversarial_attack.ipynb +++ b/tutorials/tutorial3_adversarial_attack.ipynb @@ -58,10 +58,7 @@ " !{sys.executable} -m pip install -U pip\n", " !{sys.executable} -m pip install git+https://github.com/airbus/decomon@main#egg=decomon\n", " # install desired backend (by default tensorflow)\n", - " !{sys.executable} -m pip install \"tensorflow>=2.15\"\n", - " # ensure having keras 3 and not keras 2.15\n", - " !{sys.executable} -m pip uninstall -y keras\n", - " !{sys.executable} -m pip install \"keras>=3\"" + " !{sys.executable} -m pip install \"tensorflow>=2.16\"" ] }, { @@ -506,7 +503,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.12" + "version": "3.9.18" }, "toc": { "base_numbering": 1, diff --git a/tutorials/tutorial4_certified_over_estimation.ipynb b/tutorials/tutorial4_certified_over_estimation.ipynb index 30d3309f..b2b3eb40 100644 --- a/tutorials/tutorial4_certified_over_estimation.ipynb +++ b/tutorials/tutorial4_certified_over_estimation.ipynb @@ -67,10 +67,7 @@ " !{sys.executable} -m pip install -U pip\n", " !{sys.executable} -m pip install git+https://github.com/airbus/decomon@main#egg=decomon\n", " # install desired backend (by default tensorflow)\n", - " !{sys.executable} -m pip install \"tensorflow>=2.15\"\n", - " # ensure having keras 3 and not keras 2.15\n", - " !{sys.executable} -m pip uninstall -y keras\n", - " !{sys.executable} -m pip install \"keras>=3\"" + " !{sys.executable} -m pip install \"tensorflow>=2.16\"" ] }, { @@ -735,7 +732,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.12" + "version": "3.9.18" }, "toc": { "base_numbering": 1, diff --git a/tutorials/z_Advanced/custom_layer.ipynb b/tutorials/z_Advanced/custom_layer.ipynb new file mode 100644 index 00000000..86406797 --- /dev/null +++ b/tutorials/z_Advanced/custom_layer.ipynb @@ -0,0 +1,502 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ed5852cf-7c70-440a-b8ae-558f9e792ec5", + "metadata": {}, + "source": [ + "# How to implement its own custom decomon layer?\n", + "\n", + "\n", + "

\n", + " \"Decomon!\"\n", + "

\n", + "\n", + "\n", + "\n", + "- 📚 Documentation \n", + "- Github \n", + "- Tutorials \n", + " \n", + "_Author: [Melanie DUCOFFE](https://fr.linkedin.com/in/m%C3%A9lanie-ducoffe-bbb53165)_\n", + "
\n", + "\n", + "When using decomon on customized Keras layers (or not already implemented in decomon), one has to implement their decomon counterpart. \n", + "The easiest way is to simply implement their constant and affine relaxation, as explained in this notebook." + ] + }, + { + "cell_type": "markdown", + "id": "d5478238-ebf0-4760-af96-60d78c4f1f38", + "metadata": {}, + "source": [ + "## Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ae44949-26f2-4eb8-bcfd-2bd28c16f2dc", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import keras\n", + "import keras.ops as K\n", + "import numpy as np\n", + "from keras.layers import Dense, Input, Layer\n", + "from keras.models import Sequential\n", + "\n", + "# from decomon import get_lower_box, get_upper_box\n", + "from decomon import clone\n", + "from decomon.keras_utils import batch_multid_dot\n", + "from decomon.layers import DecomonLayer" + ] + }, + { + "cell_type": "markdown", + "id": "6a695903-0d16-4fa8-be3a-d868b9884b1a", + "metadata": {}, + "source": [ + "## Custom keras layer and keras model" + ] + }, + { + "cell_type": "markdown", + "id": "73d5523e-23a3-4c36-82ca-ef65a20b1e03", + "metadata": {}, + "source": [ + "We implement here 2 keras layers:\n", + "- a linear layer: simply doubling its input\n", + "- a non-linear layer: squaring its input" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d8ba665b-20d0-48c4-8a69-5723136aa0d9", + "metadata": {}, + "outputs": [], + "source": [ + "class Double(Layer):\n", + " \"\"\"Doubling layer.\"\"\"\n", + "\n", + " def call(self, inputs):\n", + " \"\"\"Take double.\"\"\"\n", + " return inputs * 2\n", + "\n", + "\n", + "class Square(Layer):\n", + " \"\"\"Square layer.\"\"\"\n", + "\n", + " def call(self, inputs):\n", + " \"\"\"Take square.\"\"\"\n", + " return inputs**2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fc24f31a-bfba-432f-8e51-93e196096d96", + "metadata": {}, + "outputs": [], + "source": [ + "model = Sequential([Input((2,)), Double(), Dense(10), Square()])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c18d606-ae77-4616-b914-f52fddb7994e", + "metadata": {}, + "outputs": [], + "source": [ + "model.layers[2].input.shape" + ] + }, + { + "cell_type": "markdown", + "id": "fa4811e9-cd61-4e37-aed5-608188a7b067", + "metadata": {}, + "source": [ + "## Decomon custom layer implementation\n", + "\n", + "We need to derive from `DecomonLayer` and implement some methods." + ] + }, + { + "cell_type": "markdown", + "id": "fe939f13-8f8c-4a4e-916e-383d0ae641cf", + "metadata": {}, + "source": [ + "### Linear layer\n", + "\n", + "For the linear layer, we only have to give the proper affine representation of the layer (with proper shape), once we have specified that it is a linear layer. \n", + "As we have a linear (or affine) layer, this representation is independent of the batch and will be given as such. \n", + "\n", + "More precisely, we need to return weights and bias `w` and `b` such that\n", + "\n", + " layer(x) = x * w + b\n", + "\n", + "where the multiplication is actually `keras.ops.tensordot` on all non-batch axes of `x` and the the first non-batch ones of `w`, same number as the number of non-batch axes in `x`. \n", + "\n", + "This can performed via `batch_multi_dot()` from decomon (same function will be used for non-linear case).\n", + "\n", + "In the generic case, with 1 output, the shapes are:\n", + " \n", + " - x ~ (batchsize,) + layer.input.shape[1:]\n", + " - b ~ layer.output.shape[1:]\n", + " - w ~ layer.input.shape[1:] + layer.output.shape[1:]\n", + "\n", + "We can also use *diagonal representation*: this means that `w` is represented only by its diagonal. (This only possible if input and output are of same shape). The shapes are:\n", + "\n", + " - x ~ (batchsize,) + layer.input.shape[1:]\n", + " - b ~ layer.output.shape[1:]\n", + " - w ~ layer.output.shape[1:]\n", + "\n", + " - layer.input.shape[1:] == layer.output.shape[1:]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c519f84-de7b-40e6-8ee2-8240e4a3a0bf", + "metadata": {}, + "outputs": [], + "source": [ + "class DecomonDouble(DecomonLayer):\n", + " linear = True # specifying that this is a linear layer\n", + " diagonal = True # specifying `w` is represented by its diagonal\n", + "\n", + " def get_affine_representation(self):\n", + " bias_shape = self.layer.input.shape[\n", + " 1:\n", + " ] # a decomon layer has always an attribute `layer` which is the corresponding keras layer for which it is the decomon counterpart.\n", + " w = 2 * keras.ops.ones(bias_shape)\n", + " b = keras.ops.zeros(bias_shape)\n", + " return w, b" + ] + }, + { + "cell_type": "markdown", + "id": "10c7bfe2-4ff4-471e-a7f3-007d9309b9ba", + "metadata": {}, + "source": [ + "#### Verification\n", + "\n", + "Let us check the affine representation by comparing it with actual output" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e4735f8-1f6f-46b7-ace6-c4ef69287bbd", + "metadata": {}, + "outputs": [], + "source": [ + "# Instantiate the keras layer and build it\n", + "layer = Double()\n", + "layer(Input((2,)))\n", + "\n", + "# Instantiate the corresponding decomon layer\n", + "decomon_layer = DecomonDouble(layer=layer)\n", + "\n", + "# Keras input/output\n", + "x = K.convert_to_tensor(np.random.random((3, 2)), dtype=keras.config.floatx()) # ensure using same precision as default\n", + "layer_output_np = K.convert_to_numpy(layer(x))\n", + "\n", + "# Recompute with affine representation\n", + "w, b = decomon_layer.get_affine_representation()\n", + "missing_batchsize = (False, True) # specify that `w` is missing batchsize (but not x)\n", + "diagonal = (False, True) # specify that `w` is represented by its diagonal\n", + "recomputed_layer_output = batch_multid_dot(x, w, missing_batchsize=missing_batchsize, diagonal=diagonal) + b\n", + "recomputed_layer_output_np = K.convert_to_numpy(recomputed_layer_output)\n", + "\n", + "# Compare\n", + "np.testing.assert_almost_equal(recomputed_layer_output_np, layer_output_np)\n", + "\n", + "print(\"Perfect!\")" + ] + }, + { + "cell_type": "markdown", + "id": "b2bb4cb1-7548-4344-a007-90091301608e", + "metadata": {}, + "source": [ + "### Non-linear layer\n", + "\n", + "For the non-linear layer, we need to give the constant and affine relaxation of the layer (with proper shape)\n", + "\n", + "#### Constant relaxation\n", + "\n", + "Given lower and upper bounds on layer input, we give lower and upper constant bounds on layer output (with a batchsize).\n", + "\n", + "#### Affine relaxation\n", + ". \n", + "Given lower and upper bounds on layer input, we need to return weights and biases `w_l`, `b_l`, `w_u`, and `b_u` such that\n", + "\n", + " x * w_l + b_l <= layer(x) <= x * w_u + b_u\n", + "\n", + "where the multiplication is batch-wise, and on multiple axes (the first non-batch ones of `w`, same number as the number of non-batch axes in `x`). This is performed via `batch_multi_dot()` from decomon.\n", + "\n", + "In the generic case, with 1 output, the shape are:\n", + " \n", + " - x ~ (batchsize,) + layer.input.shape[1:]\n", + " - b_l, b_l ~ (batchsize,) + layer.output.shape[1:]\n", + " - w_l, w_u ~ (batchsize,) + layer.input.shape[1:] + layer.output.shape[1:]\n", + "\n", + "\n", + "We can also use *diagonal representation*: as before, this means that `w_l` and `w_u` will be represented by their diagonal, so of the same shape as `b_l` and `b_u`. Only possible if input and output of the layer share the same shape.\n", + "\n", + " - x ~ (batchsize,) + layer.input.shape[1:]\n", + " - b_l, b_l ~ (batchsize,) + layer.output.shape[1:]\n", + " - w_l, w_u ~ (batchsize,) + layer.output.shape[1:]\n", + "\n", + " - layer.input.shape[1:] == layer.output.shape[1:]\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a4c9c28-712b-424d-9a7e-238890dca400", + "metadata": {}, + "outputs": [], + "source": [ + "class DecomonSquare(DecomonLayer):\n", + " diagonal = True # specifying `w_l` and `w_u` are represented by their diagonal\n", + "\n", + " def forward_ibp_propagate(self, lower, upper):\n", + " # image of bounds\n", + " f_lower = lower**2\n", + " f_upper = upper**2\n", + "\n", + " # lower bound: if same sign, the minimum between 2 images, if opposite signs, 0.\n", + " lower_out = K.where(K.sign(lower * upper) > 0, K.minimum(f_lower, f_upper), 0.0)\n", + " # upper_bound: the maximum between 2 images\n", + " upper_out = K.maximum(f_lower, f_upper)\n", + "\n", + " return lower_out, upper_out\n", + "\n", + " def get_affine_bounds(self, lower, upper):\n", + " # image of bounds\n", + " f_lower = lower**2\n", + " f_upper = upper**2\n", + "\n", + " # lower bound:\n", + " # - opposite signs: 0 hyperplan\n", + " # - same signs: tangent hyperplan at minimum point\n", + " w_l = K.where(\n", + " K.sign(lower * upper) > 0,\n", + " K.where(\n", + " lower < 0,\n", + " 2 * upper,\n", + " 2 * lower,\n", + " ),\n", + " 0.0,\n", + " )\n", + " b_l = K.where(\n", + " K.sign(lower * upper) > 0,\n", + " K.where(\n", + " lower < 0,\n", + " -(upper**2),\n", + " -(lower**2),\n", + " ),\n", + " 0.0,\n", + " )\n", + "\n", + " # upper bound: by convexity, hyperplan between lower and upper\n", + " w_u = (f_upper - f_lower) / K.maximum(\n", + " K.cast(keras.config.epsilon(), dtype=upper.dtype), upper - lower\n", + " ) # avoid dividing by 0. -> replace by epsilon.\n", + " b_u = f_lower - w_u * lower\n", + "\n", + " return w_l, b_l, w_u, b_u" + ] + }, + { + "cell_type": "markdown", + "id": "702a789d-2f83-44c3-b1bf-d11b71902a11", + "metadata": {}, + "source": [ + "#### Verification\n", + "\n", + "Let us check the relaxations" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24a7b648-426b-4481-9dcb-d6512d1e8d5f", + "metadata": {}, + "outputs": [], + "source": [ + "# Instantiate the keras layer and build it\n", + "layer = Square()\n", + "layer(Input((2,)))\n", + "\n", + "# Instantiate the corresponding decomon layer\n", + "decomon_layer = DecomonSquare(layer=layer)\n", + "\n", + "# Keras bounds/input/output\n", + "lowers = [-2, -1, 1]\n", + "uppers = [-1, 1, 2]\n", + "\n", + "x_np = np.concatenate([np.random.random((1, 2)) * (u - l) + l for l, u in zip(lowers, uppers)], axis=0)\n", + "lower_np = np.concatenate([np.reshape([l, l], (1, 2)) for l in lowers], axis=0)\n", + "upper_np = np.concatenate([np.reshape([u, u], (1, 2)) for u in uppers], axis=0)\n", + "\n", + "x = K.convert_to_tensor(x_np, dtype=keras.config.floatx()) # ensure using same precision as default\n", + "lower = K.convert_to_tensor(lower_np, dtype=keras.config.floatx())\n", + "upper = K.convert_to_tensor(upper_np, dtype=keras.config.floatx())\n", + "\n", + "layer_output_np = K.convert_to_numpy(layer(x))\n", + "\n", + "\n", + "# constant bounds\n", + "lower_ibp, upper_ibp = decomon_layer.forward_ibp_propagate(lower, upper)\n", + "\n", + "# affine bounds => computed constant bounds\n", + "w_l, b_l, w_u, b_u = decomon_layer.get_affine_bounds(lower, upper)\n", + "diagonal = (False, True) # specify that `w` is represented by its diagonal\n", + "lower_affine = batch_multid_dot(x, w_l, diagonal=diagonal) + b_l\n", + "upper_affine = batch_multid_dot(x, w_u, diagonal=diagonal) + b_u\n", + "\n", + "\n", + "lower_affine_np = K.convert_to_numpy(lower_affine)\n", + "upper_affine_np = K.convert_to_numpy(upper_affine)\n", + "lower_ibp_np = K.convert_to_numpy(lower_ibp)\n", + "upper_ibp_np = K.convert_to_numpy(upper_ibp)\n", + "\n", + "\n", + "# comparison\n", + "assert (lower_affine_np <= layer_output_np).all()\n", + "assert (upper_affine_np >= layer_output_np).all()\n", + "assert (lower_ibp_np <= layer_output_np).all()\n", + "assert (upper_ibp_np >= layer_output_np).all()\n", + "\n", + "print(\"Perfect!\")" + ] + }, + { + "cell_type": "markdown", + "id": "73f5b684-34d9-40e2-a5e3-718410e0e65d", + "metadata": {}, + "source": [ + "## Convert to decomon model\n", + "\n", + "We need to specify the mapping between keras and decomon custom layers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9cd201a5-a066-4690-9068-9c435da76a73", + "metadata": {}, + "outputs": [], + "source": [ + "decomon_model = clone(\n", + " model,\n", + " mapping_keras2decomon_classes={Square: DecomonSquare, Double: DecomonDouble}, # custom layers mapping\n", + " final_ibp=True, # keep final constant bounds\n", + " final_affine=False, # drop final affine bounds\n", + ")\n", + "\n", + "decomon_model.summary()" + ] + }, + { + "cell_type": "markdown", + "id": "fa9f79b2-cc58-4f28-9da2-a741b736a7be", + "metadata": {}, + "source": [ + "Get formal lower and upper bounds on a box domain [0,1] for inputs:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8970932a-fafb-4ef0-8dcf-516ac220639f", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a fake box with the right shape\n", + "x_min = np.zeros((1, 2))\n", + "x_max = np.ones((1, 2))\n", + "x_box = np.concatenate([x_min[:, None], x_max[:, None]], axis=1)\n", + "\n", + "# Get lower and upper bounds\n", + "lower_bound, upper_bound = decomon_model.predict_on_single_batch_np(\n", + " x_box\n", + ") # more efficient than predict on very small batch\n", + "\n", + "print(f\"lower bound: {lower_bound}\")\n", + "print(f\"upper bound: {upper_bound}\")" + ] + }, + { + "cell_type": "markdown", + "id": "e26f590c-408a-42df-b104-a76e64a82c6b", + "metadata": {}, + "source": [ + "Compare with empirical bounds\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "70079724-8050-4d06-9c98-b8d32778ec6f", + "metadata": {}, + "outputs": [], + "source": [ + "keras_input = K.convert_to_tensor(np.random.random((100, 2)))\n", + "keras_output = K.convert_to_numpy(model(keras_input))\n", + "lower_empirical = np.min(keras_output, axis=0)\n", + "upper_empirical = np.max(keras_output, axis=0)\n", + "\n", + "print(f\"empirical lower bound: {lower_empirical}\")\n", + "print(f\"empirical upper bound: {upper_empirical}\")" + ] + }, + { + "cell_type": "markdown", + "id": "67b72d24-8b6f-4c2c-8d74-e42d971af06c", + "metadata": {}, + "source": [ + "We should have (and the tightest, the best the approximation):\n", + "\n", + " lower_bounds <= lower_empirical , upper_empirical <= upper_bound\n", + " " + ] + }, + { + "cell_type": "markdown", + "id": "5326dd7e-62fd-4311-81a2-ff9ab714345c", + "metadata": {}, + "source": [ + "That's all folks!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/z_Advanced/tensorboard-and-decomon.ipynb b/tutorials/z_Advanced/tensorboard-and-decomon.ipynb index ed9d4d15..31d4c1bb 100644 --- a/tutorials/z_Advanced/tensorboard-and-decomon.ipynb +++ b/tutorials/z_Advanced/tensorboard-and-decomon.ipynb @@ -54,14 +54,10 @@ " import sys # noqa: avoid having this import removed by pycln\n", "\n", " # install dev version for dev doc, or release version for release doc\n", - " !{sys.executable} -m pip install \"tensorflow>=2.13\" \"tensorboard>=2.13\" \"keras>=2.13\"\n", " !{sys.executable} -m pip install -U pip\n", " !{sys.executable} -m pip install git+https://github.com/airbus/decomon@main#egg=decomon\n", " # install desired backend (by default tensorflow)\n", - " !{sys.executable} -m pip install \"tensorflow>=2.15\"\n", - " # ensure having keras 3 and not keras 2.15\n", - " !{sys.executable} -m pip uninstall -y keras\n", - " !{sys.executable} -m pip install \"keras>=3\"" + " !{sys.executable} -m pip install \"tensorflow>=2.16 tensorboard>=2.16\"" ] }, { @@ -95,7 +91,7 @@ "import keras\n", "import numpy as np\n", "import tensorboard\n", - "from keras.layers import Activation, Dense\n", + "from keras.layers import Activation, Dense, Input\n", "from keras.models import Sequential\n", "\n", "from decomon.models import clone\n", @@ -145,7 +141,8 @@ "outputs": [], "source": [ "layers = []\n", - "layers.append(Dense(100, activation=\"linear\", input_dim=1, name=\"dense1\")) # specify the dimension of the input space\n", + "layers.append(Input((1,)))\n", + "layers.append(Dense(100, activation=\"linear\", name=\"dense1\"))\n", "layers.append(Activation(\"relu\", name=\"relu1\"))\n", "layers.append(Dense(100, activation=\"linear\", name=\"dense2\"))\n", "layers.append(Activation(\"relu\", name=\"relu2\"))\n", @@ -200,7 +197,7 @@ "outputs": [], "source": [ "# convert our model into a decomon model:\n", - "decomon_model = clone(model, method=\"crown\") # method is optionnal" + "decomon_model = clone(model, method=\"forward-ibp\") # method is optionnal" ] }, { @@ -356,7 +353,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.12" + "version": "3.9.18" }, "toc": { "base_numbering": 1,