diff --git a/examples/notebooks/transformation_introduction.ipynb b/examples/notebooks/transformation_introduction.ipynb new file mode 100644 index 000000000..2c313fc6f --- /dev/null +++ b/examples/notebooks/transformation_introduction.ipynb @@ -0,0 +1,496 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Introduction to Transformations\n", + "This example introduces the `pybop.BaseTransformation` class and it's instances. This class adds functionality for the cost and likelihood functions to be transformed into a separate search space. This search space is used by the optimiser and sampler classes during inference. These transformations can be both linear (`pybop.ScaledTransformation`) and non-linear (`pybop.LogTransformation`). By default, if transformations are applied, the sampling and optimisers will search in the transformed space.\n", + "\n", + "Transformation can be helpful when the difference in parameter magnitudes is large, or to create a search space that is better posed for the optimisation algorithm. Before we begin, we need to ensure that we have all the necessary tools. We will install and import PyBOP alongside any other package dependencies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install --upgrade pip ipywidgets -q\n", + "%pip install pybop -q\n", + "\n", + "import numpy as np\n", + "\n", + "import pybop\n", + "\n", + "pybop.PlotlyManager().pio.renderers.default = \"notebook_connected\"" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "First, to showcase the transformation functionality, we need to construct the `pybop.Cost` class. This class needs the following objects:\n", + "- Model\n", + "- Dataset\n", + "- Parameters to identify\n", + "- Problem\n", + "\n", + "We will first construct the model, then the parameters and corresponding dataset. Once that is complete, the problem will be created. With the cost class created, we will showcase the different interactions users can have with the class. A small example with evaluation as well as computation is presented." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "model = pybop.lithium_ion.SPM()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Now that we have the model constructed, let's define the parameters for identification. At this point, we define the corresponding transformations applied to each parameter. PyBOP allows for transformations to be applied at the individual parameter level, which is then combined for application on each cost call. Below we will apply a linear transformation using the pybop `ScaledTransformation` class. This class has arguments for a `coefficient` which defines the linear stretch or scaling of the search space, and `intercept` which defines the space shift. The equation for this transformation is:\n", + "\n", + "$$\n", + "y_{search} = m*x_{model}+b\n", + "$$\n", + "\n", + "where $m$ is the linear scale coefficient, $b$ is the intercept, $x_{model}$ is the model parameter space, and $y_{search}$ is the transformed space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "parameters = pybop.Parameters(\n", + " pybop.Parameter(\n", + " \"Negative electrode active material volume fraction\",\n", + " initial_value=0.6,\n", + " bounds=[0.35, 0.7],\n", + " transformation=pybop.ScaledTransformation(coefficient=2.0, intercept=-0.6),\n", + " ),\n", + " pybop.Parameter(\n", + " \"Positive electrode active material volume fraction\",\n", + " initial_value=0.6,\n", + " bounds=[0.45, 0.625],\n", + " transformation=pybop.ScaledTransformation(coefficient=2.0, intercept=-0.6),\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "Next, to create the `pybop.Dataset` we generate some synthetic data from the model using the `model.predict` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "t_eval = np.linspace(0, 10, 100)\n", + "values = model.predict(t_eval=t_eval)\n", + "\n", + "dataset = pybop.Dataset(\n", + " {\n", + " \"Time [s]\": t_eval,\n", + " \"Current function [A]\": values[\"Current [A]\"].data,\n", + " \"Voltage [V]\": values[\"Voltage [V]\"].data,\n", + " }\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Now that we have the model, parameters, and dataset, we can combine them and construct the problem class. This is the last step needed before investigating how the transformation functionality works." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "problem = pybop.FittingProblem(model, parameters, dataset)\n", + "cost = pybop.SumofPower(problem)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "The conventional way to use the cost class is through the `cost.__call__` method, which is completed below without transformations applied." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0.006904000484442387" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cost([0.6, 0.6])" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "However, we can also interact with the cost function with transformations applied via the `apply_transform` optional arugment. This arg is by default set to `False`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0.006904000484442387" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cost([0.0, 0.0], apply_transform=True)" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "Given the transformations applied in the parameter class above, we can confirm the alignment between the search space and the model space by passing values that coincide:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cost([0.0, 0.0], apply_transform=True) == cost([0.6, 0.6])" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "We can more thoroughly test the transformation by testing that the search space is scaled by a value of two through the following:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cost([0.05, 0.05], apply_transform=True) == cost([0.625, 0.625])" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "Next, we can plot cost landscapes of these two spaces. In the first instance, we plot the model space through the conventional method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + " \n", + " " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "pybop.plot2d(cost, steps=15);" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "Next, we can use the `apply_transform` argument when constructing the cost landscape to via the search space. First, we will transform the bounds used above to the search space using each parameters transformation instance and the `to_search` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "pybop.plot2d(cost, steps=15, apply_transform=True);" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "Note the difference in axis scale compared to the non-transformed landscape. Next, let's change the transformation on the 'Positive electrode active material volume fraction' to a non-linear log space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "parameters[\n", + " \"Positive electrode active material volume fraction\"\n", + "].transformation = pybop.LogTransformation()\n", + "cost.transformation = parameters.construct_transformation()" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "Let's update the bounds and plot the cost landscape again:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "pybop.plot2d(cost, steps=15, apply_transform=True);" + ] + }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, + "source": [ + "## Concluding Thoughts\n", + "\n", + "In the notebook we've introduced the transformation class and it's interaction with the `pybop.Parameters` and `pybop.BaseCost` classes. Transformation allow for the optimisation or sampling search space to scaled for improved convergence in situations where the optimisation hyperparameters are poorly tuned, or in optimisation tasks with high variance in the parameter magnitudes. " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py index 9d7736452..21936df75 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -525,7 +525,7 @@ def construct_transformation(self): ] return ComposedTransformation(valid_transformations) - def get_bounds_for_plotly(self): + def get_bounds_for_plotly(self, apply_transform: bool = False) -> np.ndarray: """ Retrieve parameter bounds in the format expected by Plotly. @@ -534,9 +534,7 @@ def get_bounds_for_plotly(self): bounds : numpy.ndarray An array of shape (n_parameters, 2) containing the bounds for each parameter. """ - bounds = np.zeros((len(self), 2)) - - for i, param in enumerate(self.param.values()): + for param in self.param.values(): if param.applied_prior_bounds: warnings.warn( "Bounds were created from prior distributions. " @@ -544,12 +542,14 @@ def get_bounds_for_plotly(self): UserWarning, stacklevel=2, ) - if param.bounds is not None: - bounds[i] = param.bounds - else: - raise ValueError("All parameters require bounds for plotting.") - return bounds + bounds = self.get_bounds(apply_transform=apply_transform) + + # Validate that all parameters have bounds + if bounds is None: + raise ValueError("All parameters require bounds for plotting.") + + return np.asarray(list(bounds.values())).T def as_dict(self, values=None) -> dict: """ diff --git a/pybop/plotting/plot2d.py b/pybop/plotting/plot2d.py index 09a49c0ad..99b7c77e1 100644 --- a/pybop/plotting/plot2d.py +++ b/pybop/plotting/plot2d.py @@ -1,4 +1,5 @@ import warnings +from functools import partial from typing import Union import numpy as np @@ -8,9 +9,10 @@ def plot2d( - cost_or_optim, + call_object: Union[BaseCost, BaseOptimiser], gradient: bool = False, bounds: Union[np.ndarray, None] = None, + apply_transform: bool = False, steps: int = 10, show: bool = True, use_optim_log: bool = False, @@ -24,7 +26,7 @@ def plot2d( Parameters ---------- - cost_or_optim : a callable cost function, pybop Cost or Optimisation object + call_object : Union([pybop.BaseCost,pybop.BaseOptimiser, pybop.BasePrior]) Either: - the cost function to be evaluated. Must accept a list of parameter values and return a cost value. - an Optimisation object which provides a specific optimisation trace overlaid on the cost landscape. @@ -54,15 +56,18 @@ def plot2d( ValueError If the cost function does not return a valid cost when called with a parameter list. """ + plot_optim = False + cost = cost_call = call_object # Assign input as a cost or optimisation object - if isinstance(cost_or_optim, (BaseOptimiser, Optimisation)): - optim = cost_or_optim + if isinstance(call_object, (BaseOptimiser, Optimisation)): plot_optim = True + optim = call_object cost = optim.cost - else: - cost = cost_or_optim - plot_optim = False + cost_call = partial(optim.cost, apply_transform=apply_transform) + elif isinstance(call_object, BaseCost): + cost = call_object + cost_call = partial(cost, apply_transform=apply_transform) if isinstance(cost, BaseCost) and len(cost.parameters) < 2: raise ValueError("This cost function takes fewer than 2 parameters.") @@ -85,7 +90,7 @@ def plot2d( # Set up parameter bounds if bounds is None: - bounds = cost.parameters.get_bounds_for_plotly() + bounds = cost.parameters.get_bounds_for_plotly(apply_transform=apply_transform) # Generate grid x = np.linspace(bounds[0, 0], bounds[0, 1], steps) @@ -97,22 +102,28 @@ def plot2d( # Populate cost matrix for i, xi in enumerate(x): for j, yj in enumerate(y): - costs[j, i] = cost(np.asarray([xi, yj] + additional_values)) + costs[j, i] = cost_call( + np.asarray([xi, yj] + additional_values), + ) if gradient: grad_parameter_costs = [] # Determine the number of gradient outputs from cost.compute num_gradients = len( - cost(np.asarray([x[0], y[0]] + additional_values), calculate_grad=True)[1] + cost_call( + np.asarray([x[0], y[0]] + additional_values), + calculate_grad=True, + )[1] ) # Create an array to hold each gradient output & populate grads = [np.zeros((len(y), len(x))) for _ in range(num_gradients)] for i, xi in enumerate(x): for j, yj in enumerate(y): - (*current_grads,) = cost( - np.asarray([xi, yj] + additional_values), calculate_grad=True + (*current_grads,) = cost_call( + np.asarray([xi, yj] + additional_values), + calculate_grad=True, )[1] for k, grad_output in enumerate(current_grads): grads[k][j, i] = grad_output