Repository: basf/mopti (language: python)

Issue:
Problem sanitizer
Create functionality that sanitizes a problem.
- all continuous columns are normalized to `[0, 1]`, if there are no non-linear constraints
- names of inputs and output are replaced by `input_i` or `output_i`.


Discussion / hints:

Fix patch:
diff --git a/opti/problems/__init__.py b/opti/problems/__init__.py
index b2366b7..1571260 100644
--- a/opti/problems/__init__.py
+++ b/opti/problems/__init__.py
@@ -30,6 +30,7 @@
     noisify_problem_with_gaussian,
     noisify_problem_with_scipy_stats,
 )
+from opti.problems.sanitize import sanitize_problem
 from opti.problems.single import (
     Ackley,
     Himmelblau,
diff --git a/opti/problems/sanitize.py b/opti/problems/sanitize.py
new file mode 100644
index 0000000..6fdf1da
--- /dev/null
+++ b/opti/problems/sanitize.py
@@ -0,0 +1,135 @@
+import warnings
+from copy import deepcopy
+from enum import Enum
+from typing import Tuple
+
+import numpy as np
+import pandas as pd
+
+from opti.constraint import LinearEquality, LinearInequality, NChooseK
+from opti.parameter import Continuous, Parameters
+from opti.problem import Problem
+
+
+def _normalize_parameters_data(
+    data: pd.DataFrame, params: Parameters
+) -> Tuple[pd.DataFrame, np.ndarray, np.ndarray]:
+    """Transform data from the domain bounds to the unit range"""
+
+    lo = params.bounds.loc["min"]
+    hi = params.bounds.loc["max"]
+
+    lo[lo == -np.inf] = data[lo[lo == -np.inf].index].min(axis=0)
+    hi[hi == np.inf] = data[hi[hi == np.inf].index].max(axis=0)
+
+    data = (data[params.names] - lo) / (hi - lo)
+    min_vals = lo.values
+    delta_vals = (hi - lo).values
+
+    return data, min_vals, delta_vals
+
+
+class InOrOut(str, Enum):
+    IN = "input"
+    OUT = "output"
+
+
+def _sanitize_params(parameters: Parameters, in_or_out: InOrOut):
+    """Sets parameter boundaries to 0 and 1 and replaces parameter names"""
+    sanitized = []
+    for i, p in enumerate(parameters):
+        name = f"{in_or_out}_{i}"
+        if isinstance(p, Continuous):
+            low = -np.inf if np.isneginf(p.low) else 0
+            high = np.inf if np.isinf(p.high) else 1
+            domain = [low, high]
+        else:
+            raise TypeError("currently only continuous parameters are supported")
+        sanitized.append(type(p)(name, domain))
+    return Parameters(sanitized)
+
+
+def sanitize_problem(problem: Problem):
+    """
+    This creates a transformation of the problem with sanitized data. Thereby, we try
+    to preserve relationships between inputs, outputs, and objectives.
+
+    More precisely, the resulting problem has the following properties:
+    - Inputs are named `input_0`, `input_1`, .... Outputs are named analogously.
+    - The data is scaled per feature to `[0, 1]`.
+    - Coefficients of linear constraints are adapted to the data scaling.
+    - Models and evaluatons in terms of `f` are dropped if there are any.
+
+    Currently unsuported are problems with
+    - discrete or categorical variables,
+    - input constraints that are neither linear nor n-choose-k, or
+    - output constraints.
+
+    Args:
+        problem: to be sanitized
+
+    Raises:
+        TypeError: in case there are unsupported constraints, data is None, or there are output constraints
+
+    Returns:
+        Problem instance with sanitized labels and normalized data
+    """
+    if problem.data is None:
+        raise TypeError("we cannot sanitize a problem without data")
+    if problem.output_constraints is not None:
+        raise TypeError("output constraints are currently not supported")
+    if getattr(problem, "f", None) is not None:
+        warnings.warn("f is not sanitized but dropped")
+    if problem.models is not None:
+        warnings.warn("models are not sanitized but dropped")
+
+    inputs = _sanitize_params(problem.inputs, InOrOut.IN)
+    input_name_map = {pi.name: i.name for pi, i in zip(problem.inputs, inputs)}
+    normalized_in_data, xmin, Δx = _normalize_parameters_data(
+        problem.data, problem.inputs
+    )
+    outputs = _sanitize_params(problem.outputs, InOrOut.OUT)
+    output_name_map = {pi.name: i.name for pi, i in zip(problem.outputs, outputs)}
+    normalized_out_data, ymin, Δy = _normalize_parameters_data(
+        problem.data, problem.outputs
+    )
+
+    normalized_in_data.columns = inputs.names
+    normalized_out_data.columns = outputs.names
+    normalized_data = pd.concat([normalized_in_data, normalized_out_data], axis=1)
+    normalized_data.reset_index(inplace=True, drop=True)
+
+    objectives = deepcopy(problem.objectives)
+    for obj in objectives:
+        sanitized_name = output_name_map[obj.name]
+        i = outputs.names.index(sanitized_name)
+        obj.name = sanitized_name
+        obj.parameter = sanitized_name
+        obj.target = (obj.target - ymin[i]) / Δy[i]
+        if hasattr(obj, "tolerance"):
+            obj.tolerance /= Δy[i]
+
+    constraints = deepcopy(problem.constraints)
+    if constraints is not None:
+        for c in constraints:
+            c.names = [input_name_map[n] for n in c.names]
+            if isinstance(c, (LinearEquality, LinearInequality)):
+                c.lhs = (c.lhs + xmin) * Δx
+                if c.rhs > 1e-5:
+                    c.lhs = c.lhs / c.rhs
+                    c.rhs = 1.0
+            elif isinstance(c, NChooseK):
+                pass
+            else:
+                raise TypeError(
+                    "sanitizer only supports linear and n-choose-k constraints"
+                )
+
+    normalized_problem = Problem(
+        inputs=inputs,
+        outputs=outputs,
+        objectives=objectives,
+        constraints=constraints,
+        data=normalized_data,
+    )
+    return normalized_problem


Test patch:
diff --git a/test/test_problems.py b/test/test_problems.py
index 189168a..6e037ab 100644
--- a/test/test_problems.py
+++ b/test/test_problems.py
@@ -7,6 +7,8 @@
 
 import opti
 from opti import Categorical, Continuous, Discrete, Problem
+from opti.constraint import LinearEquality
+from opti.objective import CloseToTarget, Maximize, Minimize
 from opti.problems.multi_fidelity import create_multi_fidelity
 from opti.problems.noisify import (
     NoiseType,
@@ -366,3 +368,103 @@ def pred_first(counter, _):
         for col in Y_noise.T:
             s, _ = stats.ks_2samp(col, ref)
             assert s < 0.1
+
+
+def test_sanitize_problem():
+    def _test(original):
+        sanitized = opti.problems.sanitize_problem(original)
+
+        # check inputs
+        for p1, p2 in zip(original.inputs, sanitized.inputs):
+            assert type(p1) == type(p2)
+            if isinstance(p2, Continuous):
+                p2.bounds == (0, 1)  # continuous input bounds are [0, 1]
+            assert p2.name.startswith("input")
+
+        # check outputs
+        for p1, p2 in zip(original.outputs, sanitized.outputs):
+            assert type(p1) == type(p2)
+            if isinstance(
+                p2, Continuous
+            ):  # continuous output bounds are [0, 1] if specified and [-inf, inf] if not
+                if np.isneginf(p1.low):
+                    assert np.isneginf(p2.low)
+                else:
+                    assert np.isclose(p2.low, 0)
+                if np.isinf(p1.high):
+                    assert np.isinf(p2.high)
+                else:
+                    assert np.isclose(p2.high, 1)
+            assert p2.name.startswith("output")
+
+        # check objectives
+        for (p1, p2) in zip(original.objectives, sanitized.objectives):
+            assert type(p1) == type(p2)
+            assert p2.name.startswith("output")
+            assert np.isfinite(p2.target)
+
+        # check constraints
+        if original.constraints is not None:
+            for c1, c2 in zip(original.constraints, sanitized.constraints):
+                # sanitizing should not modify the original problem
+                assert c1.names != c2.names
+
+        # check data
+        assert original.data.shape == sanitized.data.shape
+        assert set(sanitized.data.columns) == set(
+            sanitized.inputs.names + sanitized.outputs.names
+        )
+        assert sanitized.inputs.contains(sanitized.data[sanitized.inputs.names]).all()
+        assert sanitized.constraints.satisfied(sanitized.data).all()
+
+        # problem.f is dropped
+        assert not hasattr(sanitized, "f")
+
+    _test(opti.problems.Cake())
+
+    _test(opti.problems.Photodegradation())
+
+    z = opti.problems.Zakharov_Constrained()
+    z.create_initial_data(n_samples=1000)
+    _test(z)
+
+    tp = Problem(
+        inputs=[
+            Continuous("secret_ingredient_1", domain=[0, 150]),
+            Continuous("secret_ingredient_2", domain=[0, 50]),
+            Continuous("secret_ingredient_3", domain=[0, 34.3]),
+        ],
+        outputs=[
+            Continuous("ultimate_goal", domain=[0, np.linalg.norm([150, 50, 34.3])]),
+            Continuous("mega_goal", domain=[0, np.sum(np.square([150, 50, 34.3]))]),
+            Continuous(
+                "super_goal", domain=[0, np.linalg.norm([150, 50, 34.3]) ** 0.5]
+            ),
+        ],
+        objectives=[
+            CloseToTarget("ultimate_goal", target=100),
+            Minimize("mega_goal", target=1000),
+            Maximize("super_goal", target=10),
+        ],
+        constraints=[
+            LinearEquality(
+                ["secret_ingredient_1", "secret_ingredient_2", "secret_ingredient_3"],
+                lhs=np.array([3.5, 4.5, 2]),
+                rhs=153.1,
+            )
+        ],
+        f=lambda x: np.stack(
+            [
+                np.linalg.norm(x, axis=1),
+                np.sum(np.square(x), axis=1),
+                np.linalg.norm(x, axis=1) ** 0.5,
+            ],
+            axis=1,
+        ),
+    )
+    tp.create_initial_data(n_samples=100)
+    _test(tp)
+
+    empty = Problem([], [])  # this should not be possible
+    with pytest.raises(TypeError):
+        opti.problems.sanitize_problem(empty)