0
votes

Problem
I have created a curve fitting exercise (see functional code below), but I would like to add to the functionality.
I need to be able to define the following condition: slope at min(xdata) = 0.
(in words: I want the fitted curve to start out with horizontal gradient)

What I have tried
I have spent quite a bit of time researching scipy.optimize.curve_fit and evaluated other options (lmfit package, and scipy functions scipy.optimize.fmin_slsqp, scipy.optimize.minimize, etc.). lmfit only allows me to set a static condition on the parameters, such as p1 = 2 * p2 + 3. But it does not allow me to address min(xdata) dynamically, and I cannot make use of the derivate in the constraint.
Scipy only allows me to minimize the function (find an optimal x, but parameters p are already known). Or it can be used to define a specific range for the parameters. I was not able to define a second function that can be used to constrain the parameters during the curve fitting.

I need to be able to pass the condition directly to the curve fitting algorithm (rather than addressing the problem by bringing the condition into the cubic_fit() equation - it seems possible to eliminate e.g. p3 and define it as a combination of the other parameters and min(xdata)). My actual fitting function is much more complex and I need to run this script iteratively on a batch of data (varying min(xdata)). I cannot manually alter the fitting function each time...

I am grateful for any suggestions, maybe there are other packages out there that allow for a more complex definition of the curve fitting problem?

import numpy as np
import matplotlib.pyplot as plt
import scipy.stats
import scipy.optimize

# generate dummy data - on which I will run a curve fit below
def cubic_fit_with_noise(x, p1, p2, p3, p4):
    return p1 + p2*x + p3*x**2 + p4*x**3 + np.random.rand()

xdata = [x * 0.1 for x in range(0, 100)]
ydata = np.array( [cubic_fit_with_noise (x, 2, 0.4, -.2,0.02) for x in xdata] )

# now, run the curve-fit

#  set up the fitting function: 
def cubic_fit(x, p1, p2, p3, p4):
    return p1 + p2*x + p3*x**2 + p4*x**3

# define starting point:
s1 = 2.5
s2 = 0.2
s3 = -.2
s4 = 0.02

# scipy curve fitting:
popt, pcov = scipy.optimize.curve_fit(cubic_fit, xdata, ydata, p0=(s1,s2,s3,s4))
y_modelled = np.array([cubic_fit(x, popt[0], popt[1], popt[2], popt[3]) for x in xdata])

print(popt)     # prints out the 4 parameters p1,p2,p3,p4 defined in curve-fitting

plt.plot(xdata, ydata, 'bo')
plt.plot(xdata, y_modelled, 'r-')
plt.show()

The above code runs with Python3 (fix the print statement if you have Python2). As an addition, I want to bring in the derivative:

def cubic_fit_derivative(x, p1, p2, p3, p4):
    return p2 + 2.0 * p3 * x + 3 * p4 * x**2

and the constraint that cubic_fit_derivative(min(xdata), p1,p2,p3,p4) = 0.

3
quick guess fit a * (x - xmin)**2 * (x - b) or something like this - mikuszefski
Thanks for your feedback, everyone. The suggestions I see below all seem to work (at the time of writing). I have accepted the one by @Newville as the answer as it has the clearest approach and is highly versatile, so can easily be adopted for more complex tasks. - rde
I agree, though I can tell from experience: Don't fit with constraints if you don't have to. - mikuszefski
@mikuszefski in other words, if you have to use constraints, use them. ;). In this case, the constraint simply removes a variable parameter from the fit. One could have altered the objective function to impose p2 = -p3*xmin - 3*p4*xmin**2, and used only three variables (p1, p3, p4), but that's not a very general approach. In a sense, the model function always does impose a constraint on the fit. The approach described with lmfit just makes it easier to adjust what values the parameter take. - M Newville
@MNewville I never doubted that the constraints-approach is very general and powerful. Yet, depending on the type of constraint and the size of your data set, computationally it can be very painful. From that point of view you gave the right answer to the wrong question. A third order polynomial with slope zero at x0 is just a polynomial where the homogeneous part has a twin root at x0. No need for any constraints, just simple maths and just a simple fit. On the other hand, we do not know what the OP's true problem is, and it might be very different from a third order polynomial. Cheers. - mikuszefski

3 Answers

1
votes

Your condition that the derivative of your polynomial = 0 at xmin can be expressed as a simple constraint and means that the variables p2, p3, and p4 are not actually independent. The derivate condition is

p2 + 2*p3*xmin + 3*p4*xmin**2 = 0

where xmin is the minimum value of xdata. Furthermore, xmin will be known prior to the fit (if not necessarily when your script is written), you can use this to constrain one of the three parameters. Since xmin may be zero (in fact, it is for your case), the constraint should be that

p2 = - 2*p3*xmin - 3*p4*xmin**2

Using lmfit, the original, unconstrained fit would look like this (I cleaned it up a bit):

import numpy as np
from lmfit import Model
import matplotlib.pylab as plt

#  the model function:
def cubic_poly(x, p1, p2, p3, p4):
    return p1 + p2*x + p3*x**2 + p4*x**3

xdata = np.arange(100) * 0.1
ydata = cubic_poly(xdata, 2, 0.4, -.2, 0.02)
ydata = ydata + np.random.normal(size=len(xdata), scale=0.05)

# make Model, create parameters, run fit, print results
model  = Model(cubic_poly)
params = model.make_params(p1=2.5, p2=0.2, p3=-0.0, p4=0.0)
result = model.fit(ydata, params, x=xdata)
print(result.fit_report())

plt.plot(xdata, ydata, 'bo')
plt.plot(xdata, result.best_fit, 'r-')
plt.show()

which prints:

[[Model]]
    Model(cubic_poly)
