Repository: ComPWA/tensorwaves (language: python)

Issue:
Provide benchmarks tests
Would be nice to profile/monitor this in a standardised way, so that we can see whether there are improvements upon each PR. The benchmarks should be similar in structure, probably making use of some shared façade functions.

What we probably want as input:
- Initial + final state
- Allowed intermediate states (the fewer, the faster)
- Formalism type (helicity/canonical)
- Number of:
   - phase space events
   - intensity-based events ('data')
- Stage to perform (e.g. only up to data generation)

The recipe file is generated with the expertsystem based on this input. All the rest (e.g. which amplitude generator to use), should be deduced from the recipe.

Some potential tools:
- [`pytest-benchmark`](https://pytest-benchmark.readthedocs.io/) (nicely integrated with `pytest`, though seems to be more for micro-benchmarks)
- [`pycallgraph`](https://pycallgraph.readthedocs.io/) (seems rather outdated)
- Write own timer with [`timeit`](https://docs.python.org/3/library/timeit.html)
test: add unit tests for optimized_lambdify
Additional fixes:
- The faster-lambdify notebook was failing due to the interface change introduced by #348. This was not noticed, because the `%%time` statement in the cell makes the error code of that cell return 'success'. The error has been fixed and a hidden test cell has been added to prevent such failures in the future.
- `optimized_lambdify` now directly calls `_backend_lambdify` is `max_complexity` is higher than the number of nodes in the expression.


Discussion / hints:
Update: the [HSF Data Analysis WG](https://hepsoftwarefoundation.org/workinggroups/dataanalysis.html) considers defining benchmark PWA analyses for comparing different PWA fitter frameworks. Once those benchmarks are defined, they can be addressed in this issue.
Also worth considering: host these benchmark test under a separate repository, otherwise it slows down CI of TensorWaves and could clutter the repo with a lot of additional testing code. Alternatively, the tests are run only upon merging into the stable branch.
@Leongrim this may be a nice way to record performance over time
https://github.com/benchmark-action/github-action-benchmark
https://github.com/marketplace/actions/continuous-benchmark
It also supports [`pytest-benchmark`](https://pypi.org/project/pytest-benchmark)
Check out this pull request on&nbsp; <a href="https://app.reviewnb.com/ComPWA/tensorwaves/pull/351"><img align="absmiddle"  alt="ReviewNB" height="28" class="BotMessageButtonImage" src="https://raw.githubusercontent.com/ReviewNB/support/master/images/button_reviewnb.png"/></a> 

 See visual diffs & provide feedback on Jupyter Notebooks. 

---

 <i>Powered by <a href='https://www.reviewnb.com/?utm_source=gh'>ReviewNB</a></i>

Fix patch:
diff --git a/.constraints/py3.6.txt b/.constraints/py3.6.txt
index 13084fd3..d87e6da3 100644
--- a/.constraints/py3.6.txt
+++ b/.constraints/py3.6.txt
@@ -26,7 +26,7 @@ cachetools==4.2.4
 certifi==2021.10.8
 cffi==1.15.0
 cfgv==3.3.1
-charset-normalizer==2.0.8
+charset-normalizer==2.0.9
 clang==5.0
 click==7.1.2
 cloudpickle==2.0.0
@@ -62,7 +62,6 @@ gitpython==3.1.18
 google-auth==1.35.0
 google-auth-oauthlib==0.4.6
 google-pasta==0.2.0
-gprof2dot==2021.2.21
 graphviz==0.19
 greenlet==1.1.2
 grpcio==1.42.0
@@ -123,7 +122,7 @@ nbclient==0.5.9
 nbconvert==6.0.7
 nbdime==3.1.1
 nbformat==5.1.3
-nbmake==1.0
+nbmake==1.1
 nest-asyncio==1.5.4
 nodeenv==1.6.0
 notebook==6.4.6
@@ -152,6 +151,7 @@ prompt-toolkit==3.0.23
 protobuf==3.19.1
 ptyprocess==0.7.0
 py==1.11.0
+py-cpuinfo==8.0.0
 pyasn1==0.4.8
 pyasn1-modules==0.2.8
 pycodestyle==2.8.0
@@ -165,10 +165,10 @@ pylint==2.10.2 ; python_version == "3.6.*"
 pyparsing==3.0.6
 pyrsistent==0.18.0
 pytest==6.2.5
+pytest-benchmark==3.4.1
 pytest-cov==3.0.0
 pytest-forked==1.3.0
 pytest-mock==3.6.1
-pytest-profiling==1.7.0
 pytest-xdist==2.4.0
 python-constraint==1.4.0
 python-dateutil==2.8.2
@@ -177,7 +177,7 @@ pyyaml==6.0
 pyzmq==22.3.0
 qrules==0.9.4
 qtconsole==5.2.1
-qtpy==1.11.2
+qtpy==1.11.3
 regex==2021.11.10
 requests==2.26.0
 requests-oauthlib==1.3.0
diff --git a/.constraints/py3.7.txt b/.constraints/py3.7.txt
index cee8537f..0471a9a1 100644
--- a/.constraints/py3.7.txt
+++ b/.constraints/py3.7.txt
@@ -25,7 +25,7 @@ cachetools==4.2.4
 certifi==2021.10.8
 cffi==1.15.0
 cfgv==3.3.1
-charset-normalizer==2.0.8
+charset-normalizer==2.0.9
 click==7.1.2
 cloudpickle==2.0.0
 colorama==0.4.4
@@ -52,7 +52,7 @@ flake8-pytest-style==1.5.1
 flake8-rst-docstrings==0.2.3
 flake8-use-fstring==1.3
 flatbuffers==2.0
-fonttools==4.28.2
+fonttools==4.28.3
 fuzzywuzzy==0.18.0
 gast==0.4.0
 gitdb==4.0.9
@@ -60,7 +60,6 @@ gitpython==3.1.24
 google-auth==2.3.3
 google-auth-oauthlib==0.4.6
 google-pasta==0.2.0
-gprof2dot==2021.2.21
 graphviz==0.19
 greenlet==1.1.2
 grpcio==1.42.0
@@ -122,7 +121,7 @@ nbclient==0.5.9
 nbconvert==6.3.0
 nbdime==3.1.1
 nbformat==5.1.3
-nbmake==1.0
+nbmake==1.1
 nest-asyncio==1.5.4
 nodeenv==1.6.0
 notebook==6.4.6
@@ -151,6 +150,7 @@ prompt-toolkit==3.0.23
 protobuf==3.19.1
 ptyprocess==0.7.0
 py==1.11.0
+py-cpuinfo==8.0.0
 pyasn1==0.4.8
 pyasn1-modules==0.2.8
 pycodestyle==2.8.0
@@ -160,14 +160,14 @@ pydata-sphinx-theme==0.7.2
 pydocstyle==6.1.1
 pyflakes==2.4.0
 pygments==2.10.0
-pylint==2.12.1 ; python_version >= "3.7.0"
+pylint==2.12.2 ; python_version >= "3.7.0"
 pyparsing==3.0.6
 pyrsistent==0.18.0
 pytest==6.2.5
+pytest-benchmark==3.4.1
 pytest-cov==3.0.0
 pytest-forked==1.3.0
 pytest-mock==3.6.1
-pytest-profiling==1.7.0
 pytest-xdist==2.4.0
 python-constraint==1.4.0
 python-dateutil==2.8.2
@@ -176,7 +176,7 @@ pyyaml==6.0
 pyzmq==22.3.0
 qrules==0.9.4
 qtconsole==5.2.1
-qtpy==1.11.2
+qtpy==1.11.3
 regex==2021.11.10
 requests==2.26.0
 requests-oauthlib==1.3.0
diff --git a/.constraints/py3.8.txt b/.constraints/py3.8.txt
index eb284d35..a8f38f47 100644
--- a/.constraints/py3.8.txt
+++ b/.constraints/py3.8.txt
@@ -23,7 +23,7 @@ cachetools==4.2.4
 certifi==2021.10.8
 cffi==1.15.0
 cfgv==3.3.1
-charset-normalizer==2.0.8
+charset-normalizer==2.0.9
 click==7.1.2
 cloudpickle==2.0.0
 colorama==0.4.4
@@ -51,7 +51,7 @@ flake8-rst-docstrings==0.2.3
 flake8-type-ignore==0.1.0.post2 ; python_version >= "3.8.0"
 flake8-use-fstring==1.3
 flatbuffers==2.0
-fonttools==4.28.2
+fonttools==4.28.3
 fuzzywuzzy==0.18.0
 gast==0.4.0
 gitdb==4.0.9
@@ -59,7 +59,6 @@ gitpython==3.1.24
 google-auth==2.3.3
 google-auth-oauthlib==0.4.6
 google-pasta==0.2.0
-gprof2dot==2021.2.21
 graphviz==0.19
 greenlet==1.1.2
 grpcio==1.42.0
@@ -121,7 +120,7 @@ nbclient==0.5.9
 nbconvert==6.3.0
 nbdime==3.1.1
 nbformat==5.1.3
-nbmake==1.0
+nbmake==1.1
 nest-asyncio==1.5.4
 nodeenv==1.6.0
 notebook==6.4.6
@@ -150,6 +149,7 @@ prompt-toolkit==3.0.23
 protobuf==3.19.1
 ptyprocess==0.7.0
 py==1.11.0
+py-cpuinfo==8.0.0
 pyasn1==0.4.8
 pyasn1-modules==0.2.8
 pycodestyle==2.8.0
@@ -159,14 +159,14 @@ pydata-sphinx-theme==0.7.2
 pydocstyle==6.1.1
 pyflakes==2.4.0
 pygments==2.10.0
-pylint==2.12.1 ; python_version >= "3.7.0"
+pylint==2.12.2 ; python_version >= "3.7.0"
 pyparsing==3.0.6
 pyrsistent==0.18.0
 pytest==6.2.5
+pytest-benchmark==3.4.1
 pytest-cov==3.0.0
 pytest-forked==1.3.0
 pytest-mock==3.6.1
-pytest-profiling==1.7.0
 pytest-xdist==2.4.0
 python-constraint==1.4.0
 python-dateutil==2.8.2
@@ -175,7 +175,7 @@ pyyaml==6.0
 pyzmq==22.3.0
 qrules==0.9.4
 qtconsole==5.2.1
-qtpy==1.11.2
+qtpy==1.11.3
 regex==2021.11.10
 requests==2.26.0
 requests-oauthlib==1.3.0
diff --git a/.constraints/py3.9.txt b/.constraints/py3.9.txt
index 513983a5..be4ac057 100644
--- a/.constraints/py3.9.txt
+++ b/.constraints/py3.9.txt
@@ -23,7 +23,7 @@ cachetools==4.2.4
 certifi==2021.10.8
 cffi==1.15.0
 cfgv==3.3.1
-charset-normalizer==2.0.8
+charset-normalizer==2.0.9
 click==7.1.2
 cloudpickle==2.0.0
 colorama==0.4.4
@@ -51,7 +51,7 @@ flake8-rst-docstrings==0.2.3
 flake8-type-ignore==0.1.0.post2 ; python_version >= "3.8.0"
 flake8-use-fstring==1.3
 flatbuffers==2.0
-fonttools==4.28.2
+fonttools==4.28.3
 fuzzywuzzy==0.18.0
 gast==0.4.0
 gitdb==4.0.9
@@ -59,7 +59,6 @@ gitpython==3.1.24
 google-auth==2.3.3
 google-auth-oauthlib==0.4.6
 google-pasta==0.2.0
-gprof2dot==2021.2.21
 graphviz==0.19
 greenlet==1.1.2
 grpcio==1.42.0
@@ -120,7 +119,7 @@ nbclient==0.5.9
 nbconvert==6.3.0
 nbdime==3.1.1
 nbformat==5.1.3
-nbmake==1.0
+nbmake==1.1
 nest-asyncio==1.5.4
 nodeenv==1.6.0
 notebook==6.4.6
@@ -149,6 +148,7 @@ prompt-toolkit==3.0.23
 protobuf==3.19.1
 ptyprocess==0.7.0
 py==1.11.0
+py-cpuinfo==8.0.0
 pyasn1==0.4.8
 pyasn1-modules==0.2.8
 pycodestyle==2.8.0
@@ -158,14 +158,14 @@ pydata-sphinx-theme==0.7.2
 pydocstyle==6.1.1
 pyflakes==2.4.0
 pygments==2.10.0
-pylint==2.12.1 ; python_version >= "3.7.0"
+pylint==2.12.2 ; python_version >= "3.7.0"
 pyparsing==3.0.6
 pyrsistent==0.18.0
 pytest==6.2.5
+pytest-benchmark==3.4.1
 pytest-cov==3.0.0
 pytest-forked==1.3.0
 pytest-mock==3.6.1
-pytest-profiling==1.7.0
 pytest-xdist==2.4.0
 python-constraint==1.4.0
 python-dateutil==2.8.2
@@ -174,7 +174,7 @@ pyyaml==6.0
 pyzmq==22.3.0
 qrules==0.9.4
 qtconsole==5.2.1
-qtpy==1.11.2
+qtpy==1.11.3
 regex==2021.11.10
 requests==2.26.0
 requests-oauthlib==1.3.0
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
new file mode 100644
index 00000000..65b26798
--- /dev/null
+++ b/.github/workflows/benchmark.yml
@@ -0,0 +1,55 @@
+name: Benchmark
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+      - epic/*
+
+jobs:
+  benchmark:
+    name: Performance regression
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: 3.9
+      - name: Install dependencies
+        run: |
+          pip install -c .constraints/py3.9.txt -e .[test]
+          pip install .
+      - name: Run pytest-benchmark
+        run: |
+          pytest \
+            --benchmark-only \
+            --benchmark-json output.json \
+            --durations=0
+        working-directory: benchmarks
+      - name: Store result
+        if: github.event_name != 'pull_request'
+        uses: benchmark-action/github-action-benchmark@v1
+        with:
+          name: TensorWaves benchmark results
+          tool: pytest
+          output-file-path: benchmarks/output.json
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          gh-pages-branch: benchmark-results
+          benchmark-data-dir-path: ""
+          auto-push: true
+      - name: Warn on performance decrease
+        if: github.event_name == 'pull_request'
+        uses: benchmark-action/github-action-benchmark@v1
+        with:
+          name: TensorWaves benchmark results
+          tool: pytest
+          output-file-path: benchmarks/output.json
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          gh-pages-branch: benchmark-results
+          benchmark-data-dir-path: ""
+          auto-push: false
+          comment-on-alert: true
+          fail-on-alert: true
diff --git a/.mypy.ini b/.mypy.ini
index 293bdb5c..9f545efa 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -5,7 +5,7 @@ exclude = _build
 show_error_codes = True
 warn_unused_configs = True
 
-[mypy-tests.*]
+[mypy-benchmarks.*,tests.*]
 check_untyped_defs = True
 disallow_incomplete_defs = False
 disallow_untyped_defs = False
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 27df9441..fe897b38 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -37,6 +37,10 @@ repos:
       - id: name-tests-test
         name: Tests should start with test_
         args: ["--django"]
+        exclude: >
+          (?x)^(
+            benchmarks/.*
+          )$
       - id: trailing-whitespace
 
   - repo: https://github.com/ComPWA/repo-maintenance
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 55ba0093..b7af490b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -68,9 +68,10 @@
   "python.testing.unittestEnabled": false,
   "rewrap.wrappingColumn": 79,
   "search.exclude": {
-    "**/tests/**/__init__.py": true,
     "*/.pydocstyle": true,
-    ".constraints/*.txt": true
+    ".constraints/*.txt": true,
+    "benchmarks/**/__init__.py": true,
+    "tests/**/__init__.py": true
   },
   "telemetry.enableCrashReporter": false,
   "telemetry.enableTelemetry": false,
diff --git a/benchmarks/.pydocstyle b/benchmarks/.pydocstyle
new file mode 100644
index 00000000..26d0703b
--- /dev/null
+++ b/benchmarks/.pydocstyle
@@ -0,0 +1,4 @@
+; ignore all pydocstyle errors in this folder
+
+[pydocstyle]
+add_ignore = D
diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/benchmarks/ampform.py b/benchmarks/ampform.py
new file mode 100644
index 00000000..5473667c
--- /dev/null
+++ b/benchmarks/ampform.py
@@ -0,0 +1,186 @@
+# pylint: disable=no-self-use
+from pprint import pprint
+from typing import List, Mapping, Optional, Sequence, Tuple
+
+import ampform
+import numpy as np
+import pytest
+import qrules
+from ampform.dynamics.builder import create_relativistic_breit_wigner_with_ff
+from ampform.helicity import HelicityModel, ParameterValue
+from qrules.combinatorics import StateDefinition
+
+import tensorwaves as tw
+from tensorwaves.data.phasespace import TFUniformRealNumberGenerator
+from tensorwaves.data.transform import HelicityTransformer
+from tensorwaves.function.sympy import create_parametrized_function
+from tensorwaves.interface import DataSample, FitResult, ParametrizedFunction
+
+
+def formulate_amplitude_model(
+    formalism: str,
+    initial_state: StateDefinition,
+    final_state: Sequence[StateDefinition],
+    intermediate_states: Optional[List[str]] = None,
+    interaction_types: Optional[List[str]] = None,
+) -> HelicityModel:
+    reaction = qrules.generate_transitions(
+        initial_state=initial_state,
+        final_state=final_state,
+        allowed_intermediate_particles=intermediate_states,
+        formalism=formalism,
+        allowed_interaction_types=interaction_types,
+    )
+
+    builder = ampform.get_builder(reaction)
+    for name in reaction.get_intermediate_particles().names:
+        builder.set_dynamics(name, create_relativistic_breit_wigner_with_ff)
+    return builder.formulate()
+
+
+def create_function(
+    model: HelicityModel, backend: str, max_complexity: Optional[int] = None
+) -> ParametrizedFunction:
+    return create_parametrized_function(
+        expression=model.expression.doit(),
+        parameters=model.parameter_defaults,
+        max_complexity=max_complexity,
+        backend=backend,
+    )
+
+
+def generate_data(
+    model: HelicityModel,
+    function: ParametrizedFunction,
+    data_sample_size: int,
+    phsp_sample_size: int,
+    transform: bool = False,
+) -> Tuple[DataSample, DataSample]:
+    reaction = model.adapter.reaction_info
+    final_state = reaction.final_state
+    phsp = tw.data.generate_phsp(
+        size=phsp_sample_size,
+        initial_state_mass=reaction.initial_state[-1].mass,
+        final_state_masses={i: p.mass for i, p in final_state.items()},
+        random_generator=TFUniformRealNumberGenerator(seed=0),
+    )
+
+    helicity_transformer = HelicityTransformer(model.adapter)
+    data = tw.data.generate_data(
+        size=data_sample_size,
+        initial_state_mass=reaction.initial_state[-1].mass,
+        final_state_masses={i: p.mass for i, p in final_state.items()},
+        data_transformer=helicity_transformer,
+        intensity=function,
+        random_generator=TFUniformRealNumberGenerator(seed=0),
+    )
+
+    if transform:
+        data = helicity_transformer(data)
+        phsp = helicity_transformer(phsp)
+    return data, phsp
+
+
+def fit(
+    data_set: DataSample,
+    phsp_set: DataSample,
+    function: ParametrizedFunction,
+    initial_parameters: Mapping[str, ParameterValue],
+    backend: str,
+) -> FitResult:
+    estimator = tw.estimator.UnbinnedNLL(
+        function,
+        data=data_set,
+        phsp=phsp_set,
+        backend=backend,
+    )
+    optimizer = tw.optimizer.Minuit2()
+
+    return optimizer.optimize(estimator, initial_parameters)
+
+
+class TestJPsiToGammaPiPi:
+    expected_data = {
+        0: [
+            [1.50757377596, 0.37918944935, 0.73396599969, 1.26106620078],
+            [1.41389525301, -0.07315064441, -0.21998573758, 1.39475985207],
+            [1.52128570461, 0.06569896528, -1.51812710851, 0.0726906006],
+            [1.51480310845, 1.40672331053, 0.49678572189, -0.26260603856],
+            [1.52384281483, 0.79694939592, 1.29832389761, -0.03638188481],
+        ],
+        1: [
+            [1.42066087326, -0.34871369761, -0.72119471428, -1.1654765212],
+            [0.96610319301, -0.26739932067, -0.15455480956, -0.90539883872],
+            [0.60647770024, 0.11616448713, 0.57584161239, -0.06714695611],
+            [1.01045883083, -0.88651015826, -0.46024226278, 0.0713099651],
+            [1.04324742713, -0.48051670276, -0.91259832182, -0.08009031815],
+        ],
+        2: [
+            [0.16866535079, -0.03047575173, -0.01277128542, -0.09558967958],
+            [0.71690155399, 0.34054996508, 0.37454054715, -0.48936101336],
+            [0.96913659515, -0.18186345241, 0.94228549612, -0.00554364449],
+            [0.57163806072, -0.52021315227, -0.03654345912, 0.19129607347],
+            [0.52980975805, -0.31643269316, -0.38572557579, 0.11647220296],
+        ],
+    }
+
+    @pytest.fixture(scope="session")
+    def model(self) -> HelicityModel:
+        return formulate_amplitude_model(
+            formalism="canonical-helicity",
+            initial_state=("J/psi(1S)", [-1, +1]),
+            final_state=["gamma", "pi0", "pi0"],
+            intermediate_states=["f(0)(500)", "f(0)(980)"],
+            interaction_types=["EM", "strong"],
+        )
+
+    @pytest.mark.benchmark(group="data", min_rounds=1)
+    @pytest.mark.parametrize("backend", ["jax"])
+    @pytest.mark.parametrize("size", [10_000])
+    def test_data(self, backend, benchmark, model, size):
+        n_data = size
+        n_phsp = 10 * n_data
+        function = create_function(model, backend)
+        data, phsp = benchmark(generate_data, model, function, n_data, n_phsp)
+        assert len(next(iter(data.values()))) == n_data
+        assert len(next(iter(phsp.values()))) == n_phsp
+
+        # test data sample values
+        # https://github.com/ComPWA/tensorwaves/blob/b5abfad/tests/integration/ampform/test_data.py
+        sample_size = len(next(iter(self.expected_data.values())))
+        print_data_sample(data, sample_size)
+        for i, expected in self.expected_data.items():
+            assert pytest.approx(data[i][:sample_size]) == expected
+
+    @pytest.mark.benchmark(group="fit", min_rounds=1)
+    @pytest.mark.parametrize("backend", ["jax"])
+    @pytest.mark.parametrize("size", [10_000])
+    def test_fit(self, backend, benchmark, model, size):
+        n_data = size
+        n_phsp = 10 * n_data
+        function = create_function(model, backend)
+        data, phsp = generate_data(model, function, n_data, n_phsp, True)
+
+        coefficients = [p for p in function.parameters if p.startswith("C_{")]
+        assert len(coefficients) >= 1
+        coefficient_name = coefficients[1]
+        initial_parameters = {
+            coefficient_name: 1.0 + 0.0j,
+            "Gamma_f(0)(500)": 0.3,
+            "m_f(0)(980)": 1,
+        }
+        fit_result = benchmark(
+            fit, data, phsp, function, initial_parameters, backend
+        )
+        assert fit_result.minimum_valid
+
+
+def print_data_sample(data: DataSample, sample_size: int) -> None:
+    """Print a `.DataSample`, so it can be pasted into the expected sample."""
+    print()
+    pprint(
+        {
+            i: np.round(four_momenta[:sample_size], decimals=11).tolist()
+            for i, four_momenta in data.items()
+        }
+    )
diff --git a/benchmarks/expression.py b/benchmarks/expression.py
new file mode 100644
index 00000000..6c3d5562
--- /dev/null
+++ b/benchmarks/expression.py
@@ -0,0 +1,146 @@
+# pylint: disable=invalid-name, redefined-outer-name
+from typing import Tuple, Type, Union
+
+import numpy as np
+import pytest
+import sympy as sp
+
+from tensorwaves.estimator import UnbinnedNLL
+from tensorwaves.function.sympy import create_parametrized_function
+from tensorwaves.interface import DataSample, Function
+from tensorwaves.optimizer import Minuit2, ScipyMinimizer
+
+
+def gaussian(x: sp.Symbol, mu: sp.Symbol, sigma: sp.Symbol) -> sp.Expr:
+    return sp.exp(-(((x - mu) / sigma) ** 2) / 2)
+
+
+def poisson(x: sp.Symbol, k: sp.Symbol) -> sp.Expr:
+    return x ** k * sp.exp(-x) / sp.factorial(k)
+
+
+symbols = sp.symbols("x y (a:c) mu_(:2) sigma_(:2) omega")
+x, y, a, b, c, mu1, mu2, sigma1, sigma2, omega = symbols
+expression = (
+    a * gaussian(x, mu1, sigma1)
+    + b * gaussian(x, mu2, sigma2)
+    + c * poisson(x, k=2)
+) * sp.cos(y * omega) ** 2
+
+domain_boundaries = {"x": (0, 5), "y": (-np.pi, +np.pi)}
+parameter_defaults = {
+    a: 0.15,
+    b: 0.05,
+    c: 0.3,
+    mu1: 1.0,
+    mu2: 2.7,
+    omega: 0.5,
+    sigma1: 0.3,
+    sigma2: 0.5,
+}
+initial_parameters = {
+    "a": 0.2,
+    "b": 0.1,
+    "c": 0.2,
+    "mu_0": 0.9,
+    "sigma_0": 0.4,
+    "sigma_1": 0.4,
+}
+
+
+def _generate_domain(
+    size: int,
+    rng: np.random.Generator,
+) -> DataSample:
+    return {
+        var_name: rng.uniform(size=size, low=low, high=high)
+        for var_name, (low, high) in domain_boundaries.items()
+    }
+
+
+def _generate_data(
+    size: int,
+    function: Function,
+    rng: np.random.Generator,
+    bunch_size: int = 10_000,
+) -> DataSample:
+    collected_sample = {var: np.array([]) for var in domain_boundaries}
+    some_variable = next(iter(domain_boundaries))
+    while len(collected_sample[some_variable]) < size:
+        phsp = _generate_domain(bunch_size, rng)
+        y_values = function(phsp)
+        y_max = np.max(y_values)
+        random_y_values = rng.uniform(size=bunch_size, high=y_max)
+        hit_and_miss_sample = {
+            var: phsp[var][random_y_values < y_values]
+            for var in domain_boundaries
+        }
+        collected_sample = {
+            var: np.concatenate(
+                [collected_sample[var], hit_and_miss_sample[var]]
+            )
+            for var in domain_boundaries
+        }
+    return {var: collected_sample[var][:size] for var in domain_boundaries}
+
+
+def generate_data_and_domain(
+    backend: str, n_domain: int, n_data: int
+) -> Tuple[DataSample, DataSample]:
+    function = create_parametrized_function(
+        expression=expression,
+        parameters=parameter_defaults,
+        backend=backend,
+    )
+    rng = np.random.default_rng(seed=0)
+    domain = _generate_domain(n_domain, rng)
+    data = _generate_data(n_data, function, rng)
+    return domain, data
+
+
+@pytest.mark.benchmark(group="data-simple")
+@pytest.mark.parametrize("backend", ["jax", "numpy", "numba", "tf"])
+@pytest.mark.parametrize("size", [3_000])
+def test_data(backend, benchmark, size):
+    domain, data = benchmark(
+        generate_data_and_domain, backend, n_data=size, n_domain=10 * size
+    )
+    assert pytest.approx(domain["x"][0]) == 3.18481
+    assert pytest.approx(data["x"][0]) == 3.46731
+
+
+@pytest.mark.benchmark(group="fit-simple")
+@pytest.mark.parametrize("backend", ["jax", "numpy", "numba", "tf"])
+@pytest.mark.parametrize("optimizer_type", [Minuit2, ScipyMinimizer])
+@pytest.mark.parametrize("size", [1_000])
+def test_fit(  # pylint: disable=too-many-locals
+    backend: str,
+    benchmark,
+    optimizer_type: Union[
+        Type[Minuit2],
+        Type[ScipyMinimizer],
+    ],
+    size: int,
+):
+    domain, data = generate_data_and_domain(
+        backend, n_data=size, n_domain=10 * size
+    )
+    function = create_parametrized_function(
+        expression=expression,
+        parameters=parameter_defaults,
+        backend=backend,
+    )
+
+    original_parameters = function.parameters
+    estimator = UnbinnedNLL(function, data, domain, backend=backend)  # type: ignore[arg-type]
+    original_nll = estimator(function.parameters)
+    optimizer = optimizer_type()
+    result = benchmark(optimizer.optimize, estimator, function.parameters)
+
+    assert pytest.approx(result.estimator_value, rel=1e-2) == original_nll
+    if optimizer_type not in {ScipyMinimizer}:
+        assert result.minimum_valid
+    for par in function.parameters:
+        original_value = original_parameters[par]
+        converged_value = result.parameter_values[par]
+        assert pytest.approx(original_value, rel=0.3) == converged_value
diff --git a/docs/index.md b/docs/index.md
index 105fe848..12a5ccdf 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -57,6 +57,7 @@ maxdepth: 3
 install
 usage
 API <api/tensorwaves>
+Continuous benchmarks <https://compwa.github.io/tensorwaves>
 Changelog <https://github.com/ComPWA/tensorwaves/releases>
 Upcoming features <https://github.com/ComPWA/tensorwaves/milestones?direction=asc&sort=title&state=open>
 Help developing <https://compwa-org.rtfd.io/en/stable/develop.html>
diff --git a/setup.cfg b/setup.cfg
index 9e89c6e6..bff10583 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -89,9 +89,9 @@ test =
     ipython  # test _repr_pretty_
     nbmake
     pytest
+    pytest-benchmark
     pytest-cov
     pytest-mock >=3.3.0
-    pytest-profiling
     pytest-xdist
 format =
     black; python_version >="3.7.0"
diff --git a/tox.ini b/tox.ini
index 83b8f9db..37cf70fc 100644
--- a/tox.ini
+++ b/tox.ini
@@ -14,15 +14,30 @@ description =
 allowlist_externals =
     pytest
 commands =
-    pytest src {posargs:tests/unit}
+    pytest src {posargs:tests}
+
+[testenv:bench]
+description =
+    Run benchmark tests and visualize in histogram
+allowlist_externals =
+    pytest
+deps =
+    pygal
+    pygaljs
+commands =
+    pytest {posargs:benchmarks} \
+        --durations=0 \
+        --benchmark-autosave \
+        --benchmark-histogram \
+        --benchmark-only
 
 [testenv:cov]
 description =
-    Compute fast test coverage
+    Compute test coverage
 allowlist_externals =
     pytest
 commands =
-    pytest {posargs:tests/unit} \
+    pytest src {posargs:tests} \
         --cov-fail-under=90 \
         --cov-report=html \
         --cov-report=xml \
@@ -93,6 +108,14 @@ allowlist_externals =
 commands =
     make ignore-warnings=1 linkcheck # for margin directive
 
+[testenv:nb]
+description =
+    Run all notebooks with pytest
+allowlist_externals =
+    pytest
+commands =
+    pytest --nbmake {posargs:docs}
+
 [testenv:pydeps]
 description =
     Visualize module dependencies
@@ -116,23 +139,3 @@ allowlist_externals =
     pre-commit
 commands =
     pre-commit run {posargs} -a
-
-[testenv:test]
-description =
-    Run unit tests, integration tests, and doctests
-allowlist_externals =
-    pytest
-commands =
-    pytest {posargs} \
-        --cov-fail-under=90 \
-        --cov-report=html \
-        --cov-report=xml \
-        --cov=tensorwaves
-
-[testenv:nb]
-description =
-    Run all notebooks with pytest
-allowlist_externals =
-    pytest
-commands =
-    pytest --nbmake {posargs:docs}


Test patch:
diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py
new file mode 100644
index 00000000..60f4a55b
--- /dev/null
+++ b/benchmarks/conftest.py
@@ -0,0 +1,6 @@
+from _pytest.config import Config
+
+
+def pytest_configure(config: Config):
+    # cspell:ignore addinivalue
+    config.addinivalue_line("python_files", "*.py")
diff --git a/pytest.ini b/pytest.ini
index e36a3867..af21a813 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -4,6 +4,7 @@ source = src
 
 [pytest]
 addopts =
+    --benchmark-skip
     --color=yes
     --doctest-continue-on-failure
     --doctest-modules
@@ -11,6 +12,7 @@ addopts =
     --ignore=docs/abbreviate_signature.py
     --ignore=docs/conf.py
     --ignore-glob=*/.ipynb_checkpoints/*
+    -m "not slow"
 filterwarnings =
     error
     ignore:.*Using or importing the ABCs.*:DeprecationWarning
@@ -22,7 +24,8 @@ filterwarnings =
 norecursedirs =
     _build
 markers =
-    slow: marks tests as slow (deselect with '-m "not slow"')
+    slow: marks tests as slow (select with '-m slow')
 testpaths =
+    benchmarks
     src
     tests
diff --git a/tests/__init__.py b/tests/__init__.py
index 948df262..e69de29b 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,1 +0,0 @@
-"""Required to set mypy options for the tests folder."""
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/data/test_generate.py b/tests/data/test_generate.py
similarity index 100%
rename from tests/unit/data/test_generate.py
rename to tests/data/test_generate.py
diff --git a/tests/unit/data/test_phasespace.py b/tests/data/test_phasespace.py
similarity index 100%
rename from tests/unit/data/test_phasespace.py
rename to tests/data/test_phasespace.py
diff --git a/tests/function/__init__.py b/tests/function/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/function/test_ampform.py b/tests/function/test_ampform.py
similarity index 100%
rename from tests/unit/function/test_ampform.py
rename to tests/function/test_ampform.py
diff --git a/tests/unit/function/test_backend.py b/tests/function/test_backend.py
similarity index 100%
rename from tests/unit/function/test_backend.py
rename to tests/function/test_backend.py
diff --git a/tests/unit/function/test_function.py b/tests/function/test_function.py
similarity index 100%
rename from tests/unit/function/test_function.py
rename to tests/function/test_function.py
diff --git a/tests/unit/function/test_sympy.py b/tests/function/test_sympy.py
similarity index 100%
rename from tests/unit/function/test_sympy.py
rename to tests/function/test_sympy.py
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
deleted file mode 100644
index 948df262..00000000
--- a/tests/integration/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""Required to set mypy options for the tests folder."""
diff --git a/tests/integration/ampform/__init__.py b/tests/integration/ampform/__init__.py
deleted file mode 100644
index 948df262..00000000
--- a/tests/integration/ampform/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""Required to set mypy options for the tests folder."""
diff --git a/tests/integration/ampform/conftest.py b/tests/integration/ampform/conftest.py
deleted file mode 100644
index 3451cc88..00000000
--- a/tests/integration/ampform/conftest.py
+++ /dev/null
@@ -1,135 +0,0 @@
-# pylint: disable=redefined-outer-name
-from typing import Tuple
-
-import ampform
-import pytest
-import qrules
-from _pytest.fixtures import SubRequest
-from ampform.dynamics.builder import create_relativistic_breit_wigner_with_ff
-from ampform.helicity import HelicityModel
-
-from tensorwaves.data import generate_data, generate_phsp
-from tensorwaves.data.phasespace import TFUniformRealNumberGenerator
-from tensorwaves.data.transform import HelicityTransformer
-from tensorwaves.function import ParametrizedBackendFunction
-from tensorwaves.function.sympy import create_parametrized_function
-from tensorwaves.interface import DataSample, DataTransformer
-
-
-@pytest.fixture(scope="session", params=["canonical", "helicity"])
-def reaction(request: SubRequest) -> qrules.ReactionInfo:
-    formalism_aliases = {
-        "canonical": "canonical-helicity",
-        "helicity": "helicity",
-    }
-    return qrules.generate_transitions(
-        initial_state=("J/psi(1S)", [-1, +1]),
-        final_state=["gamma", "pi0", "pi0"],
-        allowed_intermediate_particles=[
-            "f(0)(500)",
-            "f(0)(980)",
-        ],
-        formalism=formalism_aliases[request.param],
-        topology_building="isobar",
-        allowed_interaction_types=["EM", "strong"],
-        number_of_threads=1,
-    )
-
-
-@pytest.fixture(scope="session")
-def helicity_model(reaction: qrules.ReactionInfo) -> HelicityModel:
-    model_builder = ampform.get_builder(reaction)
-    for name in reaction.get_intermediate_particles().names:
-        model_builder.set_dynamics(
-            name, create_relativistic_breit_wigner_with_ff
-        )
-    return model_builder.formulate()
-
-
-@pytest.fixture(scope="session")
-def kinematics(helicity_model: HelicityModel) -> DataTransformer:
-    return HelicityTransformer(helicity_model.adapter)
-
-
-@pytest.fixture(scope="session")
-def phsp_sample(reaction: qrules.ReactionInfo) -> DataSample:
-    n_events = int(1e5)
-    initial_state = reaction.initial_state
-    final_state = reaction.final_state
-    rng = TFUniformRealNumberGenerator(seed=0)
-    sample = generate_phsp(
-        size=n_events,
-        initial_state_mass=initial_state[-1].mass,
-        final_state_masses={i: p.mass for i, p in final_state.items()},
-        random_generator=rng,
-    )
-    assert all(map(lambda v: len(v) == n_events, sample.values()))
-    return sample
-
-
-@pytest.fixture(scope="session")
-def phsp_set(
-    kinematics: DataTransformer, phsp_sample: DataSample
-) -> DataSample:
-    return kinematics(phsp_sample)
-
-
-@pytest.fixture(
-    scope="session",
-    params=[
-        ("jax", False),
-        ("jax", True),
-    ],
-    ids=[
-        "jax-normal",
-        "jax-fast",
-    ],
-)
-def function_fixture(
-    helicity_model: HelicityModel, request: SubRequest
-) -> Tuple[ParametrizedBackendFunction, str]:
-    backend, fast_lambdify = request.param
-    if fast_lambdify:
-        max_complexity = None
-        lambdify_type = f"{backend}-fast"
-    else:
-        max_complexity = 200
-        lambdify_type = f"{backend}-normal"
-    function = create_parametrized_function(
-        expression=helicity_model.expression.doit(),
-        parameters=helicity_model.parameter_defaults,
-        max_complexity=max_complexity,
-        backend=backend,
-    )
-    return function, lambdify_type
-
-
-@pytest.fixture(scope="session")
-def data_sample(
-    reaction: qrules.ReactionInfo,
-    kinematics: DataTransformer,
-    function_fixture: Tuple[ParametrizedBackendFunction, str],
-) -> DataSample:
-    function, _ = function_fixture
-    n_events = int(1e4)
-    initial_state = reaction.initial_state
-    final_state = reaction.final_state
-    rng = TFUniformRealNumberGenerator(seed=0)
-    sample = generate_data(
-        size=n_events,
-        initial_state_mass=initial_state[-1].mass,
-        final_state_masses={i: p.mass for i, p in final_state.items()},
-        data_transformer=kinematics,
-        intensity=function,
-        random_generator=rng,
-    )
-    assert all(map(lambda v: len(v) == n_events, sample.values()))
-    return sample
-
-
-@pytest.fixture(scope="session")
-def data_set(
-    kinematics: DataTransformer,
-    data_sample: DataSample,
-) -> DataSample:
-    return kinematics(data_sample)
diff --git a/tests/integration/ampform/test_data.py b/tests/integration/ampform/test_data.py
deleted file mode 100644
index c813ab62..00000000
--- a/tests/integration/ampform/test_data.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# cspell:ignore tolist
-from pprint import pprint
-
-import numpy as np
-import pytest
-
-from tensorwaves.interface import DataSample
-
-
-def test_generate_data(data_sample: DataSample):
-    sample_size = 5
-    sub_sample = {k: v[:sample_size] for k, v in data_sample.items()}
-    print("Expected values, get by running pytest with the -s flag")
-    pprint(
-        {
-            i: np.round(four_momenta, decimals=11).tolist()
-            for i, four_momenta in sub_sample.items()
-        }
-    )
-    expected_sample = {
-        0: [
-            [1.50757377596, 0.37918944935, 0.73396599969, 1.26106620078],
-            [1.41389525301, -0.07315064441, -0.21998573758, 1.39475985207],
-            [1.52128570461, 0.06569896528, -1.51812710851, 0.0726906006],
-            [1.51480310845, 1.40672331053, 0.49678572189, -0.26260603856],
-            [1.52384281483, 0.79694939592, 1.29832389761, -0.03638188481],
-        ],
-        1: [
-            [1.42066087326, -0.34871369761, -0.72119471428, -1.1654765212],
-            [0.96610319301, -0.26739932067, -0.15455480956, -0.90539883872],
-            [0.60647770024, 0.11616448713, 0.57584161239, -0.06714695611],
-            [1.01045883083, -0.88651015826, -0.46024226278, 0.0713099651],
-            [1.04324742713, -0.48051670276, -0.91259832182, -0.08009031815],
-        ],
-        2: [
-            [0.16866535079, -0.03047575173, -0.01277128542, -0.09558967958],
-            [0.71690155399, 0.34054996508, 0.37454054715, -0.48936101336],
-            [0.96913659515, -0.18186345241, 0.94228549612, -0.00554364449],
-            [0.57163806072, -0.52021315227, -0.03654345912, 0.19129607347],
-            [0.52980975805, -0.31643269316, -0.38572557579, 0.11647220296],
-        ],
-    }
-    n_events = len(next(iter(expected_sample.values())))
-    assert set(sub_sample) == set(expected_sample)
-    for i in expected_sample:  # pylint: disable=consider-using-dict-items
-        expected_momenta = expected_sample[i]
-        momenta = sub_sample[i]
-        assert len(expected_momenta) == n_events
-        assert len(momenta) == n_events
-        assert pytest.approx(momenta) == expected_sample[i]
diff --git a/tests/integration/ampform/test_optimize.py b/tests/integration/ampform/test_optimize.py
deleted file mode 100644
index 68b57028..00000000
--- a/tests/integration/ampform/test_optimize.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# pylint: disable=redefined-outer-name
-from pathlib import Path
-from typing import Tuple
-
-import pytest
-import qrules
-
-from tensorwaves.estimator import UnbinnedNLL
-from tensorwaves.function import ParametrizedBackendFunction
-from tensorwaves.interface import DataSample
-from tensorwaves.optimizer.callbacks import (
-    CallbackList,
-    CSVSummary,
-    TFSummary,
-    YAMLSummary,
-)
-from tensorwaves.optimizer.minuit import Minuit2
-
-
-@pytest.fixture(scope="session")
-def estimator(
-    function_fixture: Tuple[ParametrizedBackendFunction, str],
-    data_set: DataSample,
-    phsp_set: DataSample,
-) -> UnbinnedNLL:
-    function, _ = function_fixture
-    return UnbinnedNLL(
-        function,
-        dict(data_set),
-        dict(phsp_set),
-        backend="jax",
-    )
-
-
-def test_fit_and_callbacks(  # pylint: disable=too-many-locals
-    estimator: UnbinnedNLL,
-    function_fixture: Tuple[ParametrizedBackendFunction, str],
-    output_dir: Path,
-    reaction: qrules.ReactionInfo,
-):
-    _, lambdify_type = function_fixture
-    formalism_alias = reaction.formalism[:4]
-    filename = output_dir / f"fit_result_{formalism_alias}_{lambdify_type}"
-    csv_file = f"{filename}.csv"
-    yml_file = f"{filename}.yml"
-    optimizer = Minuit2(
-        callback=CallbackList(
-            [
-                CSVSummary(csv_file),
-                TFSummary(),
-                YAMLSummary(yml_file, step_size=1),
-            ]
-        )
-    )
-
-    if reaction.formalism == "canonical-helicity":
-        coefficient_name = (
-            R"C_{J/\psi(1S) \xrightarrow[S=1]{L=0} f_{0}(500) \gamma;"
-            R" f_{0}(500) \xrightarrow[S=0]{L=0} \pi^{0} \pi^{0}}"
-        )
-    else:
-        coefficient_name = (
-            R"C_{J/\psi(1S) \to f_{0}(980)_{0} \gamma_{+1}; f_{0}(980) \to"
-            R" \pi^{0}_{0} \pi^{0}_{0}}"
-        )
-    initial_parameters = {
-        coefficient_name: 1.0 + 0.0j,
-        "Gamma_f(0)(500)": 0.3,
-        "m_f(0)(980)": 1,
-    }
-    fit_result = optimizer.optimize(estimator, initial_parameters)
-
-    final_parameters = fit_result.parameter_values
-    csv_parameters = CSVSummary.load_latest_parameters(csv_file)
-    yml_parameters = YAMLSummary.load_latest_parameters(yml_file)
-    for par, value in final_parameters.items():
-        assert pytest.approx(value, rel=1e-2) == csv_parameters[par]
-        assert pytest.approx(value, rel=1e-2) == yml_parameters[par]
diff --git a/tests/unit/optimizer/__init__.py b/tests/optimizer/__init__.py
similarity index 96%
rename from tests/unit/optimizer/__init__.py
rename to tests/optimizer/__init__.py
index 2a3aed78..405e0a19 100644
--- a/tests/unit/optimizer/__init__.py
+++ b/tests/optimizer/__init__.py
@@ -1,5 +1,3 @@
-"""Required to set mypy options for the tests folder."""
-
 from enum import Enum
 from typing import Any, Dict, Optional
 from unittest.mock import MagicMock
diff --git a/tests/optimizer/test_callbacks.py b/tests/optimizer/test_callbacks.py
new file mode 100644
index 00000000..02ed451f
--- /dev/null
+++ b/tests/optimizer/test_callbacks.py
@@ -0,0 +1,7 @@
+# pylint: disable=no-self-use
+from tensorwaves.optimizer.callbacks import CallbackList
+
+
+class TestCallbackList:
+    def test_eq(self):
+        assert CallbackList([]) == CallbackList([])
diff --git a/tests/unit/optimizer/test_fit_simple_model.py b/tests/optimizer/test_fit_simple_model.py
similarity index 100%
rename from tests/unit/optimizer/test_fit_simple_model.py
rename to tests/optimizer/test_fit_simple_model.py
diff --git a/tests/unit/optimizer/test_gradient.py b/tests/optimizer/test_gradient.py
similarity index 100%
rename from tests/unit/optimizer/test_gradient.py
rename to tests/optimizer/test_gradient.py
diff --git a/tests/unit/optimizer/test_minuit.py b/tests/optimizer/test_minuit.py
similarity index 100%
rename from tests/unit/optimizer/test_minuit.py
rename to tests/optimizer/test_minuit.py
diff --git a/tests/unit/optimizer/test_parameter_flattener.py b/tests/optimizer/test_parameter.py
similarity index 100%
rename from tests/unit/optimizer/test_parameter_flattener.py
rename to tests/optimizer/test_parameter.py
diff --git a/tests/unit/optimizer/test_scipy.py b/tests/optimizer/test_scipy.py
similarity index 100%
rename from tests/unit/optimizer/test_scipy.py
rename to tests/optimizer/test_scipy.py
diff --git a/tests/unit/test_estimator.py b/tests/test_estimator.py
similarity index 100%
rename from tests/unit/test_estimator.py
rename to tests/test_estimator.py
diff --git a/tests/unit/test_interface.py b/tests/test_interface.py
similarity index 100%
rename from tests/unit/test_interface.py
rename to tests/test_interface.py
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
deleted file mode 100644
index 948df262..00000000
--- a/tests/unit/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""Required to set mypy options for the tests folder."""
diff --git a/tests/unit/data/__init__.py b/tests/unit/data/__init__.py
deleted file mode 100644
index 948df262..00000000
--- a/tests/unit/data/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""Required to set mypy options for the tests folder."""
diff --git a/tests/unit/function/__init__.py b/tests/unit/function/__init__.py
deleted file mode 100644
index 948df262..00000000
--- a/tests/unit/function/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""Required to set mypy options for the tests folder."""