Skip to content

Commit

Permalink
feat: adds support for single DesignProblem optimisation, fixes for m…
Browse files Browse the repository at this point in the history
…inimising, adds integration tests
  • Loading branch information
BradyPlanden committed Jul 18, 2024
1 parent 8c7dddb commit 132f83c
Show file tree
Hide file tree
Showing 9 changed files with 371 additions and 181 deletions.
10 changes: 6 additions & 4 deletions examples/scripts/spme_max_energy.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@
model, parameters, experiment, signal=signal, init_soc=init_soc
)

# Generate cost function and optimisation class:
cost = pybop.GravimetricEnergyDensity(problem)
# Generate multiple cost functions and combine them.
cost1 = pybop.GravimetricEnergyDensity(problem, update_capacity=True)
cost2 = pybop.VolumetricEnergyDensity(problem, update_capacity=True)
cost = pybop.WeightedCost(cost1, cost2, weights=[1, 1])

# Run optimisation
optim = pybop.PSO(
cost, verbose=True, allow_infeasible_solutions=False, max_iterations=15
)

# Run optimisation
x, final_cost = optim.run()
print("Estimated parameters:", x)
print(f"Initial gravimetric energy density: {cost(optim.x0):.2f} Wh.kg-1")
Expand Down
3 changes: 2 additions & 1 deletion pybop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
#
# Cost function class
#
from .costs.base_cost import BaseCost, WeightedCost
from .costs.base_cost import BaseCost
from .costs.fitting_costs import (
RootMeanSquaredError,
SumSquaredError,
Expand All @@ -100,6 +100,7 @@
GaussianLogLikelihoodKnownSigma,
MAP,
)
from .costs._weighted_cost import WeightedCost

#
# Optimiser class
Expand Down
137 changes: 137 additions & 0 deletions pybop/costs/_weighted_cost.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import copy
from typing import Optional

import numpy as np

from pybop import BaseCost, BaseLikelihood, DesignCost
from pybop.parameters.parameter import Inputs


class WeightedCost(BaseCost):
"""
A subclass for constructing a linear combination of cost functions as
a single weighted cost function.
Inherits all parameters and attributes from ``BaseCost``.
Attributes
---------------------
costs : list[pybop.BaseCost]
A list of PyBOP cost objects.
weights : list[float]
A list of values with which to weight the cost values.
_has_different_problems : bool
If True, the problem for each cost is evaluated independently during
each evaluation of the cost (default: False).
"""

def __init__(self, *costs, weights: Optional[list[float]] = None):
if not all(isinstance(cost, BaseCost) for cost in costs):
raise TypeError("All costs must be instances of BaseCost.")
self.costs = [copy.copy(cost) for cost in costs]
self._has_different_problems = False
self.minimising = not any(
isinstance(cost, (BaseLikelihood, DesignCost)) for cost in self.costs
)
if len(set(type(cost.problem) for cost in self.costs)) > 1:
raise TypeError("All problems must be of the same class type.")

# Check if weights are provided
if weights is not None:
try:
self.weights = np.asarray(weights, dtype=float)
except ValueError:
raise ValueError("Weights must be numeric values.") from None

if self.weights.size != len(self.costs):
raise ValueError("Number of weights must match number of costs.")
else:
self.weights = np.ones(len(self.costs))

# Check if all costs depend on the same problem
self._has_different_problems = any(
hasattr(cost, "problem") and cost.problem is not self.costs[0].problem
for cost in self.costs[1:]
)

if self._has_different_problems:
super().__init__()
for cost in self.costs:
self.parameters.join(cost.parameters)
else:
super().__init__(self.costs[0].problem)
self._predict = False
for cost in self.costs:
cost._predict = False

# Check if any cost function requires capacity update
if any(cost.update_capacity for cost in self.costs):
self.update_capacity = True

def _evaluate(self, inputs: Inputs, grad=None):
"""
Calculate the weighted cost for a given set of parameters.
Parameters
----------
inputs : Inputs
The parameters for which to compute the cost.
grad : array-like, optional
An array to store the gradient of the cost function with respect
to the parameters.
Returns
-------
float
The weighted cost value.
"""
e = np.empty_like(self.costs)

