Skip to content

Commit

Permalink
Add StandaloneProblem (#153)
Browse files Browse the repository at this point in the history
* Add StandaloneProblem

* Make separate test_standalone

* Update problem evaluate and evaluateS1

* Update CHANGELOG.md

* Increase test coverage

* Update checks for multiple signals
  • Loading branch information
NicolaCourtier authored Jan 25, 2024
1 parent a7515a7 commit 2e35380
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 18 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Features

- [#151](https://github.com/pybop-team/PyBOP/issues/151) - Adds a standalone version of the Problem class.

## Bug Fixes

- [#164](https://github.com/pybop-team/PyBOP/issues/164) - Fixes convergence issues with gradient-based optimisers, changes default `model.check_params()` to allow infeasible solutions during optimisation iterations. Adds a feasibility check on the optimal parameters.
Expand Down
File renamed without changes.
80 changes: 80 additions & 0 deletions examples/standalone/problem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import numpy as np
from pybop._problem import BaseProblem


class StandaloneProblem(BaseProblem):
"""
Defines an example standalone problem without a Model.
"""

def __init__(
self,
parameters,
dataset,
model=None,
check_model=True,
signal=None,
init_soc=None,
x0=None,
):
super().__init__(parameters, model, check_model, signal, init_soc, x0)
self._dataset = dataset.data

# Check that the dataset contains time and current
for name in ["Time [s]"] + self.signal:
if name not in self._dataset:
raise ValueError(f"expected {name} in list of dataset")

self._time_data = self._dataset["Time [s]"]
self.n_time_data = len(self._time_data)
if np.any(self._time_data < 0):
raise ValueError("Times can not be negative.")
if np.any(self._time_data[:-1] >= self._time_data[1:]):
raise ValueError("Times must be increasing.")

for signal in self.signal:
if len(self._dataset[signal]) != self.n_time_data:
raise ValueError(
f"Time data and {signal} data must be the same length."
)
target = [self._dataset[signal] for signal in self.signal]
self._target = np.vstack(target).T

def evaluate(self, x):
"""
Evaluate the model with the given parameters and return the signal.
Parameters
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
y : np.ndarray
The model output y(t) simulated with inputs x.
"""

return x[0] * self._time_data + x[1]

def evaluateS1(self, x):
"""
Evaluate the model with the given parameters and return the signal and its derivatives.
Parameters
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
tuple
A tuple containing the simulation result y(t) and the sensitivities dy/dx(t) evaluated
with given inputs x.
"""

y = x[0] * self._time_data + x[1]

dy = np.dstack([self._time_data, np.zeros(self._time_data.shape)])

return (np.asarray(y), np.asarray(dy))
27 changes: 21 additions & 6 deletions pybop/_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,13 @@ def __init__(
if np.any(self._time_data[:-1] >= self._time_data[1:]):
raise ValueError("Times must be increasing.")

for signal in self.signal:
if len(self._dataset[signal]) != self.n_time_data:
raise ValueError(
f"Time data and {signal} data must be the same length."
)
target = [self._dataset[signal] for signal in self.signal]
self._target = np.vstack(target).T
if self.n_outputs == 1:
if len(self._target) != self.n_time_data:
raise ValueError("Time data and target data must be the same length.")
else:
if self._target.shape != (self.n_time_data, self.n_outputs):
raise ValueError("Time data and target data must be the same shape.")

# Add useful parameters to model
if model is not None:
Expand All @@ -191,6 +190,11 @@ def evaluate(self, x):
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
y : np.ndarray
The model output y(t) simulated with inputs x.
"""

y = np.asarray(self._model.simulate(inputs=x, t_eval=self._time_data))
Expand All @@ -205,6 +209,12 @@ def evaluateS1(self, x):
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
tuple
A tuple containing the simulation result y(t) and the sensitivities dy/dx(t) evaluated
with given inputs x.
"""

y, dy = self._model.simulateS1(
Expand Down Expand Up @@ -273,6 +283,11 @@ def evaluate(self, x):
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
y : np.ndarray
The model output y(t) simulated with inputs x.
"""

sol = self._model.predict(
Expand Down
12 changes: 0 additions & 12 deletions tests/unit/test_optimisation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import pybop
import numpy as np
import pytest
from examples.costs.standalone import StandaloneCost


class TestOptimisation:
Expand Down Expand Up @@ -98,17 +97,6 @@ class RandomClass:
with pytest.raises(ValueError):
pybop.Optimisation(cost=cost, optimiser=RandomClass)

@pytest.mark.unit
def test_standalone(self):
# Build an Optimisation problem with a StandaloneCost
cost = StandaloneCost()
opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize)
x, final_cost = opt.run()

assert len(opt.x0) == opt.n_parameters
np.testing.assert_allclose(x, 0, atol=1e-2)
np.testing.assert_allclose(final_cost, 42, atol=1e-2)

@pytest.mark.unit
def test_prior_sampling(self, cost):
# Tests prior sampling
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/test_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ def test_fitting_problem(self, parameters, dataset, model, signal):
with pytest.raises(ValueError):
pybop.FittingProblem(model, parameters, bad_dataset, signal=signal)

two_signals = ["Voltage [V]", "Time [s]"]
with pytest.raises(ValueError):
pybop.FittingProblem(model, parameters, bad_dataset, signal=two_signals)

@pytest.mark.unit
def test_design_problem(self, parameters, experiment, model):
# Test incorrect number of initial parameter values
Expand Down
97 changes: 97 additions & 0 deletions tests/unit/test_standalone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import pytest
import pybop
import numpy as np
from examples.standalone.cost import StandaloneCost
from examples.standalone.problem import StandaloneProblem


class TestStandalone:
"""
Class for testing stanadalone components.
"""

@pytest.mark.unit
def test_standalone(self):
# Build an Optimisation problem with a StandaloneCost
cost = StandaloneCost()
opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize)
x, final_cost = opt.run()

assert len(opt.x0) == opt.n_parameters
np.testing.assert_allclose(x, 0, atol=1e-2)
np.testing.assert_allclose(final_cost, 42, atol=1e-2)

@pytest.mark.unit
def test_standalone_problem(self):
# Define parameters to estimate
parameters = [
pybop.Parameter(
"Gradient",
prior=pybop.Gaussian(4.2, 0.02),
bounds=[-1, 10],
),
pybop.Parameter(
"Intercept",
prior=pybop.Gaussian(3.3, 0.02),
bounds=[-1, 10],
),
]

# Define target data
t_eval = np.linspace(0, 1, 100)
x0 = np.array([3, 4])
dataset = pybop.Dataset(
{
"Time [s]": t_eval,
"Output": x0[0] * t_eval + x0[1],
}
)
signal = "Output"

# Define a Problem without a Model
problem = StandaloneProblem(parameters, dataset, signal=signal)

# Test the Problem with a Cost
rmse_cost = pybop.RootMeanSquaredError(problem)
x = rmse_cost([1, 2])

np.testing.assert_allclose(x, 3.138, atol=1e-2)

# Test the sensitivities
sums_cost = pybop.SumSquaredError(problem)
sums_cost.evaluateS1([1, 2])

# Test incorrect number of initial parameter values
with pytest.raises(ValueError):
StandaloneProblem(parameters, dataset, signal=signal, x0=np.array([]))

# Test problem construction errors
for bad_dataset in [
pybop.Dataset({"Time [s]": np.array([0])}),
pybop.Dataset(
{
"Time [s]": np.array([-1]),
"Output": np.array([0]),
}
),
pybop.Dataset(
{
"Time [s]": np.array([1, 0]),
"Output": np.array([0, 0]),
}
),
pybop.Dataset(
{
"Time [s]": np.array([0]),
"Output": np.array([0, 0]),
}
),
pybop.Dataset(
{
"Time [s]": np.array([[0], [0]]),
"Output": np.array([0, 0]),
}
),
]:
with pytest.raises(ValueError):
StandaloneProblem(parameters, bad_dataset, signal=signal)

0 comments on commit 2e35380

Please sign in to comment.