# Copyright (C) 2015-2024, Iraklis Giannakis and Sylwia Majchrowska # # This module is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License. # To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/. # # Please use the attribution at http://dx.doi.org/10.1109/TAP.2014.2308549 import numpy as np import scipy.optimize from matplotlib import pylab as plt from tqdm import tqdm class Optimizer(object): """ Create choosen optimisation object. :param maxiter: The maximum number of iterations for the optimizer (Default: 1000). :type maxiter: int, optional :param seed: Seed for RandomState. Must be convertible to 32 bit unsigned integers (Default: None). :type seed: int, NoneType, optional """ def __init__(self, maxiter=1000, seed=None): self.maxiter = maxiter self.seed = seed self.calc_weights = None def fit(self, func, lb, ub, funckwargs={}): """Call the optimization function that tries to find an optimal set of relaxation times that minimise the error between the actual and the approximated electric permittivity and calculate optimised weights for the given relaxation times. Args: func (function): The function to be minimized. lb (ndarray): The lower bounds of the design variable(s). ub (ndarray): The upper bounds of the design variable(s). funckwargs (dict): Additional keyword arguments passed to objective and constraint function (Default: empty dict). Returns: tau (ndarray): The the best relaxation times. weights (ndarray): Resulting optimised weights for the given relaxation times. ee (float): Average error between the actual and the approximated real part. rl (ndarray): Real parts of chosen relaxation function for given frequency points. im (ndarray): Imaginary parts of chosen relaxation function for given frequency points. """ np.random.seed(self.seed) # find the relaxation frequencies using choosen optimization alghoritm tau, _ = self.calc_relaxation_times(func, lb, ub, funckwargs) # find the weights using a calc_weights method if self.calc_weights is None: raise NotImplementedError() _, _, weights, ee, rl_exp, im_exp = self.calc_weights(tau, **funckwargs) return tau, weights, ee, rl_exp, im_exp def calc_relaxation_times(self): """Optimisation method that tries to find an optimal set of relaxation times that minimise the error between the actual and the approximated electric permittivity. """ raise NotImplementedError() @staticmethod def cost_function(x, rl, im, freq): """ The cost function is the average error between the actual and the approximated electric permittivity. Args: x (ndarray): The logarithm with base 10 of relaxation times of the Debyes poles. rl (ndarray): Real parts of chosen relaxation function for given frequency points. im (ndarray): Imaginary parts of chosen relaxation function for given frequency points. freq (ndarray): The frequencies vector for defined grid. Returns: cost (float): Sum of mean absolute errors for real and imaginary parts. """ cost_i, cost_r, _, _, _, _ = DLS(x, rl, im, freq) return cost_i + cost_r class PSO_DLS(Optimizer): """Create hybrid Particle Swarm-Damped Least Squares optimisation object with predefined parameters. :param swarmsize: The number of particles in the swarm (Default: 40). :type swarmsize: int, optional :param maxiter: The maximum number of iterations for the swarm to search (Default: 50). :type maxiter: int, optional :param omega: Particle velocity scaling factor (Default: 0.9). :type omega: float, optional :param phip: Scaling factor to search away from the particle's best known position (Default: 0.9). :type phip: float, optional :param phig: Scaling factor to search away from the swarm's best known position (Default: 0.9). :type phig: float, optional :param minstep: The minimum stepsize of swarm's best position before the search terminates (Default: 1e-8). :type minstep: float, optional :param minfun: The minimum change of swarm's best objective value before the search terminates (Default: 1e-8) :type minfun: float, optional :param pflag: if True will plot the actual and the approximated value during optimization process (Default: False). :type pflag: bool, optional """ def __init__( self, swarmsize=40, maxiter=50, omega=0.9, phip=0.9, phig=0.9, minstep=1e-8, minfun=1e-8, pflag=False, seed=None ): super(PSO_DLS, self).__init__(maxiter, seed) self.swarmsize = swarmsize self.omega = omega self.phip = phip self.phig = phig self.minstep = minstep self.minfun = minfun self.pflag = pflag self.calc_weights = DLS def calc_relaxation_times(self, func, lb, ub, funckwargs={}): """ A particle swarm optimisation that tries to find an optimal set of relaxation times that minimise the error between the actual and the approximated electric permittivity. The current code is a modified edition of the pyswarm package which can be found at https://pythonhosted.org/pyswarm/ Args: func (function): The function to be minimized. lb (ndarray): The lower bounds of the design variable(s). ub (ndarray): The upper bounds of the design variable(s). funckwargs (dict): Additional keyword arguments passed to objective and constraint function (Default: empty dict). Returns: g (ndarray): The swarm's best known position (relaxation times). fg (float): The objective value at ``g``. """ np.random.seed(self.seed) # check input parameters assert len(lb) == len(ub), "Lower- and upper-bounds must be the same length" assert hasattr(func, "__call__"), "Invalid function handle" lb = np.array(lb) ub = np.array(ub) assert np.all(ub > lb), "All upper-bound values must be greater than lower-bound values" vhigh = np.abs(ub - lb) vlow = -vhigh # Initialize objective function obj = lambda x: func(x=x, **funckwargs) # Initialize the particle swarm d = len(lb) # the number of dimensions each particle has x = np.random.rand(self.swarmsize, d) # particle positions v = np.zeros_like(x) # particle velocities p = np.zeros_like(x) # best particle positions fp = np.zeros(self.swarmsize) # best particle function values g = [] # best swarm position fg = np.inf # artificial best swarm position starting value for i in range(self.swarmsize): # Initialize the particle's position x[i, :] = lb + x[i, :] * (ub - lb) # Initialize the particle's best known position p[i, :] = x[i, :] # Calculate the objective's value at the current particle's fp[i] = obj(p[i, :]) # At the start, there may not be any feasible starting point, # so just # give it a temporary "best" point since it's likely to change if i == 0: g = p[0, :].copy() # If the current particle's position is better than the swarm's, # update the best swarm position if fp[i] < fg: fg = fp[i] g = p[i, :].copy() # Initialize the particle's velocity v[i, :] = vlow + np.random.rand(d) * (vhigh - vlow) # Iterate until termination criterion met for it in tqdm(range(self.maxiter), desc="Debye fitting"): rp = np.random.uniform(size=(self.swarmsize, d)) rg = np.random.uniform(size=(self.swarmsize, d)) for i in range(self.swarmsize): # Update the particle's velocity v[i, :] = ( self.omega * v[i, :] + self.phip * rp[i, :] * (p[i, :] - x[i, :]) + self.phig * rg[i, :] * (g - x[i, :]) ) # Update the particle's position, # correcting lower and upper bound # violations, then update the objective function value x[i, :] = x[i, :] + v[i, :] mark1 = x[i, :] < lb mark2 = x[i, :] > ub x[i, mark1] = lb[mark1] x[i, mark2] = ub[mark2] fx = obj(x[i, :]) # Compare particle's best position if fx < fp[i]: p[i, :] = x[i, :].copy() fp[i] = fx # Compare swarm's best position to current # particle's position if fx < fg: tmp = x[i, :].copy() stepsize = np.sqrt(np.sum((g - tmp) ** 2)) if np.abs(fg - fx) <= self.minfun: print(f"Stopping search: Swarm best objective " f"change less than {self.minfun}") return tmp, fx elif stepsize <= self.minstep: print(f"Stopping search: Swarm best position " f"change less than {self.minstep}") return tmp, fx else: g = tmp.copy() fg = fx # Dynamically plot the error as the optimisation takes place if self.pflag: if it == 0: xpp = [it] ypp = [fg] else: xpp.append(it) ypp.append(fg) PSO_DLS.plot(xpp, ypp) return g, fg @staticmethod def plot(x, y): """ Dynamically plot the error as the optimisation takes place. Args: x (array): The number of current iterations. y (array): The objective value at for all x points. """ # it clears an axes plt.cla() plt.plot(x, y, "b-", linewidth=1.0) plt.ylim(min(y) - 0.1 * min(y), max(y) + 0.1 * max(y)) plt.xlim(min(x) - 0.1, max(x) + 0.1) plt.grid(b=True, which="major", color="k", linewidth=0.2, linestyle="--") plt.suptitle("Debye fitting process") plt.xlabel("Iteration") plt.ylabel("Average Error") plt.pause(0.0001) class DA_DLS(Optimizer): """Create Dual Annealing object with predefined parameters. The current class is a modified edition of the scipy.optimize package which can be found at: https://docs.scipy.org/doc/scipy/reference/generated/ scipy.optimize.dual_annealing.html#scipy.optimize.dual_annealing """ def __init__( self, maxiter=1000, local_search_options={}, initial_temp=5230.0, restart_temp_ratio=2e-05, visit=2.62, accept=-5.0, maxfun=1e7, no_local_search=False, callback=None, x0=None, seed=None, ): super(DA_DLS, self).__init__(maxiter, seed) self.local_search_options = local_search_options self.initial_temp = initial_temp self.restart_temp_ratio = restart_temp_ratio self.visit = visit self.accept = accept self.maxfun = maxfun self.no_local_search = no_local_search self.callback = callback self.x0 = x0 self.calc_weights = DLS def calc_relaxation_times(self, func, lb, ub, funckwargs={}): """ Find the global minimum of a function using Dual Annealing. The current class is a modified edition of the scipy.optimize package which can be found at: https://docs.scipy.org/doc/scipy/reference/generated/ scipy.optimize.dual_annealing.html#scipy.optimize.dual_annealing Args: func (function): The function to be minimized lb (ndarray): The lower bounds of the design variable(s) ub (ndarray): The upper bounds of the design variable(s) funckwargs (dict): Additional keyword arguments passed to objective and constraint function (Default: empty dict) Returns: x (ndarray): The solution array (relaxation times). fun (float): The objective value at the best solution. """ np.random.seed(self.seed) result = scipy.optimize.dual_annealing( func, bounds=list(zip(lb, ub)), args=funckwargs.values(), maxiter=self.maxiter, local_search_options=self.local_search_options, initial_temp=self.initial_temp, restart_temp_ratio=self.restart_temp_ratio, visit=self.visit, accept=self.accept, maxfun=self.maxfun, no_local_search=self.no_local_search, callback=self.callback, x0=self.x0, ) print(result.message) return result.x, result.fun class DE_DLS(Optimizer): """ Create Differential Evolution-Damped Least Squares object with predefined parameters. The current class is a modified edition of the scipy.optimize package which can be found at: https://docs.scipy.org/doc/scipy/reference/generated/ scipy.optimize.differential_evolution.html#scipy.optimize.differential_evolution """ def __init__( self, maxiter=1000, strategy="best1bin", popsize=15, tol=0.01, mutation=(0.5, 1), recombination=0.7, callback=None, disp=False, polish=True, init="latinhypercube", atol=0, updating="immediate", workers=1, constraints=(), seed=None, ): super(DE_DLS, self).__init__(maxiter, seed) self.strategy = strategy self.popsize = popsize self.tol = tol self.mutation = mutation self.recombination = recombination self.callback = callback self.disp = disp self.polish = polish self.init = init self.atol = atol self.updating = updating self.workers = workers self.constraints = constraints self.calc_weights = DLS def calc_relaxation_times(self, func, lb, ub, funckwargs={}): """ Find the global minimum of a function using Differential Evolution. The current class is a modified edition of the scipy.optimize package which can be found at: https://docs.scipy.org/doc/scipy/reference/generated/ scipy.optimize.differential_evolution.html#scipy.optimize.differential_evolution Args: func (function): The function to be minimized lb (ndarray): The lower bounds of the design variable(s) ub (ndarray): The upper bounds of the design variable(s) funckwargs (dict): Additional keyword arguments passed to objective and constraint function (Default: empty dict) Returns: x (ndarray): The solution array (relaxation times). fun (float): The objective value at the best solution. """ np.random.seed(self.seed) result = scipy.optimize.differential_evolution( func, bounds=list(zip(lb, ub)), args=funckwargs.values(), strategy=self.strategy, popsize=self.popsize, tol=self.tol, mutation=self.mutation, recombination=self.recombination, callback=self.callback, disp=self.disp, polish=self.polish, init=self.init, atol=self.atol, updating=self.updating, workers=self.workers, constraints=self.constraints, ) print(result.message) return result.x, result.fun def DLS(logt, rl, im, freq): """ Find the weights using a non-linear least squares (LS) method, the Levenberg–Marquardt algorithm (LMA or just LM), also known as the damped least-squares (DLS) method. Args: logt (ndarray): The best known position form optimization module (optimal design), the logarithm with base 10 of relaxation times of the Debyes poles. rl (ndarray): Real parts of chosen relaxation function for given frequency points. im (ndarray): Imaginary parts of chosen relaxation function for given frequency points. freq (ndarray): The frequencies vector for defined grid. Returns: cost_i (float): Mean absolute error between the actual and the approximated imaginary part. cost_r (float): Mean absolute error between the actual and the approximated real part (plus average error). x (ndarray): Resulting optimised weights for the given relaxation times. ee (float): Average error between the actual and the approximated real part. rp (ndarray): The real part of the permittivity for the optimised relaxation times and weights for the frequnecies included in freq. ip (ndarray): The imaginary part of the permittivity for the optimised relaxation times and weights for the frequnecies included in freq. """ # The relaxation time of the Debyes are given at as logarithms # logt=log10(t0) for efficiency during the optimisation # Here they are transformed back t0=10**logt tt = 10**logt # y = Ax, here the A matrix for the real and the imaginary part is builded d = 1 / (1 + 1j * 2 * np.pi * np.repeat(freq, len(tt)).reshape((-1, len(tt))) * tt) # Adding dumping (Levenberg–Marquardt algorithm) # Solving the overdetermined system y=Ax x = np.abs(np.linalg.lstsq(d.imag, im, rcond=None)[0]) # x - absolute damped least-squares solution rp, ip = np.matmul(d.real, x[np.newaxis].T).T[0], np.matmul(d.imag, x[np.newaxis].T).T[0] cost_i = np.sum(np.abs(ip - im)) / len(im) ee = np.mean(rl - rp) ee = max(ee, 1) cost_r = np.sum(np.abs(rp + ee - rl)) / len(im) return cost_i, cost_r, x, ee, rp, ip