optimization-dev / backend /tests /test_logic.py
nhamid289's picture
Deploy optimisation from main (6727a1a)
c964813 verified
Raw History Blame Contribute Delete
6.76 kB
import inspect
import json
from pathlib import Path
import numpy as np
import pytest
from logic import DIVERGED, OPTIMISERS, SINGULAR, Objective, Run, parse_function
from manager import MAX_ITERATIONS, Manager, UserError
SPEC = json.loads((Path(__file__).parent.parent / "src" / "algorithms.json").read_text())["algorithms"]
DEFAULTS = {spec["id"]: {p["name"]: p["default"] for p in spec["params"]} for spec in SPEC}
def run(expression: str, algorithm: str, start: list[float], **params) -> Run:
f = Objective(parse_function(expression, len(start)), len(start))
return Run(f, OPTIMISERS[algorithm](f, **(DEFAULTS[algorithm] | params)), np.array(start, dtype=float))
def test_spec_matches_optimisers():
assert [spec["id"] for spec in SPEC] == list(OPTIMISERS)
for spec in SPEC:
names = list(inspect.signature(OPTIMISERS[spec["id"]]).parameters)[1:]
assert names == [p["name"] for p in spec["params"]], spec["id"]
@pytest.mark.parametrize("expression, n, expected", [
("x^2 - 0.1cos(20x)", 1, 0.81 - 0.1 * np.cos(18)),
("2x y + e", 2, 2 * 0.9 * -0.5 + np.e),
("sqrt(abs(x)) + ln(exp(y))", 2, np.sqrt(0.9) - 0.5),
])
def test_parse_function(expression, n, expected):
f = Objective(parse_function(expression, n), n)
point = np.array([0.9, -0.5][:n])
assert f.value(point) == pytest.approx(expected)
@pytest.mark.parametrize("expression, message", [
("", "Enter a function"),
("x^2 + (", "can read"),
("x + z", "Unknown name z"),
])
def test_parse_function_errors(expression, message):
with pytest.raises(ValueError, match=message):
parse_function(expression, 2)
def test_gradient_and_hessian():
f = Objective(parse_function("x^2 y + y^3", 2), 2)
p = np.array([1.0, 2.0])
assert f.gradient(p).tolist() == [4.0, 13.0]
assert f.hessian(p).tolist() == [[4.0, 2.0], [2.0, 12.0]]
def test_gradient_descent_on_a_quadratic():
# x_k = (1 - 2η)^k x_0
r = run("x^2", "gradient_descent", [1.0], learning_rate=0.1)
assert r.point(10)["position"][0] == pytest.approx(0.8**10)
def test_momentum_overshoots_and_nesterov_damps_it():
plain = run("x^2", "gradient_descent", [1.0], learning_rate=0.1, momentum=0.9)
nesterov = run("x^2", "nesterov", [1.0], learning_rate=0.1, momentum=0.9)
assert min(plain.point(k)["position"][0] for k in range(20)) < -0.5
assert abs(nesterov.point(20)["position"][0]) < abs(plain.point(20)["position"][0])
def test_newton_solves_a_quadratic_in_one_step():
r = run("(x - 1)^2 + 3(y + 2)^2 + x y", "newton", [5.0, 5.0])
assert r.point(1)["gradient"] == pytest.approx([0, 0], abs=1e-12)
@pytest.mark.parametrize("algorithm", [spec["id"] for spec in SPEC])
def test_every_optimiser_goes_downhill_on_a_bowl(algorithm):
r = run("x^2 + 3y^2", algorithm, [0.8, -0.6])
assert r.point(50)["value"] < r.point(0)["value"]
def test_divergence_stops_the_run():
r = run("x^2", "gradient_descent", [1.0], learning_rate=1.5)
# |x| doubles each step, so it passes 1e6 after 20 steps
assert r.point(20) is None and r.point(19) is not None
assert r.stopped == DIVERGED
def test_leaving_the_domain_stops_the_run():
r = run("sqrt(x)", "gradient_descent", [0.5], learning_rate=1.0)
assert r.point(3) is None
assert r.stopped == DIVERGED
def test_singular_hessian_stops_newton():
r = run("x^3", "newton", [0.0])
assert r.point(1) is None
assert r.stopped == SINGULAR
def test_singular_hessian_stops_newton_in_pyodide(monkeypatch):
# Pyodide's solve returns inf rather than raising LinAlgError
monkeypatch.setattr(np.linalg, "solve", lambda a, b: np.full_like(b, np.inf))
r = run("x^2 + 0y", "newton", [1.0, 1.0])
assert r.point(1) is None
assert r.stopped == SINGULAR
def test_points_are_cached_and_asked_for_in_any_order():
r = run("x^2 + y^2", "adam", [1.0, 1.0])
late = r.point(30)
assert r.point(3) == r.points[3]
assert r.point(30) == late
def manager_with(expression: str, *runs: dict) -> Manager:
m = Manager()
m.handle_set_function({"variables": len(runs[0]["start"]), "expression": expression})
m.handle_set_runs({"runs": list(runs)})
return m
def test_state_past_the_last_iteration():
m = manager_with("x^2", {"algorithm": "gradient_descent", "start": [1.0], "params": {}})
assert m.handle_state({"iteration": MAX_ITERATIONS}) is not None
assert m.handle_state({"iteration": MAX_ITERATIONS + 1}) is None
def test_state_ends_when_every_run_has_stopped():
m = manager_with(
"x^3 + x^2",
{"algorithm": "gradient_descent", "start": [1.0], "params": {"learning_rate": 1.5}},
{"algorithm": "newton", "start": [-1/3], "params": {}}, # f'' = 0 here
)
states = [m.handle_state({"iteration": k}) for k in range(MAX_ITERATIONS)]
end = states.index(None)
assert 1 < end and all(s is None for s in states[end:])
# Newton stops first and says why while gradient descent carries on
assert states[1]["runs"][1] == {"point": None, "stopped": SINGULAR}
assert states[1]["runs"][0]["point"] is not None
# Each run says why at its last point
assert states[0]["runs"][1]["stopped"] == SINGULAR
assert states[end - 2]["runs"][0]["stopped"] is None
assert states[end - 1]["runs"][0]["stopped"] == DIVERGED
def test_start_point_outside_the_domain():
m = Manager()
m.handle_set_function({"variables": 1, "expression": "log(x)"})
with pytest.raises(UserError, match="no finite value at the initial point"):
m.handle_set_runs({"runs": [{"algorithm": "adam", "start": [-1.0], "params": {}}]})
def test_failed_runs_replace_the_old_ones():
m = manager_with("log(x)", {"algorithm": "adam", "start": [1.0], "params": {}})
with pytest.raises(UserError):
m.handle_set_runs({"runs": [{"algorithm": "adam", "start": [-1.0], "params": {}}]})
assert m.handle_state({"iteration": 0}) is None
def test_function_values_one_variable():
m = Manager()
m.handle_set_function({"variables": 1, "expression": "log(x)"})
values = m.handle_function_values({"x_range": [-1, 1], "y_range": [0, 1]})
assert len(values["x"]) == len(values["f"])
assert values["f"][0] is None and values["f"][-1] == 0.0
def test_function_values_two_variables():
m = Manager()
m.handle_set_function({"variables": 2, "expression": "x + 2y"})
values = m.handle_function_values({"x_range": [0, 1], "y_range": [0, 2]})
assert values["f"][-1][0] == 4.0 # rows follow y
assert set(values["arrows"]["u"]) == {1.0} and set(values["arrows"]["v"]) == {2.0}
def test_no_function_values_without_a_function():
assert Manager().handle_function_values({"x_range": [0, 1], "y_range": [0, 1]}) is None