optapy / optapy

OptaPy is an AI constraint solver for Python to optimize planning and scheduling problems.

Home Page:https://www.optapy.org/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Final best solution can't be used to explain the score

ubersan opened this issue · comments

Hi, it seems the returned object of getFinalBestSolution can't be used in explainScore.

You can reproduce this behavior by running this snippet:

import time

import optapy
import optapy.config
import optapy.constraint
import optapy.score
import optapy.types
from org.optaplanner.core.api.solver import SolverStatus


@optapy.problem_fact
class Value:
    def __init__(self, number):
        self.number = number


@optapy.planning_entity
class Entity:
    def __init__(self, code, value=None):
        self.code = code
        self.value = value

    @optapy.planning_variable(Value, ["value_range"])
    def get_value(self):
        return self.value

    def set_value(self, value):
        self.value = value


@optapy.planning_solution
class Solution:
    def __init__(self, entity_list, value_list, score=None):
        self.entity_list = entity_list
        self.value_list = value_list
        self.score = score

    @optapy.planning_entity_collection_property(Entity)
    def get_entity_list(self):
        return self.entity_list

    def set_entity_list(self, entity_list):
        self.entity_list = entity_list

    @optapy.problem_fact_collection_property(Value)
    @optapy.value_range_provider("value_range")
    def get_value_list(self):
        return self.value_list

    def set_value_list(self, value_list):
        self.value_list = value_list

    @optapy.planning_score(optapy.score.SimpleScore)
    def get_score(self):
        return self.score

    def set_score(self, score):
        self.score = score


@optapy.constraint_provider
def define_constraints(constraint_factory: optapy.constraint.ConstraintFactory):
    return [
        constraint_factory.for_each(Entity)
        .group_by(optapy.constraint.ConstraintCollectors.min(lambda entity: entity.value.number))
        .reward("Min value", optapy.score.SimpleScore.ONE, lambda min_value: min_value)
    ]


if __name__ == "__main__":
    entity_a: Entity = Entity("A")
    entity_b: Entity = Entity("B")

    value_1 = Value(1)
    value_2 = Value(2)
    entity_a.set_value(value_1)
    entity_b.set_value(value_1)

    problem = Solution([entity_a, entity_b], [value_1, value_2])

    solver_config = optapy.config.solver.SolverConfig()
    solver_config \
        .withEntityClasses(Entity) \
        .withSolutionClass(Solution) \
        .withConstraintProviderClass(define_constraints) \
        .withTerminationSpentLimit(optapy.types.Duration.ofSeconds(3))

    with optapy.solver_manager_create(solver_config) as solver_manager:
        solver_job = solver_manager.solve(1, problem)
        while solver_job.getSolverStatus() != SolverStatus.NOT_SOLVING:
            time.sleep(1)

    solution: Solution = solver_job.getFinalBestSolution()

    # uncomment to make this work
    # solution = Solution(entity_list=solution.entity_list, value_list=solution.value_list, score=solution.score)

    score_manager = optapy.score_manager_create(optapy.solver_factory_create(solver_config))
    explanation = score_manager.explainScore(solution)
    print("explanation", explanation)

The output I get is the following:

21:42:42.143 [l-1-thread-1] INFO  Solving started: time spent (66), best score (1), environment mode (REPRODUCIBLE), move thread count (NONE), random (JDK with seed 0).
21:42:42.156 [l-1-thread-1] INFO  Construction Heuristic phase (0) ended: time spent (80), best score (1), score calculation speed (111/sec), step total (0).
21:42:45.078 [l-1-thread-1] INFO  Local Search phase (1) ended: time spent (3002), best score (2), score calculation speed (242072/sec), step total (414).
21:42:45.079 [l-1-thread-1] INFO  Solving ended: time spent (3002), best score (2), score calculation speed (235057/sec), phase total (2), environment mode (REPRODUCIBLE), move thread count (NONE).
Traceback (most recent call last):
  File "/home/sandro/dev/hourclass/neo/issue.py", line 101, in <module>
    explanation = score_manager.explainScore(solution)
  File "/home/sandro/.cache/pypoetry/virtualenvs/neo-jZqp7wPN-py3.10/lib/python3.10/site-packages/optapy/optaplanner_api_wrappers.py", line 199, in explainScore
    return self._wrap_call(lambda wrapped_solution: self._java_explainScore(wrapped_solution), solution)
  File "/home/sandro/.cache/pypoetry/virtualenvs/neo-jZqp7wPN-py3.10/lib/python3.10/site-packages/optapy/optaplanner_api_wrappers.py", line 169, in _wrap_call
    wrapped_problem = PythonSolver.wrapProblem(get_class(type(problem)), problem)