if not self._predict:
if self._has_different_problems:
self.parameters.update(values=list(inputs.values()))
else:
self.y = self.problem.evaluate(
inputs, update_capacity=self.update_capacity
)

for i, cost in enumerate(self.costs):
if not self._has_different_problems:
cost.y = self.y
e[i] = cost.evaluate(inputs)

return np.dot(e, self.weights)

def _evaluateS1(self, inputs: Inputs):
"""
Compute the weighted cost and its gradient with respect to the parameters.
Parameters
----------
inputs : Inputs
The parameters for which to compute the cost and gradient.
Returns
-------
tuple
A tuple containing the cost and the gradient. The cost is a float,
and the gradient is an array-like of the same length as `x`.
"""
e = np.empty_like(self.costs)
de = np.empty((len(self.parameters), len(self.costs)))

if not self._predict:
if self._has_different_problems:
self.parameters.update(values=list(inputs.values()))
else:
self.y, self.dy = self.problem.evaluateS1(inputs)

for i, cost in enumerate(self.costs):
if not self._has_different_problems:
cost.y, cost.dy = (self.y, self.dy)
e[i], de[:, i] = cost.evaluateS1(inputs)

e = np.dot(e, self.weights)
de = np.dot(de, self.weights)

return e, de
141 changes: 2 additions & 139 deletions pybop/costs/base_cost.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import copy
import warnings
from typing import Optional, Union

import numpy as np

from pybop import BaseProblem, DesignProblem
from pybop import BaseProblem
from pybop.parameters.parameter import Inputs, Parameters


Expand Down Expand Up @@ -36,6 +32,7 @@ def __init__(self, problem: Optional[BaseProblem] = None):
self.problem = problem
self.verbose = False
self._predict = False
self.update_capacity = False
self.y = None
self.dy = None
self.set_fail_gradient()
Expand Down Expand Up @@ -210,137 +207,3 @@ def verify_prediction(self, y):
return False

return True


class WeightedCost(BaseCost):
"""
A subclass for constructing a linear combination of cost functions as
a single weighted cost function.
Inherits all parameters and attributes from ``BaseCost``.
Attributes
---------------------
costs : list[pybop.BaseCost]
A list of PyBOP cost objects.
weights : list[float]
A list of values with which to weight the cost values.
_has_different_problems : bool
If True, the problem for each cost is evaluated independently during
each evaluation of the cost (default: False).
"""

def __init__(self, *costs, weights: Optional[list[float]] = None):
if not all(isinstance(cost, BaseCost) for cost in costs):
raise TypeError("All costs must be instances of BaseCost.")
self.costs = [copy.copy(cost) for cost in costs]
self._has_different_problems = False
self.minimising = not any(
isinstance(cost.problem, DesignProblem) for cost in self.costs
)
if len(set(type(cost.problem) for cost in self.costs)) > 1:
raise TypeError("All problems must be of the same class type.")

# Check if weights are provided
if weights is not None:
try:
self.weights = np.asarray(weights, dtype=float)
except ValueError:
raise ValueError("Weights must be numeric values.") from None

if self.weights.size != len(self.costs):
raise ValueError("Number of weights must match number of costs.")
else:
self.weights = np.ones(len(self.costs))

# Check if all costs depend on the same problem
self._has_different_problems = any(
hasattr(cost, "problem") and cost.problem is not self.costs[0].problem
for cost in self.costs[1:]
)

if self._has_different_problems:
super().__init__()
for cost in self.costs:
self.parameters.join(cost.parameters)
else:
super().__init__(self.costs[0].problem)
self._predict = False
for cost in self.costs:
cost._predict = False

# Catch UserWarnings as exceptions
if not self.minimising:
warnings.filterwarnings("error", category=UserWarning)

def _evaluate(self, inputs: Inputs, grad=None):
"""
Calculate the weighted cost for a given set of parameters.
Parameters
----------
inputs : Inputs
The parameters for which to compute the cost.
grad : array-like, optional
An array to store the gradient of the cost function with respect
to the parameters.
Returns
-------
float
The weighted cost value.
"""
e = np.empty_like(self.costs)