[[Fit Statistics]]
    # function evals   = 13
    # data points      = 100
    # variables        = 4
    chi-square         = 0.218
    reduced chi-square = 0.002
    Akaike info crit   = -604.767
    Bayesian info crit = -594.347
[[Variables]]
    p1:   2.00924432 +/- 0.018375 (0.91%) (init= 2.5)
    p2:   0.39427207 +/- 0.016155 (4.10%) (init= 0.2)
    p3:  -0.19902928 +/- 0.003802 (1.91%) (init=-0)
    p4:   0.01993319 +/- 0.000252 (1.27%) (init= 0)
[[Correlations]] (unreported correlations are <  0.100)
    C(p3, p4)                    = -0.986 
    C(p2, p3)                    = -0.967 
    C(p2, p4)                    =  0.914 
    C(p1, p2)                    = -0.857 
    C(p1, p3)                    =  0.732 
    C(p1, p4)                    = -0.646 

and produces a plot of enter image description here

Now, to add your constraint condition, we will add xmin as a fixed parameter, and constrain p2 as above, replace the above with:

params = model.make_params(p1=2.5, p2=0.2, p3=-0.0, p4=0.0)

# add an extra parameter for `xmin`
params.add('xmin', min(xdata), vary=False)

# constrain p2 so that the derivative is 0 at xmin
params['p2'].expr = '-2*p3*xmin - 3*p4*xmin**2'

result = model.fit(ydata, params, x=xdata)
print(result.fit_report())

plt.plot(xdata, ydata, 'bo')
plt.plot(xdata, result.best_fit, 'r-')
plt.show()

which now prints

[[Model]]
    Model(cubic_poly)
[[Fit Statistics]]
    # function evals   = 10
    # data points      = 100
    # variables        = 3
    chi-square         = 1.329
    reduced chi-square = 0.014
    Akaike info crit   = -426.056
    Bayesian info crit = -418.241
[[Variables]]
    p1:     2.39001759 +/- 0.023239 (0.97%) (init= 2.5)
    p2:     0          +/- 0        (nan%)  == '-2*p3*xmin - 3*p4*xmin**2'
    p3:    -0.10858258 +/- 0.002372 (2.19%) (init=-0)
    p4:     0.01424411 +/- 0.000251 (1.76%) (init= 0)
    xmin:   0 (fixed)
[[Correlations]] (unreported correlations are <  0.100)
    C(p3, p4)                    = -0.986 
    C(p1, p3)                    = -0.742 
    C(p1, p4)                    =  0.658 

and a plot like

enter image description here

If xmin had not been zero (say, xdata = np.linspace(-10, 10, 101), the value and uncertainty of p2 would not be zero.

0
votes

As mentioned in my comment, you just have to fit the right function. I forgot the constant, though. So the function would be a*(x-xmin)**2*(x-xn)+c

As curvefit does not take additional parameters as would e.g. leatssq, the only trick is to pass xmin. I do that by a global variable (Maybe not the nicest way, but it works. Comments on how to do it better are welcome). Eventually, you just need to add the following lines to your code:

def cubic_zero(x,a,xn,const):
    global xmin
    return (a*(x-xmin)**2*(x-xn)+const)

and

xmin=xdata[0]
popt2, pcov2 = scipy.optimize.curve_fit(cubic_zero, xdata, ydata)
y_modelled2 = np.array([cubic_zero(x, *popt2) for x in xdata])

print(popt2)

plt.plot(xdata, y_modelled2, color='#ee9900',linestyle="--")

providing

>>>[ 0.01429367  7.63190327  2.92604132]

and

With zero slope fit

0
votes

This solution uses scipy.optimize.leastsq. Using the self made residuals function, there is actually no need to pass xmin as additional parameter to the fit. The fit function is as in the other post and therefore has no necessity for constraints. This looks like:

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import leastsq

def cubic_fit_with_noise(x, p1, p2, p3, p4):
    return p1 + p2*x + p3*x**2 + p4*x**3 + .2*(1-2*np.random.rand())

def cubic_zero(x,a,xn,const, xmin):
    return (a*(x-xmin)**2*(x-xn)+const)


def residuals(params, dataX,dataY):
    a,xn,const=params
    xmin=dataX[0]
    dist=np.fromiter( (y-cubic_zero(x,a,xn,const, xmin) for x,y in zip(dataX,dataY)), np.float)
    return dist

xdata = np.linspace(.5,10.5,100)
ydata = np.fromiter( (cubic_fit_with_noise (x, 2, 0.4, -.2,0.02) for x in xdata), np.float )

# scipy curve fitting with leastsq:
initialGuess=[.3,.3,.3]
popt2, pcov2, info2, msg2, ier2 = leastsq(residuals,initialGuess, args=(xdata, ydata), full_output=True)

fullparams=np.append(popt2,xdata[0])
y_modelled2 = np.array([cubic_zero(x, *fullparams) for x in xdata])

print(popt2) 
print(pcov2)
print np.array([ -popt2[0]*xdata[0]**2*popt2[1]+popt2[2],popt2[0]*(xdata[0]**2+2*xdata[0]*popt2[1]),-popt2[0]*(2*xdata[0]+popt2[1]),popt2[0]  ])

plt.plot(xdata, ydata, 'bo')
plt.plot(xdata, y_modelled2, 'r-')
plt.show()

and provides:

>>>[ 0.01710749  7.69369653  2.38986378]
>>>[[  4.33308441e-06   5.61402017e-04   2.71819763e-04]
    [  5.61402017e-04   1.10367937e-01   5.67852980e-02]
    [  2.71819763e-04   5.67852980e-02   3.94127702e-02]]
>>>[ 2.35695882  0.13589672 -0.14872733  0.01710749]

Image upload does not work at the moment ... for whatever reason but result is the same as in the other post