Repository: MachineLearningLifeScience/poli (language: python)

Issue:
Custom function API addition
Currently we do not provide capabilities to load a custom user-defined function with poli.

We require the user to add any function as an objective to the objective repository.
However that may be a large hurdle for initial users of the poli library.

We could consider adding an empty black-box objective/ dummy objective on any available data assets and let the user specify a custom function for f.

This way benchmarking and dev can be done quicker if a callable function object is available for the user without the requirement to make substantial contributions to poli.


Discussion / hints:
I like it, were you imagining something like this?:

```python
from poli.objective_repository import DummyBlackBox

f = DummyBlackBox(black_box=lambda x: 0.0)

# then f(whatever) == 0.0, and the logging and other functionalities of black boxes can still be attached and measured? 
```
Exactly like that. +1
to preserve shapes, we would need something like `lambda x: np.zeros(len(x)).reshape(1, -1)`, but these are minor details.
We could implement a `lambda` black box registration for isolation.

The way to do it would be by asking the user to implement a subclass of `AbstractIsolatedFunction`.

Fix patch:
diff --git a/pyproject.toml b/pyproject.toml
index 06cef86b..f4518c0c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "poli"
-version = "1.0.0.dev10"
+version = "1.0.0.dev11"
 description = "poli, a library of discrete objective functions"
 readme = "README.md"
 authors = [{name="Miguel González-Duque", email="miguelgondu@gmail.com"}, {name="Simon Bartels"}]
@@ -76,7 +76,7 @@ profile = "black"
 exclude = ["src/poli/core/util/proteins/rasp/inner_rasp", "src/poli/objective_repository/gfp_cbas"]
 
 [tool.bumpversion]
-current_version = "1.0.0.dev10"
+current_version = "1.0.0.dev11"
 parse = """(?x)
     (?P<major>0|[1-9]\\d*)\\.
     (?P<minor>0|[1-9]\\d*)\\.
diff --git a/setup.cfg b/setup.cfg
index 2a127482..c9a63ce6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = poli
-version = "1.0.0.dev10"
+version = "1.0.0.dev11"
 author_email = miguel.gonzalez-duque@bio.ku.dk
 description = Protein Objectives Library
 long_description = file: README.md
diff --git a/src/poli/__init__.py b/src/poli/__init__.py
index 42bd93da..96fd2974 100644
--- a/src/poli/__init__.py
+++ b/src/poli/__init__.py
@@ -1,6 +1,6 @@
 """poli, a library for discrete black-box objective functions."""
 
-__version__ = "1.0.0.dev10"
+__version__ = "1.0.0.dev11"
 from .core.util.isolation.instancing import instance_function_as_isolated_process
 
 # from .core import get_problems
diff --git a/src/poli/core/chemistry/tdc_black_box.py b/src/poli/core/chemistry/tdc_black_box.py
index 970fb6af..97ef8d88 100644
--- a/src/poli/core/chemistry/tdc_black_box.py
+++ b/src/poli/core/chemistry/tdc_black_box.py
@@ -16,6 +16,8 @@
     https://pubs.acs.org/doi/10.1021/acs.jcim.8b00839
 """
 
+from __future__ import annotations
+
 from typing import Literal
 
 import numpy as np
