From c31a45c2e0f5af3c0b8c785cdebdaf2a0fffa29b Mon Sep 17 00:00:00 2001 From: Ricardo Leal <61102024+ricardoleal20@users.noreply.github.com> Date: Tue, 16 Jul 2024 23:11:34 -0600 Subject: [PATCH] Include several tests and minor fixes (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿงช Tests: Update the tests * ๐Ÿงช Tests: Add tests for the OptSolver * โšฐ๏ธ Remove: Delete old and useless files * ๐Ÿฉน Patch: Add a simple addition. This allow us to add a initial value for the Variables * ๐Ÿงช Tests: Add a fix to the `value` method, to now set the closes value limit when we're trying to update the value * ๐Ÿ“ Docs: Add documentation for the gradient method * โœ๏ธ Typo: Fix minor typos * ๐Ÿ› Bug: Fix a minor bug in the libraries for the engine * ๐Ÿงช Test: Add tests for the gradient descent method * ๐Ÿ”– Tag: Bump version `0.3.1` * โฌ†๏ธ Dependencies: Upgrade the lock for Cargo and poetry * ๐Ÿ‘ท CI: Add different CI to evaluate the code * โœ… Test: Evaluate the CI * ๐Ÿšš Rename: Change the name to `action.yml` * ๐Ÿ‘ท CI: Add tests for the CI * โž• Dependencies: Add pylint as dev dependency * ๐Ÿ‘ท CI: Update the CI to install the python version `3.10` * ๐Ÿ’š CI: Upgrade the general CI * ๐Ÿ’š CI: Delete an useless command in th CI * ๐Ÿ‘ท CI: Add a command for the tests * ๐Ÿ‘ท CI: Add the `--release` flag in the Rust CI * โšก๏ธ Improve: Improve the Rust code with minor changes * ๐Ÿ‘ท CI: Add final line to the CI * ๐Ÿ‘ท CI: Add an etra layer of evaluation in Rust CI --- .github/actions/python_ci/action.yml | 27 ++++ .github/actions/rust_ci/action.yml | 27 ++++ .github/actions/tests_ci/action.yml | 29 +++++ .github/workflows/ci.yaml | 31 +++++ CHANGELOG.md | 14 ++ Cargo.lock | 4 +- Cargo.toml | 4 +- poetry.lock | 84 ++++++++++-- .../engine/optimization_methods.pyi | 18 ++- pymath_compute/methods/opt_methods.py | 23 +++- pymath_compute/model/variable.py | 37 ++++-- pymath_compute/solvers/opt_solver.py | 22 +++- pyproject.toml | 16 ++- src/lib.rs | 1 - src/math_models.rs | 44 ------- src/methods/training.rs | 33 +++-- tests/conftest.py | 7 +- .../engine/test_training_methods.py | 121 ------------------ .../opt_methods/test_gradient_descent.py | 65 ++++++++++ tests/pymath_compute/model/test_function.py | 10 +- tests/pymath_compute/model/test_variable.py | 32 ++++- .../pymath_compute/solver/test_opt_solver.py | 102 +++++++++++++++ 22 files changed, 530 insertions(+), 221 deletions(-) create mode 100644 .github/actions/python_ci/action.yml create mode 100644 .github/actions/rust_ci/action.yml create mode 100644 .github/actions/tests_ci/action.yml create mode 100644 .github/workflows/ci.yaml delete mode 100644 src/math_models.rs delete mode 100644 tests/pymath_compute/engine/test_training_methods.py create mode 100644 tests/pymath_compute/methods/opt_methods/test_gradient_descent.py create mode 100644 tests/pymath_compute/solver/test_opt_solver.py diff --git a/.github/actions/python_ci/action.yml b/.github/actions/python_ci/action.yml new file mode 100644 index 0000000..758d83c --- /dev/null +++ b/.github/actions/python_ci/action.yml @@ -0,0 +1,27 @@ +name: Python CI ๐Ÿ +description: CI that evaluates the Python linter + +# ----------------------------------- # +# DEFINE THE STEPS # +# ----------------------------------- # + +runs: + using: "composite" + steps: + - name: Checkout code ๐Ÿ” + uses: actions/checkout@v2 + + - name: Set up Python ๐Ÿ + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install dependencies ๐Ÿ—๏ธ + shell: bash + run: | + pip install poetry + poetry install + + - name: Evaluate the linter โ˜ข๏ธ + shell: bash + run: poetry run pylint pymath_compute/ --rcfile pyproject.toml diff --git a/.github/actions/rust_ci/action.yml b/.github/actions/rust_ci/action.yml new file mode 100644 index 0000000..9bdb028 --- /dev/null +++ b/.github/actions/rust_ci/action.yml @@ -0,0 +1,27 @@ +name: Rust CI ๐Ÿฆ€ +description: Action that check the Rust code using the built-in cargo check + +# ----------------------------------- # +# DEFINE THE STEPS # +# ----------------------------------- # + + +runs: + using: "composite" + steps: + - name: Checkout code ๐Ÿ” + uses: actions/checkout@v2 + + - name: Set up Rust ๐Ÿฆ€ + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + + - name: Check Rust code ๐Ÿฉบ + shell: bash + run: cargo check --release + + - name: Build Rust code ๐Ÿงฑ + shell: bash + run: cargo build --release diff --git a/.github/actions/tests_ci/action.yml b/.github/actions/tests_ci/action.yml new file mode 100644 index 0000000..6aa82d4 --- /dev/null +++ b/.github/actions/tests_ci/action.yml @@ -0,0 +1,29 @@ +name: Tests CI ๐Ÿงช +description: CI Action to perform the tests execution + +# ----------------------------------- # +# DEFINE THE STEPS # +# ----------------------------------- # + + +runs: + using: "composite" + steps: + - name: Checkout code ๐Ÿ” + uses: actions/checkout@v2 + + - name: Set up Python ๐Ÿ + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install dependencies ๐Ÿ—๏ธ + shell: bash + run: | + pip install poetry + poetry install + poetry run maturin develop --release + + - name: Run tests ๐Ÿงช + shell: bash + run: poetry run pytest diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..e7960bc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: Continuous Integration Workflow ๐Ÿ‘พ + +# Controls when the action will run. We only want that this action to happens when +# we open a pull request that points to main +on: + pull_request: + branches: + - "main" + +# ----------------------------------- # +# DEFINE THE JOBS # +# ----------------------------------- # + +jobs: + rust_ci: + runs-on: ubuntu-latest + name: Run Rust CI ๐Ÿฆ€ + steps: + - uses: ricardoleal20/pymath_compute/.github/actions/rust_ci@ricardo/AddSolverTests + + python_ci: + runs-on: ubuntu-latest + name: Run Python CI ๐Ÿ + steps: + - uses: ricardoleal20/pymath_compute/.github/actions/python_ci@ricardo/AddSolverTests + + tests_ci: + runs-on: ubuntu-latest + name: Run tests ๐Ÿงช + steps: + - uses: ricardoleal20/pymath_compute/.github/actions/tests_ci@ricardo/AddSolverTests diff --git a/CHANGELOG.md b/CHANGELOG.md index c92ecab..38ef5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.3.1] - 16/07/2024 + +### Added + +- [Classifiers]: Add the classifiers for the project, including homepages and more stuff +- [Maturin]: Add information for the correct Rust/Python project be implemented +- [Tests]: Include the Engine and the solver tests +- [Variable]: Now, you can directly include the initial value of the variable in their definition +- [Variable]: Now, in the setter, the value set is going to be the closes bound if the expected value is outside the bound + +### Fixed + +- [Engine]: The engine now it updates the value using the setting method `value`, instead of going directly for the `_value`. + ## [0.3.0] - 15/07/2024 ### Added diff --git a/Cargo.lock b/Cargo.lock index 07fd104..7e34128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,8 +92,8 @@ dependencies = [ ] [[package]] -name = "pymath_compute_engine" -version = "0.1.0" +name = "pymath_compute" +version = "0.3.1" dependencies = [ "pyo3", ] diff --git a/Cargo.toml b/Cargo.toml index 8eb3e66..53245c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "pymath_compute_engine" -version = "0.1.0" +name = "pymath_compute" +version = "0.3.1" edition = "2021" [dependencies] diff --git a/poetry.lock b/poetry.lock index 7efc703..fcdc755 100644 --- a/poetry.lock +++ b/poetry.lock @@ -52,6 +52,20 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "astroid" +version = "3.2.3" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "astroid-3.2.3-py3-none-any.whl", hash = "sha256:3eae9ea67c11c858cdd2c91337d2e816bd019ac897ca07d7b346ac10105fceb3"}, + {file = "astroid-3.2.3.tar.gz", hash = "sha256:7099b5a60985529d8d46858befa103b82d0d05a5a5e8b816b5303ed96075e1d9"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} + [[package]] name = "async-timeout" version = "4.0.3" @@ -850,6 +864,20 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1286,6 +1314,17 @@ tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} patchelf = ["patchelf"] zig = ["ziglang (>=0.10.0,<0.13.0)"] +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1743,6 +1782,35 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pylint" +version = "3.2.5" +description = "python code static checker" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "pylint-3.2.5-py3-none-any.whl", hash = "sha256:32cd6c042b5004b8e857d727708720c54a676d1e22917cf1a2df9b4d4868abd6"}, + {file = "pylint-3.2.5.tar.gz", hash = "sha256:e9b7171e242dcc6ebd0aaa7540481d1a72860748a0a7816b8fe6cf6c80a6fe7e"}, +] + +[package.dependencies] +astroid = ">=3.2.2,<=3.3.0-dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = [ + {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, +] +isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomlkit = ">=0.10.1" + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + [[package]] name = "pyparsing" version = "3.1.2" @@ -1945,13 +2013,13 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "reflex" -version = "0.5.6" +version = "0.5.7" description = "Web apps in pure Python." optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "reflex-0.5.6-py3-none-any.whl", hash = "sha256:49eba29555826588342e4eec6ceee3f6233b0ee109f3ca610502e997095103bd"}, - {file = "reflex-0.5.6.tar.gz", hash = "sha256:a6b73ffa05de8429bfe57b41c9a2319aa9914de652636566323d4c22ffd7540d"}, + {file = "reflex-0.5.7-py3-none-any.whl", hash = "sha256:664053a13b416df2cdffbe654cb74d5fb4d9dd258db60d8a8ee0f92a1cabfb9a"}, + {file = "reflex-0.5.7.tar.gz", hash = "sha256:6c2ed244c053663324ce8565dba1a92e9e73581a38b795234c9e55f2ad08a61a"}, ] [package.dependencies] @@ -1986,8 +2054,8 @@ watchdog = ">=2.3.1,<5.0" watchfiles = ">=0.19.0,<1.0" wheel = ">=0.42.0,<1.0" wrapt = [ - {version = ">=1.14.0,<2.0", markers = "python_version >= \"3.11\""}, {version = ">=1.11.0,<2.0", markers = "python_version < \"3.11\""}, + {version = ">=1.14.0,<2.0", markers = "python_version >= \"3.11\""}, ] [[package]] @@ -2291,13 +2359,13 @@ sqlcipher = ["sqlcipher3_binary"] [[package]] name = "sqlmodel" -version = "0.0.19" +version = "0.0.20" description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." optional = false python-versions = ">=3.7" files = [ - {file = "sqlmodel-0.0.19-py3-none-any.whl", hash = "sha256:6c8125d4101970d031e9aae970b20cbeaf44149989f8366d939f4ab21aab8763"}, - {file = "sqlmodel-0.0.19.tar.gz", hash = "sha256:95449b0b48a40a3eecf0a629fa5735b9dfc8a5574a91090d24ca17f02246ad96"}, + {file = "sqlmodel-0.0.20-py3-none-any.whl", hash = "sha256:744756c49e24095808984754cc4d3a32c2d8361fef803c4914fadcb912239bc9"}, + {file = "sqlmodel-0.0.20.tar.gz", hash = "sha256:94dd1f63e4ceb0ab405e304e1ad3e8b8c8800b47c3ca5f68736807be8e5b9314"}, ] [package.dependencies] @@ -2803,4 +2871,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.10, <3.13" -content-hash = "2a719c44b8a49451be669810deb742b18681f553dbd0b412dae112efdaf8c70d" +content-hash = "f0b645030f14f379cee161d9fe8509c111831d5c4ebb5ba6d422ca5cd4841177" diff --git a/pymath_compute/engine/optimization_methods.pyi b/pymath_compute/engine/optimization_methods.pyi index 175b7b3..f5aa09f 100644 --- a/pymath_compute/engine/optimization_methods.pyi +++ b/pymath_compute/engine/optimization_methods.pyi @@ -23,4 +23,20 @@ def gradient_descent( iterations: int, tol: float, ) -> STATUS: - """...""" + """Gradient Descent implementation + + This is a normal gradient descent implementation for a Python code. + The values of the variables are updated in each iteration of the + method, only if the old cost is better than the new cost. + + Args: + - variables (list[Variable]): Variables given by the PyMath Module + - cost_method (Callable): Method to calculate the cost. + - var_step (float): Finite step to calculate the gradient + - learning_rate (float): The learning rate for the variables + - iterations (int): How many iterations are you going to run as max + - tol (float): The tolerance to know if you got an optimal + + Returns: + - The status of the method + """ diff --git a/pymath_compute/methods/opt_methods.py b/pymath_compute/methods/opt_methods.py index b3a9a16..639e56e 100644 --- a/pymath_compute/methods/opt_methods.py +++ b/pymath_compute/methods/opt_methods.py @@ -20,7 +20,28 @@ async def _gradient_descent( # pylint: disable=R0913 iterations: int = 1000, tol: float = 1e-6, ) -> STATUS: - """...""" + """Gradient Descent implementation + + This is a normal gradient descent implementation for a Python code. + The values of the variables are updated in each iteration of the + method, only if the old cost is better than the new cost. + + Args: + - variables (list[Variable]): Variables given by the PyMath Module + - cost_method (Callable): Method to calculate the cost. + - var_step (float): Finite step to calculate the gradient + - learning_rate (float): The learning rate for the variables + - iterations (int): How many iterations are you going to run as max + - tol (float): The tolerance to know if you got an optimal + + Returns: + - The status of the method + """ + if len(variables) < 2: + raise RuntimeError( + "This gradient method only works for more than 1 variable." + + "Try making your solution space more finite." + ) # Just call the gradient descent method from the engine return gradient_descent( variables, diff --git a/pymath_compute/model/variable.py b/pymath_compute/model/variable.py index 7bb4246..06918ad 100644 --- a/pymath_compute/model/variable.py +++ b/pymath_compute/model/variable.py @@ -20,6 +20,7 @@ class Variable: Default to -infinite ub (int | float): The upper bound of the variable's range. Default to infinite + v0 (Optional:(int | float)): The initial value of the variable """ _name: str lower_bound: float @@ -32,7 +33,8 @@ def __init__( self, name: str, lb: Bound = float("-inf"), - ub: Bound = float("inf") + ub: Bound = float("inf"), + v0: Optional[Bound] = None, ) -> None: # Evaluate that the parameters are correct if not isinstance(name, str): @@ -47,11 +49,19 @@ def __init__( if lb > ub: raise ValueError("The lower bound should be lower than the upper bound" + f" but we have LB={lb} > UP={ub}.") + if v0 is not None: + if not lb <= v0 <= ub: + raise ValueError(f"The initial value {v0} is not" + + " in the right bounds. It should be " + + f"LB={lb} <= V0={v0} <= UP={ub}.") + value = v0 + else: + value = lb if lb != float("-inf") else 0.0 # If everything is okay, set the values self._name = name self.lower_bound = lb self.upper_bound = ub - self._value = None + self._value = value @property def name(self) -> str: @@ -73,25 +83,26 @@ def value(self) -> float: return self._value if self._value else 0.0 @value.setter - def value(self, new_value: float) -> None: + def value(self, new_value: float) -> int | float: """Set a new value for this variable: Args: - new_value (float): New value to set - Raises: - ValueError: If the value set is not in the defined - [lower_bound, upper_bound] range. + Note: + If the value set is not in the defined + [lower_bound, upper_bound] range, it would + take the closes bound as the value. """ # Evaluate if the value is inside the range - if self.lower_bound <= new_value <= self.upper_bound: + if self.lower_bound >= new_value: + self._value = self.lower_bound + elif self.upper_bound <= new_value: + self._value = self.upper_bound + else: + # Set the value self._value = new_value - return - # If not, raise an error - raise ValueError( - f"The new expected value {new_value} is outside the range of" + - f" [{self.lower_bound}, {self.upper_bound}]." - ) + return self._value def to_expression(self) -> 'MathExpression': """Convert this Variable into a MathExpression""" diff --git a/pymath_compute/solvers/opt_solver.py b/pymath_compute/solvers/opt_solver.py index 124930d..bf1175b 100644 --- a/pymath_compute/solvers/opt_solver.py +++ b/pymath_compute/solvers/opt_solver.py @@ -51,7 +51,7 @@ class OptSolver: """ _vars: list[Variable] _config: OptSolverConfig - _objective: Callable[[list[Variable]], int | float] + _objective: Callable[[dict[str, int | float]], int | float] _results: list[Variable] _status: STATUS # Define the slots @@ -71,7 +71,6 @@ def __init__(self) -> None: "solver_time": 30 } self._objective = None # type: ignore - self._results = [] def set_variables(self, variables: list[Variable] | Variable) -> None: """Set Variables to be considered in the algorithm @@ -110,6 +109,11 @@ def set_solver_config(self, solver_config: OptSolverConfig) -> None: Args: - solver_config (OptSolverConfig): Include the configuration of the solver. """ + if not isinstance(solver_config, dict): + raise TypeError("The configuration it should be a dictionary.") + if not "solver_method" in solver_config: + raise TypeError( + "The solver configuration should incluye the Solver Method") if not isinstance(solver_config["solver_method"], OptMethods): raise TypeError( "The solver_config` is not an enum of type `OptMethods`. " + @@ -117,19 +121,25 @@ def set_solver_config(self, solver_config: OptSolverConfig) -> None: ) self._config.update(solver_config) - def set_objective_function(self, function: Callable[[list[Variable]], float]) -> None: + def set_objective_function( + self, + function: Callable[[dict[str, int | float]], int | float] + ) -> None: """Set the objective function. This objective function should be of the form: ``` - def obj_func(vars: list[Variable]) -> int | float: + def obj_func(vars: dict[str, int | float]) -> int | float: ... ``` + where the dictionary is going to be of type {var_name: var_value} Args: - - function (Callable[[list[Variable]], int | float]): Function to calculate + - function (Callable[[dict[str, int | float]]], int | float]): Function to calculate the objective functions to minimize. """ + if not callable(function): + raise TypeError("The Objective Function is not a callable.") self._objective = function def solve(self) -> None: @@ -161,7 +171,7 @@ def vars_results(self) -> list[Variable]: Returns: - Return the solution from the optimization """ - return self._results + return self._vars @property def status(self) -> STATUS: diff --git a/pyproject.toml b/pyproject.toml index 12dc92f..36747da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,18 @@ [tool.poetry] name = "pymath_compute" -version = "0.3.0" +version = "0.3.1" description = "Tool to handle mathematical operations using Variables and Mathematical Expressions." authors = ["ricardoleal20 "] +homepage = "https://pymath.ricardoleal20.dev" +documentation = "https://pymath.ricardoleal20.dev/docs/" +repository = "https://github.com/ricardoleal20/pymath_compute" license = "MIT" readme = "README.md" +keywords = ["scientific_computing", "applied_math", "optimization"] +classifiers = [ + "Topic :: Scientific Development :: Mathematical Optimization", + "Topic :: Scientific Development :: Applied Mathematics", +] [tool.poetry.dependencies] python = ">=3.10, <3.13" @@ -15,6 +23,7 @@ matplotlib = ">=3.9.1" [tool.poetry.dev-dependencies] +pylint = "~=3.2.5" pytest = "~=8.2.2" pytest-cov = "~=5.0" pytest-xdist = "~=3.6.1" @@ -27,3 +36,8 @@ build-backend = "maturin" [tool.maturin] bindings = "pyo3" module-name = "pymath_compute.engine" + +[tool.pylint] +ignore-paths = ["pymath_compute/engine/*"] +disable = ["E0401", "E0611"] +fail-under = 9.8 diff --git a/src/lib.rs b/src/lib.rs index 783a1bf..b4e3b05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,6 @@ //! Library export for Python modules //! // Import the methods module here -mod math_models; mod math_utilities; mod methods; // Import methods diff --git a/src/math_models.rs b/src/math_models.rs deleted file mode 100644 index 00dfcb3..0000000 --- a/src/math_models.rs +++ /dev/null @@ -1,44 +0,0 @@ -/// PYTHON MODELS -/// -/// Define some references for the python models such as: -/// - Variable -/// - MathFunction -/// - MathExpression -/// -/// This allow us to interact with them in Rust, giving us the change -/// to get their values, set new values and things similar to that -use pyo3::prelude::*; - -// ==================================== // -// VARIABLE // -// ==================================== // -/// Rust interpreter for the Variable class of the -/// Python module of PyMath Compute -#[pyclass] -pub struct Variable { - pub name: String, - value: f64, -} - -#[pymethods] -impl Variable { - #[new] - fn new(name: String, value: f64) -> Self { - Variable { name, value } - } - - #[getter] - pub fn get_name(&self) -> &str { - &self.name - } - - #[getter] - pub fn get_value(&self) -> f64 { - self.value - } - - #[setter] - pub fn set_value(&mut self, value: f64) { - self.value = value; - } -} diff --git a/src/methods/training.rs b/src/methods/training.rs index 6a52ca9..2af1058 100644 --- a/src/methods/training.rs +++ b/src/methods/training.rs @@ -60,7 +60,7 @@ pub fn gradient_descent( for (var_name, var_value) in var_values.items().extract::>().unwrap() { var_values.set_item::<&str, f64>( &var_name, - var_value + learning_rate * gradient[var_index], + var_value - learning_rate * gradient[var_index], )?; // Add one value to the var index if var_index < gradient.len() - 1 { @@ -77,17 +77,30 @@ pub fn gradient_descent( if (best_cost - cost).abs() < tol { status = "OPTIMAL"; break; - } else { - if cost < best_cost { - best_cost = cost; - for variable in &variables { - let name: String = variable.getattr("name")?.extract()?; - let new_value: f64 = var_values.get_item(name).unwrap().extract()?; - let _ = variable.setattr("_value", new_value); + } else if cost < best_cost { + let mut recalculate_cost: bool = false; + for variable in &variables { + let name: &str = variable.getattr("name")?.extract()?; + let new_value: f64 = var_values.get_item(name).unwrap().extract()?; + // Use the setter method to set the new value + variable.setattr("value", new_value)?; + // Get the value + let var_value: f64 = variable.getattr("value")?.extract()?; + if var_value != new_value { + var_values + .set_item::<&str, f64>(name, variable.getattr("value")?.extract()?)?; + // Ask to recalculate the cost + recalculate_cost = true } - // Change the status to FEASIBLE - status = "FEASIBLE"; } + if recalculate_cost { + // Recalculate the cost, in case that we have changed the variables + best_cost = cost_method.call1(py, (var_values,))?.extract(py)?; + } else { + best_cost = cost; + } + // Change the status to FEASIBLE + status = "FEASIBLE"; } // Add one iteration at the end iter_exec += 1; diff --git a/tests/conftest.py b/tests/conftest.py index b51b15b..8cd0628 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,8 +7,11 @@ test_marks = [ "variable", "function", - "expression" - "engine.methods" + "expression", + # Solver tests + "opt_solver", + # Engine tests marks + "opt_methods" ] diff --git a/tests/pymath_compute/engine/test_training_methods.py b/tests/pymath_compute/engine/test_training_methods.py deleted file mode 100644 index 3bbe0f3..0000000 --- a/tests/pymath_compute/engine/test_training_methods.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Implement tests for the solver engine. - -In this case, we're going to solve the following problem: - -``` - f(x) = e^{-x^2}; [-1, 1] -``` -We'll calculate the minimum area of the curve in this function. - -For the Area, we have: - -``` - A = 2*pi*int( f(x) * sqrt(1 + (f'(x))^2 ) dx ) -``` -""" -from typing import Callable -import math -from functools import partial -import random -# External imports -import numpy as np -# Local imports -from pymath_compute.methods import Methods -from pymath_compute.solver import Solver -from pymath_compute import Variable, MathFunction, MathExpression - - -def _integrate( - variable: Variable, - math_expression: MathExpression -) -> float: - """Integrate an expression using the MonteCarlo method""" - # Discretize the space of the variable - num_of_iterations: int = 100000 - # get the integral values - # Get the limits from the variable - lb = variable.lower_bound - ub = variable.upper_bound - # Get the random values - random_int_value: float = 0.0 - for _ in range(num_of_iterations): - # Evaluate the expression using a random value - # for x - random_int_value += math_expression.evaluate({ - variable.name: random.uniform(lb, ub) - }) - # At the end, return the value - return ((ub - lb)) / num_of_iterations * random_int_value - - -def cost( - variables: list[Variable], - expr: MathExpression -) -> int | float: - """Cost function. - This cost function is the - """ - # For this, we'll divide the cost into three sections - # SECTION 1 (section_one) - # |-- 2 * pi - section_one = 2*math.pi - # SECTION 2 (section_two) - # |-- integral ( expr * sqrt(1 + (derivate of expr)^2 ) dx ) - section_two = _integrate( - variables[0], - expr * MathFunction( - math.sqrt, - (1 + (2 * expr * variables[0]) ** 2) - ).to_expression() - ) - # section_two = expr * MathFunction( - # math.sqrt, 1+4 * variables[0] ** 2 * - # MathFunction(math.exp, -2 * variables[0] ** 2).to_expression() - # ).to_expression() - # At the end, we'll use to calculate the area - # A = section_one * section_two - return section_one * section_two - - -def init_solver( - variables: list[Variable], - cost_method: Callable[[list[Variable]], int | float] -) -> Solver: - """Initialize the OPT Solver - to use in this tests - """ - solver = Solver() - solver.set_variables(variables=variables) - solver.set_objective_function(cost_method) - # Set the parameters - solver.set_solver_config({ - "solver_time": 30, - "solver_method": Methods.GRADIENT_DESCENT - }) - # Return the solver at the end - return solver - - -def test_run_problem() -> None: - """Test the problem spotted here beyond.""" - x = Variable("x", lb=-1, ub=1) - # Define the expression - expr = MathFunction(math.exp, (-x) ** 2).to_expression() - # With this, define the cost method - cost_method = partial( - cost, - integral=_integrate( - x, - expr * MathFunction(math.sqrt, - (1 + (2 * expr * x) ** 2)).to_expression() - ) - ) - # Init the solver - solver = init_solver([x], cost_method) - # Run the solver - solver.solve() - # Get the results - results = solver.vars_results() - for result in results: - print(result.value) diff --git a/tests/pymath_compute/methods/opt_methods/test_gradient_descent.py b/tests/pymath_compute/methods/opt_methods/test_gradient_descent.py new file mode 100644 index 0000000..6093f43 --- /dev/null +++ b/tests/pymath_compute/methods/opt_methods/test_gradient_descent.py @@ -0,0 +1,65 @@ +""" +Test the gradient descent method +""" +import pytest +# Local imports +from pymath_compute.solvers.opt_solver import OptSolver +from pymath_compute.model.variable import Variable +from pymath_compute.methods import OptMethods + + +def obj_func(variables: dict[str, float]) -> int | float: + """Objective function that sums all the variables of the vars""" + return sum(v for v in variables.values()) + + +def create_solver( + variables: list[Variable], +) -> OptSolver: + """Create an instance of the solver""" + # Instance the solver + solver = OptSolver() + # Set the variables + solver.set_variables(variables) + solver.set_objective_function(obj_func) + solver.set_solver_config({ + "solver_time": 10, + "solver_method": OptMethods.GRADIENT_DESCENT + }) + return solver + + +@pytest.mark.opt_methods +def test_just_one_variable() -> None: + """Using the Gradient Descent, obtain the minimization of + the sum of one variable + """ + variables = [ + Variable(name="x", lb=1, v0=10) + ] + # Create the solver + solver = create_solver(variables) + # Run the solution. This should give a RunTime error + # since we cannot calculate the gradient of just one variable + solver.solve() + # Then, the status should be UNFEASIBLE + assert solver.status == "UNFEASIBLE" + + +@pytest.mark.opt_methods +def test_just_two_variable() -> None: + """Using the Gradient Descent, obtain the minimization of + the sum of two variables + """ + variables = [ + Variable(name="x", lb=1, v0=10), + Variable(name="y", lb=1, v0=15) + ] + # Create the solver + solver = create_solver(variables) + # Run the solution + solver.solve() + # Get the results + result = solver.vars_results() + # Ensure that the value of the sum of variables values is 2, for both + assert sum(v.value for v in result) == 2 diff --git a/tests/pymath_compute/model/test_function.py b/tests/pymath_compute/model/test_function.py index 7443667..5b6b043 100644 --- a/tests/pymath_compute/model/test_function.py +++ b/tests/pymath_compute/model/test_function.py @@ -9,7 +9,7 @@ from pymath_compute.model.expression import MathExpression # Generate a global math function -x = Variable(name="x", lower_bound=0, upper_bound=10) +x = Variable(name="x", lb=0, ub=10) func_to_test = MathFunction(np.sin, x) @@ -20,7 +20,7 @@ def test_create_math_function(): This test checks that a MathFunction instance is created correctly with the expected function and variable. """ - variable = Variable(name="x", lower_bound=0, upper_bound=10) + variable = Variable(name="x", lb=0, ub=10) math_func = MathFunction(np.sin, variable) assert math_func.function == np.sin assert math_func.variable == variable @@ -47,7 +47,7 @@ def test_repr_math_function(): of the MathFunction instance. """ math_func = func_to_test - expected_repr = "sin(x)" + expected_repr = "sin(x: 0)" assert repr(math_func) == expected_repr @@ -59,7 +59,7 @@ def test_add_method_math_function(): a new MathExpression when adding another MathFunction. """ math_func1 = func_to_test - variable = Variable(name="y", lower_bound=-5, upper_bound=5) + variable = Variable(name="y", lb=-5, ub=5) math_func2 = MathFunction(np.cos, variable) new_expr = math_func1 + math_func2 assert isinstance(new_expr, MathExpression) @@ -85,7 +85,7 @@ def test_add_method_variable(): a new MathExpression when adding a Variable instance. """ math_func = func_to_test - variable = Variable(name="y", lower_bound=-5, upper_bound=5) + variable = Variable(name="y", lb=-5, ub=5) new_expr = math_func + variable assert isinstance(new_expr, MathExpression) diff --git a/tests/pymath_compute/model/test_variable.py b/tests/pymath_compute/model/test_variable.py index bcd56c4..95873d7 100644 --- a/tests/pymath_compute/model/test_variable.py +++ b/tests/pymath_compute/model/test_variable.py @@ -7,7 +7,7 @@ # Create a variable as global -variable_to_test: Variable = Variable(name="x", lower_bound=0, upper_bound=10) +variable_to_test: Variable = Variable(name="x", lb=0, ub=10) @pytest.mark.variable @@ -35,6 +35,29 @@ def test_create_variable_without_bounds(): assert var.name == "x" assert var.lower_bound == float("-inf") assert var.upper_bound == float("inf") + # The value should be 0.0 + assert var.value == 0.0 + + +@pytest.mark.variable +def test_create_variable_with_initial_value(): + """Test the creation of a variable with a v0, being the initial value + of the var + """ + var: Variable = Variable("x", lb=10, v0=15) + # Since lb = 10, ub = inf, then it is valid that lb <= v0 <= ub + assert var.value == 15 + + +@pytest.mark.variable +def test_create_variable_with_initial_value_out_of_bounds(): + """Test the creation of a variable with a v0 that + is out of the bounds. That means, that the rule + lb <= v0 <= ub is violated + """ + # Since lb = 10, ub = 15, then it is not valid that lb <= v0 <= ub + with pytest.raises(ValueError): + _: Variable = Variable("x", lb=10, ub=15, v0=0) @pytest.mark.variable def test_set_valid_value(): @@ -53,11 +76,12 @@ def test_set_invalid_value(): """Test setting an invalid value to a Variable. This test checks that setting a value outside the defined - bounds raises a ValueError. + bounds would set the closes bound as the value """ var: Variable = variable_to_test - with pytest.raises(ValueError): - var.value = 15.0 + var.value = 15.0 + # Evaluate it and confirm that it has, indeed, the upper bound as limit + assert var.value == var.upper_bound @pytest.mark.variable diff --git a/tests/pymath_compute/solver/test_opt_solver.py b/tests/pymath_compute/solver/test_opt_solver.py new file mode 100644 index 0000000..09fdfd7 --- /dev/null +++ b/tests/pymath_compute/solver/test_opt_solver.py @@ -0,0 +1,102 @@ +""" +Tests for the Optimization Solver. + +This solver is the one that allow to the users to easily +implement different optimization methods +""" +import pytest +# Local imports +from pymath_compute.solvers.opt_solver import OptSolver +from pymath_compute.model.variable import Variable + + +@pytest.mark.opt_solver +def test_try_to_solve_without_params() -> None: + """Test the execution of the OptSolver when you're + trying to initialize it and using it without the + needed parameters, such as the variables, the objective + or the configuration + """ + solver = OptSolver() + # Try to solve it and receive the RunTime Error + with pytest.raises(RuntimeError): + solver.solve() + # Then, add the variables and try to run it without objective + solver._vars = ["TEST"] # type: ignore # pylint: disable=W0212 + with pytest.raises(RuntimeError): + solver.solve() + # Set a objective + solver._objective = lambda x: 10 * x # type: ignore # pylint: disable=W0212 + with pytest.raises(RuntimeError): + solver.solve() + + +@pytest.mark.opt_solver +def test_setting_bad_variables() -> None: + """Test the behaviour of the class when you're setting + bad variables to the optimizer + """ + solver = OptSolver() + # Try to set something that is not a Variable + # or that is not a list of variables + with pytest.raises(TypeError): + solver.set_variables(variables="Test") # type: ignore + # Do the same but using a list of Test + with pytest.raises(TypeError): + solver.set_variables(variables=["Test"]) # type: ignore + + +@pytest.mark.opt_solver +def test_bad_objective_function() -> None: + """Test that happens when you set a function that is not a callable""" + solver = OptSolver() + # Using a "Test", we'll have a type error + with pytest.raises(TypeError): + solver.set_objective_function("Test") # type: ignore + + +@pytest.mark.opt_solver +def test_bad_config() -> None: + """Test what happens if you did not put correctly the configuration + of the solver + """ + solver = OptSolver() + # First, set something that is not a dict + with pytest.raises(TypeError): + solver.set_solver_config("Test") # type: ignore + # Then, set something that doesn't have the "solver_method" + # included + with pytest.raises(TypeError): + solver.set_solver_config({"Test": "test"}) # type: ignore + # Then, add a test where you're setting a solver method + # that is not of OptMethod type + with pytest.raises(TypeError): + solver.set_solver_config({"solver_method": "test"}) # type: ignore + + +@pytest.mark.opt_solver +def test_status_not_executed() -> None: + """Test that the status of the solver is NOT_EXECUTED + if we did not run anything + """ + solver = OptSolver() + assert solver.status == "NOT_EXECUTED" + + +@pytest.mark.opt_solver +def test_set_variables() -> None: + """Test that we can set variables at different + moments of time in the solver, and that we'll + have all those variables at the end + """ + solver = OptSolver() + # First, set one variable + variable = Variable(name="V1") + solver.set_variables(variable) + # Assert and ensure that you have only one variable + assert len(solver._vars) == 1 # pylint: disable=W0212 + # Then, append two vars as a list and ensure that now + # the length is 3 + variables = [Variable(name="V2"), Variable(name="V3")] + solver.set_variables(variables) + assert len(solver._vars) == 3 # pylint: disable=W0212