Skip to content

Commit

Permalink
Merge branch 'develop' into mcmc-summary-updates
Browse files Browse the repository at this point in the history
# Conflicts:
#	CHANGELOG.md
  • Loading branch information
BradyPlanden committed Dec 4, 2024
2 parents 29ee4fa + 190f647 commit 8c6c8f9
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 49 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ ci:

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.8.0"
rev: "v0.8.1"
hooks:
- id: ruff
args: [--fix, --show-fixes]
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Features

- [#570](https://github.com/pybop-team/PyBOP/pull/570) - Updates the contour and surface plots, adds mixed chain effective sample size computation, x0 to optim.log
- [#566](https://github.com/pybop-team/PyBOP/pull/566) - Adds `UnitHyperCube` transformation class, fixes incorrect application of gradient transformation.
- [#569](https://github.com/pybop-team/PyBOP/pull/569) - Adds parameter specific learning rate functionality to GradientDescent optimiser.
- [#282](https://github.com/pybop-team/PyBOP/issues/282) - Restructures the examples directory.
- [#396](https://github.com/pybop-team/PyBOP/issues/396) - Adds `ecm_with_tau.py` example script.
Expand Down
1 change: 1 addition & 0 deletions pybop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
ScaledTransformation,
LogTransformation,
ComposedTransformation,
UnitHyperCube,
)

#
Expand Down
35 changes: 24 additions & 11 deletions pybop/costs/base_cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,45 +57,58 @@ def __call__(
inputs: Union[Inputs, list],
calculate_grad: bool = False,
apply_transform: bool = False,
):
) -> Union[float, tuple[float, np.ndarray]]:
"""
This method calls the forward model via problem.evaluate(inputs),
and computes the cost for the given output by calling self.compute().
Parameters
----------
inputs : Inputs or array-like
The parameters for which to compute the cost and gradient.
calculate_grad : bool, optional
A bool condition designating whether to calculate the gradient.
inputs : Inputs or list-like
The input parameters for which the cost and optionally the gradient
will be computed.
calculate_grad : bool, optional, default=False
If True, both the cost and gradient will be computed. Otherwise, only the
cost is computed.
apply_transform : bool, optional, default=False
If True, applies a transformation to the inputs before evaluating the model.
Returns
-------
float
The calculated cost function value.
float or tuple
- If `calculate_grad` is False, returns the computed cost (float).
- If `calculate_grad` is True, returns a tuple containing the cost (float)
and the gradient (np.ndarray).
Raises
------
ValueError
If an error occurs during the calculation of the cost.
"""
# Apply transformation if needed
# Note, we use the transformation and parameter properties here to enable
# differing attributes within the `LogPosterior` class

# Apply transformation if needed
self.has_transform = self.transformation is not None and apply_transform
if self.has_transform:
inputs = self.transformation.to_model(inputs)
inputs = self.parameters.verify(inputs)
self.parameters.update(values=list(inputs.values()))
model_inputs = self.transformation.to_model(inputs)
else:
model_inputs = inputs

# Validate inputs, update parameters
model_inputs = self.parameters.verify(model_inputs)
self.parameters.update(values=list(model_inputs.values()))

y, dy = None, None
if self._has_separable_problem:
if calculate_grad:
y, dy = self.problem.evaluateS1(self.problem.parameters.as_dict())
cost, grad = self.compute(y, dy=dy, calculate_grad=calculate_grad)

if self.has_transform and np.isfinite(cost):
jac = self.transformation.jacobian(inputs)
grad = np.matmul(grad, jac)

return cost, grad

y = self.problem.evaluate(self.problem.parameters.as_dict())
Expand Down
4 changes: 2 additions & 2 deletions pybop/models/base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ def simulate(
t_eval=[t_eval[0], t_eval[-1]]
if isinstance(self._solver, IDAKLUSolver)
else t_eval,
t_interp=t_eval,
t_interp=t_eval if self._solver.supports_interp else None,
)

def simulateEIS(
Expand Down Expand Up @@ -668,7 +668,7 @@ def simulateS1(
if isinstance(self._solver, IDAKLUSolver)
else t_eval,
calculate_sensitivities=True,
t_interp=t_eval,
t_interp=t_eval if self._solver.supports_interp else None,
)

def predict(
Expand Down
49 changes: 49 additions & 0 deletions pybop/transformation/transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,55 @@ def _transform(self, x: np.ndarray, method: str) -> np.ndarray:
raise ValueError(f"Unknown method: {method}")


class UnitHyperCube(ScaledTransformation):
"""
A class that implements a linear transformation between the model parameter space
and a normalized search space (unit hypercube), using an inverse scale factor.
This transformation maps the input parameters from a given range [lower, upper]
to a unit range [0, 1].
Initially based on pints.UnitCubeTransformation method.
Parameters
----------
lower : float or array-like of shape (n,)
The lower bound(s) of the model parameter space.
upper : float or array-like of shape (n,)
The upper bound(s) of the model parameter space.
Attributes
----------
lower : np.ndarray
The lower bound of the input space for each parameter.
upper : np.ndarray
The upper bound of the input space for each parameter.
coeff : np.ndarray
The scaling coefficient (1 / (upper - lower)) for each parameter.
inter : np.ndarray
The intercept (-lower) to shift the input parameters.
"""

def __init__(
self,
lower: Union[float, list, np.ndarray],
upper: Union[float, list, np.ndarray],
):
self.lower = lower
self.upper = upper

# Validate that upper > lower for all elements
if np.any(self.upper <= self.lower):
raise ValueError(
"All elements of upper bounds must be greater than lower bounds."
)

# Compute the scaling coefficient (1 / (upper - lower)) and intercept (-lower)
self.coeff = 1 / (self.upper - self.lower)
self.inter = -self.lower
super().__init__(coefficient=self.coeff, intercept=self.inter)


class LogTransformation(Transformation):
"""
This class implements a logarithmic transformation between the model parameter space
Expand Down
4 changes: 2 additions & 2 deletions scripts/ci/build_matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
python_version=("3.9" "3.10" "3.11" "3.12")
os=("ubuntu-latest" "windows-latest" "macos-13" "macos-14")
# This command fetches the last PyBaMM version excluding release candidates from PyPI
pybamm_version=($(curl -s https://pypi.org/pypi/pybamm/json | jq -r '.releases | keys[]' | grep -v rc | tail -n 1 | paste -sd " " -))
pybamm_version=($(curl -s https://pypi.org/pypi/pybamm/json | jq -r '.releases | keys[]' | grep -v rc | sort -V | tail -n 1 | paste -sd " " -))

# This command fetches the last PyBaMM versions including release candidates from PyPI
#pybamm_version=($(curl -s https://pypi.org/pypi/pybamm/json | jq -r '.releases | keys[]' | tail -n 1 | paste -sd " " -))
#pybamm_version=($(curl -s https://pypi.org/pypi/pybamm/json | jq -r '.releases | keys[]' | sort -V | tail -n 1 | paste -sd " " -))

# open dict
json='{
Expand Down
77 changes: 44 additions & 33 deletions tests/integration/test_transformation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import itertools

import numpy as np
import pytest

import pybop


def transformation_id(val):
"""Create a readable name for each transformation."""
if isinstance(val, pybop.IdentityTransformation):
return "Identity"
elif isinstance(val, pybop.UnitHyperCube):
return "UnitHyperCube"
elif isinstance(val, pybop.LogTransformation):
return "Log"
else:
return str(val)


class TestTransformation:
"""
A class for integration tests of the transformation methods.
Expand Down Expand Up @@ -34,25 +48,23 @@ def model(self):
return pybop.empirical.Thevenin(parameter_set=parameter_set)

@pytest.fixture
def parameters(self):
def parameters(self, transformation_r0, transformation_r1):
return pybop.Parameters(
pybop.Parameter(
"R0 [Ohm]",
prior=pybop.Uniform(0.001, 0.1),
bounds=[0, 0.1],
transformation=pybop.ScaledTransformation(
coefficient=1 / 0.35, intercept=-0.375
),
bounds=[1e-4, 0.1],
transformation=transformation_r0,
),
pybop.Parameter(
"R1 [Ohm]",
prior=pybop.Uniform(0.001, 0.1),
bounds=[0, 0.1],
transformation=pybop.LogTransformation(),
bounds=[1e-4, 0.1],
transformation=transformation_r1,
),
)

@pytest.fixture(params=[0.5])
@pytest.fixture(params=[0.6])
def init_soc(self, request):
return request.param

Expand All @@ -61,14 +73,10 @@ def noise(self, sigma, values):

@pytest.fixture(
params=[
pybop.GaussianLogLikelihoodKnownSigma,
pybop.GaussianLogLikelihood,
pybop.RootMeanSquaredError,
pybop.SumSquaredError,
pybop.SumofPower,
pybop.Minkowski,
pybop.LogPosterior,
pybop.LogPosterior, # Second for GaussianLogLikelihood
]
)
def cost_cls(self, request):
Expand All @@ -91,41 +99,44 @@ def cost(self, model, parameters, init_soc, cost_cls):
problem = pybop.FittingProblem(model, parameters, dataset)

# Construct the cost
first_map = True
if cost_cls is pybop.GaussianLogLikelihoodKnownSigma:
return cost_cls(problem, sigma0=self.sigma0)
elif cost_cls is pybop.GaussianLogLikelihood:
if cost_cls is pybop.GaussianLogLikelihood:
return cost_cls(problem)
elif cost_cls is pybop.LogPosterior and first_map:
first_map = False
return cost_cls(log_likelihood=pybop.GaussianLogLikelihood(problem))
elif cost_cls is pybop.LogPosterior:
return cost_cls(
log_likelihood=pybop.GaussianLogLikelihoodKnownSigma(
problem, sigma0=self.sigma0
)
)
return cost_cls(log_likelihood=pybop.GaussianLogLikelihood(problem))
else:
return cost_cls(problem)

@pytest.mark.parametrize(
"optimiser",
[
pybop.IRPropMin,
pybop.NelderMead,
],
[pybop.IRPropMin, pybop.CMAES, pybop.SciPyDifferentialEvolution],
ids=["IRPropMin", "CMAES", "SciPyDifferentialEvolution"],
)
@pytest.mark.integration
@pytest.mark.parametrize(
"transformation_r0, transformation_r1",
list(
itertools.product(
[
pybop.IdentityTransformation(),
pybop.UnitHyperCube(0.001, 0.1),
pybop.LogTransformation(),
],
repeat=2,
)
),
ids=lambda val: f"{transformation_id(val)}",
)
def test_thevenin_transformation(self, optimiser, cost):
x0 = cost.parameters.initial_value()
optim = optimiser(
cost=cost,
sigma0=[0.03, 0.03, 1e-3]
sigma0=[0.02, 0.02, 3e-3]
if isinstance(cost, (pybop.GaussianLogLikelihood, pybop.LogPosterior))
else [0.03, 0.03],
max_unchanged_iterations=35,
else [0.02, 0.02],
max_unchanged_iterations=25,
absolute_tolerance=1e-6,
max_iterations=250,
popsize=3 if optimiser is pybop.SciPyDifferentialEvolution else 6,
)

initial_cost = optim.cost(x0)
Expand Down Expand Up @@ -157,8 +168,8 @@ def get_data(self, model, init_soc):
experiment = pybop.Experiment(
[
(
"Discharge at 0.5C for 2 minutes (4 second period)",
"Rest for 1 minute (4 second period)",
"Rest for 10 seconds (5 second period)",
"Discharge at 0.5C for 6 minutes (12 second period)",
),
]
)
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/test_transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ def parameters(self):
"Log",
transformation=pybop.LogTransformation(),
),
pybop.Parameter(
"UnitHyperCube", transformation=pybop.UnitHyperCube(10, 100)
),
)

@pytest.mark.unit
Expand Down Expand Up @@ -80,6 +83,36 @@ def test_scaled_transformation(self, parameters):
with pytest.raises(ValueError, match="Unknown method:"):
transformation._transform(q, "bad-string")

@pytest.mark.unit
def test_hypercube_transformation(self, parameters):
q = np.asarray([0.5])
coeff = 1 / (100 - 10)
transformation = parameters["UnitHyperCube"].transformation
p = transformation.to_model(q)
assert np.allclose(p, (q / coeff) + 10)
assert transformation.n_parameters == 1
assert transformation.is_elementwise()

q_transformed = transformation.to_search(p)
assert np.allclose(q_transformed, q)
assert np.allclose(
transformation.log_jacobian_det(q), np.sum(np.log(np.abs(coeff)))
)
log_jac_det_S1 = transformation.log_jacobian_det_S1(q)
assert log_jac_det_S1[0] == np.sum(np.log(np.abs(coeff)))
assert log_jac_det_S1[1] == np.zeros(1)

jac, jac_S1 = transformation.jacobian_S1(q)
assert np.array_equal(jac, np.diag([1 / coeff]))
assert np.array_equal(jac_S1, np.zeros((1, 1, 1)))

# Test incorrect scaling bounds
with pytest.raises(
ValueError,
match="All elements of upper bounds must be greater than lower bounds.",
):
pybop.UnitHyperCube(100, 1)

@pytest.mark.unit
def test_log_transformation(self, parameters):
q = np.asarray([10])
Expand Down

0 comments on commit 8c6c8f9

Please sign in to comment.