diff --git a/src/poli/core/lambda_black_box.py b/src/poli/core/lambda_black_box.py
new file mode 100644
index 00000000..10d3646e
--- /dev/null
+++ b/src/poli/core/lambda_black_box.py
@@ -0,0 +1,84 @@
+"""
+This module implements a lambda black box:
+
+Once the user has specified a callable f which
+takes as input np.ndarray and returns np.ndarray,
+this black box uses it for the evaluation.
+
+This is useful when the user has already implemented
+a lot of functionality around a callable, and wants to
+leverage all that comes with a poli black box: logging,
+budget management, etc.
+"""
+
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+
+from poli.core.abstract_black_box import AbstractBlackBox
+from poli.core.black_box_information import BlackBoxInformation
+
+
+class LambdaBlackBox(AbstractBlackBox):
+    """
+    A Lambda black box is a black box that uses a callable
+    for the evaluation.
+
+    Parameters:
+    -----------
+    function : Callable[[np.ndarray], np.ndarray]
+        The callable that will be used for the evaluation.
+    info : BlackBoxInformation, optional
+        The information about the black box. If None,
+        a default information is used.
+    batch_size : int, optional
+        The batch size for evaluating the black box function. Default is None.
+    parallelize : bool, optional
+        Flag indicating whether to evaluate the black box function in parallel.
+        Default is False.
+    num_workers : int, optional
+        The number of workers to use for parallel evaluation. Default is None,
+        which uses half of the available CPU cores.
+    evaluation_budget : int, optional
+        The maximum number of evaluations allowed for the black box function.
+        Default is float("inf").
+    force_isolation : bool, optional
+        Flag indicating whether to force isolation of the black
+        box function. In this black box, this flag is ignored.
+    """
+
+    def __init__(
+        self,
+        function: Callable[[np.ndarray], np.ndarray],
+        info: BlackBoxInformation | None = None,
+        batch_size=None,
+        parallelize=False,
+        num_workers=None,
+        evaluation_budget=float("inf"),
+        force_isolation=False,
+    ):
+        super().__init__(
+            batch_size, parallelize, num_workers, evaluation_budget, force_isolation
+        )
+        self.function = function
+        self._information = (
+            info
+            if info is not None
+            else BlackBoxInformation(
+                name="lambda_function",
+                max_sequence_length=np.inf,
+                aligned=False,
+                fixed_length=False,
+                deterministic=False,
+                alphabet=None,
+                discrete=True,
+            )
+        )
+
+    def _black_box(self, x, context=None):
+        return self.function(x)
+
+    def get_black_box_info(self):
+        return self._information


Test patch:
diff --git a/src/poli/tests/observers/__init__.py b/src/poli/tests/observers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/poli/tests/test_lambda_black_box.py b/src/poli/tests/test_lambda_black_box.py
new file mode 100644
index 00000000..0482864d
--- /dev/null
+++ b/src/poli/tests/test_lambda_black_box.py
@@ -0,0 +1,76 @@
+import numpy as np
+
+from poli.core.black_box_information import BlackBoxInformation
+from poli.core.lambda_black_box import LambdaBlackBox
+from poli.tests.observers.test_observers import SimpleObserver
+
+
+def test_lambda_black_box_works_without_custom_info():
+    def f_(x: np.ndarray) -> np.ndarray:
+        return np.zeros((len(x), 1))
+
+    f = LambdaBlackBox(
+        function=f_,
+    )
+
+    assert f.info is not None
+    assert (f(np.ones((10, 10))) == 0.0).all
+    assert f.num_evaluations == 10
+
+
+def test_lambda_black_box_with_custom_info_works():
+    def f_(x: np.ndarray) -> np.ndarray:
+        return np.zeros((len(x), 1))
+
+    f = LambdaBlackBox(
+        function=f_,
+        info=BlackBoxInformation(
+            name="zero",
+            max_sequence_length=np.inf,
+            aligned=False,
+            fixed_length=False,
+            deterministic=True,
+            alphabet=None,
+            discrete=True,
+        ),
+    )
+
+    assert f.info is not None
+    assert f.info.name == "zero"
+    assert f.info.deterministic
+    assert (f(np.ones((10, 10))) == 0.0).all
+    assert f.num_evaluations == 10
+
+
+def test_attaching_observer_to_lambda_black_box_works():
+    def f_(x: np.ndarray) -> np.ndarray:
+        return np.zeros((len(x), 1))
+
+    f = LambdaBlackBox(
+        function=f_,
+        info=BlackBoxInformation(
+            name="zero",
+            max_sequence_length=np.inf,
+            aligned=False,
+            fixed_length=False,
+            deterministic=True,
+            alphabet=None,
+            discrete=True,
+        ),
+    )
+
+    observer = SimpleObserver()
+    observer.initialize_observer(
+        problem_setup_info=f.info, caller_info={"experiment_id": "example"}, seed=None
+    )
+
+    f.set_observer(observer)
+
+    f(np.ones((10, 10)))
+
+    assert (np.array(observer.results[0]["x"]) == 1.0).all()
+    assert (np.array(observer.results[0]["y"]) == 0.0).all()
+
+
+if __name__ == "__main__":
+    test_attaching_observer_to_lambda_black_box_works()