TypeError: No matching overloads found for *static* org.optaplanner.optapy.PythonSolver.wrapProblem(_jpype._JClass,org.jpyinterpreter.user.Solution), options are:
        public static java.lang.Object org.optaplanner.optapy.PythonSolver.wrapProblem(java.lang.Class,org.optaplanner.jpyinterpreter.types.wrappers.OpaquePythonReference)

This can be fixed by manually creating a Solution (uncomment line in bottom of the snippet) entity and using this for the explanation. Is this intentional or can/should I do something different?

I'm on

  • Python 3.10.9
  • openjdk 17.0.5 2022-10-18
  • Debian with a kernel version of 6.1.4-1

Thanks for the help 🙌

Sounds like a bug. From the stack trace, I am guessing what happening is ScoreManager.explainScore is rewrapping an already wrapped problem.

Wrapping is the process of converting a CPython object to a Java compatible object. This involves passing a reference of the CPython object to its corresponding Java class constructor. It appears SolverJob.getFinalBestSolution is returning the wrapped Java object, instead of the unwrapped CPython object. Thanks to JPype magic, you can interact with the wrapped Java object just fine in CPython, but Type issues will arise if you pass that object to code that expects a CPython reference.

Looking at the code, we return a normal Java SolverJob and not a Python wrapper around SolverJob. So the fix would be:

  1. Create a SolverJob Python wrapper, which unwraps the Java object (like Solver):
    return _unwrap_java_object(self._java_solve(wrapped_problem))
  2. In _PythonSolverManager (
    @JOverride
    def solve(self, problem_id: ProblemId_, problem: Union[Solution_, Callable[[ProblemId_], Solution_]],
    final_best_solution_consumer: Callable[[Solution_], None] = None,
    exception_handler: Callable[[ProblemId_, JException], None] = None) -> \
    '_SolverJob[Solution_, ProblemId_]':
    problem_getter, cleanup = self._get_problem_getter_and_cleanup(problem_id, problem)
    wrapped_final_best_solution_consumer, wrapped_exception_handler = \
    self._wrap_final_best_solution_and_exception_handler(cleanup, final_best_solution_consumer,
    exception_handler)
    solver_job = self.delegate.solve(problem_id, problem_getter, wrapped_final_best_solution_consumer,
    wrapped_exception_handler)
    create_python_thread_for_solver_job(solver_job, problem_id,
    exception_handler if exception_handler is not None else lambda _1, _2: None)
    return solver_job
    @JOverride
    def solveAndListen(self, problem_id: ProblemId_, problem: Union[Solution_, Callable[[ProblemId_], Solution_]],
    best_solution_consumer: Callable[[Solution_], None],
    final_best_solution_consumer: Callable[[Solution_], None] = None,
    exception_handler: Callable[[ProblemId_, JException], None] = None) -> \
    '_SolverJob[Solution_, ProblemId_]':
    problem_getter, cleanup = self._get_problem_getter_and_cleanup(problem_id, problem)
    wrapped_final_best_solution_consumer, wrapped_exception_handler = \
    self._wrap_final_best_solution_and_exception_handler(cleanup, final_best_solution_consumer,
    exception_handler)
    def wrapped_best_solution_consumer(best_solution):
    best_solution_consumer(_unwrap_java_object(best_solution))
    solver_job = self.delegate.solveAndListen(problem_id, problem_getter, wrapped_best_solution_consumer,
    wrapped_final_best_solution_consumer,
    wrapped_exception_handler)
    create_python_thread_for_solver_job(solver_job, problem_id,
    exception_handler if exception_handler is not None else lambda _1, _2: None)
    return solver_job
    ), return the wrapped SolverJob instead of the Java SolverJob

I see, thanks for the explanation, yes indeed it seems that the wrapping seems to be the problem.

I can bypass it for now with only minor impact on code and performance so it's not a big issue right now.

Hey,

What is the solution ? How did you bypass it ?

Use _unwrap_java_object on the solution ?

Thx by advance !

check the commented out code at the bottom of the snippet in the issue description