if not self._predict:
if self._has_different_problems:
self.parameters.update(values=list(inputs.values()))
else:
try:
with warnings.catch_warnings():
self.y = self.problem.evaluate(inputs)
except UserWarning as e:
if self.verbose:
print(f"Ignoring this sample due to: {e}")
return -np.inf

for i, cost in enumerate(self.costs):
if not self._has_different_problems:
cost.y = self.y
e[i] = cost.evaluate(inputs)

return np.dot(e, self.weights)

def _evaluateS1(self, inputs: Inputs):
"""
Compute the weighted cost and its gradient with respect to the parameters.
Parameters
----------
inputs : Inputs
The parameters for which to compute the cost and gradient.
Returns
-------
tuple
A tuple containing the cost and the gradient. The cost is a float,
and the gradient is an array-like of the same length as `x`.
"""
e = np.empty_like(self.costs)
de = np.empty((len(self.parameters), len(self.costs)))

if not self._predict:
if self._has_different_problems:
self.parameters.update(values=list(inputs.values()))
else:
self.y, self.dy = self.problem.evaluateS1(inputs)

for i, cost in enumerate(self.costs):
if not self._has_different_problems:
cost.y, cost.dy = (self.y, self.dy)
e[i], de[:, i] = cost.evaluateS1(inputs)

e = np.dot(e, self.weights)
de = np.dot(de, self.weights)

return e, de
31 changes: 11 additions & 20 deletions pybop/costs/design_costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,32 +91,17 @@ def evaluate(self, inputs: Union[Inputs, list], grad=None):
inputs = self.parameters.verify(inputs)

try:
with warnings.catch_warnings():
# Convert UserWarning to an exception
warnings.filterwarnings("error", category=UserWarning)
if self._predict:
if self.update_capacity:
self.problem.model.approximate_capacity(inputs)
self.y = self.problem.evaluate(inputs)
if self._predict:
self.y = self.problem.evaluate(
inputs, update_capacity=self.update_capacity
)

return self._evaluate(inputs, grad)
return self._evaluate(inputs, grad)

# Catch NotImplementedError and raise it
except NotImplementedError as e:
raise e

# Catch infeasible solutions and return infinity
except UserWarning as e:
if self.verbose:
print(f"Ignoring this sample due to: {e}")
return -np.inf

# Catch any other exception and return infinity
except Exception as e:
if self.verbose:
print(f"An error occurred during the evaluation: {e}")
return -np.inf


class GravimetricEnergyDensity(DesignCost):
"""
Expand Down Expand Up @@ -147,6 +132,9 @@ def _evaluate(self, inputs: Inputs, grad=None):
float
The gravimetric energy density or -infinity in case of infeasible parameters.
"""
if not any(np.isfinite(self.y[signal][0]) for signal in self.signal):
return -np.inf

voltage, current = self.y["Voltage [V]"], self.y["Current [A]"]
energy_density = np.trapz(voltage * current, dx=self.dt) / (
3600 * self.problem.model.cell_mass(self.parameter_set)
Expand Down Expand Up @@ -184,6 +172,9 @@ def _evaluate(self, inputs: Inputs, grad=None):
float
The volumetric energy density or -infinity in case of infeasible parameters.
"""
if not any(np.isfinite(self.y[signal][0]) for signal in self.signal):
return -np.inf

voltage, current = self.y["Voltage [V]"], self.y["Current [A]"]
energy_density = np.trapz(voltage * current, dx=self.dt) / (
3600 * self.problem.model.cell_volume(self.parameter_set)
Expand Down
1 change: 1 addition & 0 deletions pybop/problems/base_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def __init__(
self.n_outputs = len(self.signal)
self._time_data = None
self._target = None
self.verbose = False

if isinstance(model, BaseModel):
self.additional_variables = additional_variables
Expand Down
Loading

0 comments on commit 132f83c

Please sign in to comment.