fitting_toolkit.py

Go to the documentation of this file.
00001 """Hessian and Levenberg-Marquardt Curve Fitting Package with resampling capability.
00002 
00003 This is loosely derived from the information in 'Numerical Recipes' 2nd Ed. by Press, Flannery, Teukolsky and Vetterling.
00004 Implementation by Marcus H. Mendenhall, Vanderbilt University Free Electron Laser Center, Nashville, TN, USA
00005 Implemented around 3 December, 2002.
00006 This implementation is in the public domain, although some of its algorithms 
00007 may be at least in part owned by the 'Numerical Recipes' people.
00008 Check with them before using this in commercial software.
00009 
00010 To use it, subclass the fit class and, at minimum, define function(self, params, x) as below
00011 
00012     from fitting_toolkit import fit
00013     class myfit(fit):
00014         "a really terrible way to fit a polynomial to data!"
00015         def function(self, params, x):
00016             return params[0]+params[1]*x+params[2]*x*x
00017 
00018 This function takes a Numeric array of parameters 'params', and a Numeric array of independent variable values 'x'.  If the 
00019 independent variable is a scalar, the function will be passed a 1-d array.  If the independent variable is a vector,
00020 the function will be passed a 2-d array with each _row_ holding one component of the vector, so x[0] would be x, x[1] would be y, etc.
00021 
00022 Then, instantiate this class, give it initial parameter estimates, set the autoderivative step size if numerical derivatives
00023 are being used (the default value is 0.001*parameter values), add points, and repeatedly call hessian_compute_fit until 
00024 fitting has converged (which, with Hessian fitting, it often won't!)
00025     
00026     x=myfit()
00027     x.set_initial_params([1.,1.,1.])
00028     x.auto_deriv_steps((1,1,1))
00029     
00030     x.add_point(0, 2)
00031     x.add_point(1, 5)
00032     x.add_point(2, 10)
00033     x.add_point(3, 17)
00034     x.add_point(4, 26.1)
00035     
00036     print "***Start test fit***" 
00037     for i in range(5):
00038         x.hessian_compute_fit()
00039         print x.fitmat, x.fitvector, x.funcparams, x.funcvals[:x.pointcount], sqrt(x.chi2/x.pointcount)
00040 
00041 To add more than one point at a time, do
00042 
00043     x.add_points(<array of independent variables>, <array of dependent variables>, x_transpose=<0 | 1>)
00044 
00045 If the independent variable is a vector and the independent array has the components of the vector in columns, 
00046 so each column is one vector, the default x_transpose=0 is correct.  
00047 If the components are along rows, and each row is a vector, use x_transpose=1.
00048 
00049 For a more robust fitter, use the Levenberg-Marquardt stepping as follows e.g.  (this example shows one convergence checker):
00050     
00051     savechi2=1e30       
00052     acceptcount=0
00053     for i in range(maxloops):
00054         reject=x.lm_compute_fit(trace=1)
00055         chi2=sqrt(x.chi2/x.pointcount)      
00056         if not reject:
00057             if chi2/savechi2 >0.99 and acceptcount > 5:     break
00058             else:   
00059                 savechi2=chi2
00060                 acceptcount += 1
00061         elif chi2/savechi2 > 1.01: #only penalize for really bad steps, not  noise!
00062             acceptcount=0
00063     
00064     print x.funcparams, chi2
00065 
00066 For more advanced fitting (variable weight, analytic derivatives, etc.) other methods can be overridden.  Note that
00067 except for the function() override, all other function use raw access to the internal variables of the fitter.  Since the data are
00068 stored in arrays which are larger than the number of points, one must always calculate based on
00069 self.xarray[:,self.pointcount] and self.yarray[:self.pointcount] to return data of the right length.
00070 
00071 If weighted fitting is desired, include the function weight_func(self) in the subclass, \e e.g. 
00072     def weight_func(self): #throw out points with < 5 a/d counts in the bin.  
00073         #This is a bad way to do this if lots of points are being tossed, 
00074         #but doesn't cost much for the expected case that it doesn't happen often
00075         return Numeric.greater(self.yarray[:self.pointcount], 5.0)      
00076 
00077 If analytic derivatives are desired, do, \e e.g. 
00078     class gauss_fit_2d(fit):
00079         def function(self, p, r):
00080             #print p
00081             z0, a, xmu, xsigma, ymu, ysigma = p
00082             xsigi=-1.0/(2.0*xsigma**2)
00083             ysigi=-1.0/(2.0*ysigma**2)
00084             return z0+a*Numeric.exp(xsigi*(r[0]-xmu)**2+ysigi*(r[1]-ymu)**2)
00085     
00086         def derivs(self): 
00087             #analytic derivatives for a 2-d gaussian
00088             #z0+a*exp( -(x-xmu)**2/(2*xsig**2) -(y-ymu)**2/(2.0*ysig**2))
00089             z0, a, xmu, xsigma, ymu, ysigma = self.funcparams
00090             n=self.pointcount
00091             x=self.xarray[0,:n]
00092             y=self.xarray[1,:n]
00093             xsigi=-1.0/(2.0*xsigma**2)
00094             ysigi=-1.0/(2.0*ysigma**2)
00095             dx=x-xmu
00096             dx2=dx*dx
00097             dy=y-ymu
00098             dy2=dy*dy
00099             expfact=Numeric.exp(xsigi*dx2+ysigi*dy2)
00100             z=a*expfact
00101             
00102             dd = zeros((n, 6), self.atype)
00103             dd[:,0]=1.0
00104             dd[:,1]=expfact
00105             dd[:,2]=(-2.0*xsigi)*(dx*z)
00106             dd[:,3]=(-2.0*xsigi/xsigma)*(dx2*z)
00107             dd[:,4]=(-2.0*ysigi)*(dy*z)
00108             dd[:,5]= (-2.0*ysigi/ysigma)*(dy2*z)
00109             
00110             return dd   
00111 
00112 """
00113 
00114 _rcsid="$Id: fitting_toolkit.py,v 1.18 2006/08/08 02:00:19 mendenhall Exp $"
00115 
00116 try:
00117     import numpy as Numeric
00118     import numpy
00119     numeric_float=Numeric.float64
00120     from numpy import linalg
00121     solve_linear_equations=linalg.solve
00122     def  singular_value_decomposition(mat):
00123         return  linalg.svd(mat, full_matrices=0, compute_uv=1)
00124         
00125     matinverse=linalg.inv
00126     from numpy import dot, zeros, transpose, array, array_str
00127 except:
00128     import Numeric
00129     numeric_float=Numeric.Float64
00130     from LinearAlgebra import solve_linear_equations, inverse as matinverse, singular_value_decomposition
00131     from Numeric import dot, zeros, transpose, array, array_str
00132 
00133 import random
00134 
00135 
00136 import math
00137 from math import sqrt
00138 import copy
00139 import operator
00140 
00141 ##\file
00142 ## Provides the analysis.fitting_toolkit package.
00143 ##\package analysis.fitting_toolkit
00144 ## Hessian and Levenberg-Marquardt Curve Fitting Package with resampling capability.
00145 #\verbatim version $Id: fitting_toolkit.py,v 1.18 2006/08/08 02:00:19 mendenhall Exp $ \endverbatim
00146 #This is loosely derived from the information in 'Numerical Recipes' 2nd Ed. by Press, Flannery, Teukolsky and Vetterling.
00147 #Implementation by Marcus H. Mendenhall, Vanderbilt University Free Electron Laser Center, Nashville, TN, USA
00148 #Implemented around 3 December, 2002.
00149 #This implementation is in the public domain, although some of its algorithms may be at least in part owned by the 'Numerical Recipes' people.
00150 #Check with them before using this in commercial software.
00151 #
00152 #To use it, subclass the fit class and, at minimum, define function(self, params, x) as below
00153 #\code
00154 #   from fitting_toolkit import fit
00155 #   class myfit(fit):
00156 #       "a really terrible way to fit a polynomial to data!"
00157 #       def function(self, params, x):
00158 #           return params[0]+params[1]*x+params[2]*x*x
00159 #\endcode
00160 #This function takes a Numeric array of parameters \a params, and a Numeric array of independent variable values \a x.  If the 
00161 #independent variable is a scalar, the function will be passed a 1-d array.  If the independent variable is a vector,
00162 #the function will be passed a 2-d array with each _row_ holding one component of the vector, so x[0] would be x, x[1] would be y, etc.
00163 #
00164 #Then, instantiate this class, give it initial parameter estimates, set the autoderivative step size if numerical derivatives
00165 #are being used (the default value is 0.001*parameter values), add points, and repeatedly call hessian_compute_fit until 
00166 #fitting has converged (which, with Hessian fitting, it often won't!)
00167 #\code  
00168 #   x=myfit()
00169 #   x.set_initial_params([1.,1.,1.])
00170 #   x.auto_deriv_steps((1,1,1))
00171 #   
00172 #   x.add_point(0, 2)
00173 #   x.add_point(1, 5)
00174 #   x.add_point(2, 10)
00175 #   x.add_point(3, 17)
00176 #   x.add_point(4, 26.1)
00177 #   
00178 #   print "***Start test fit***" 
00179 #   for i in range(5):
00180 #       x.hessian_compute_fit()
00181 #       print x.fitmat, x.fitvector, x.funcparams, x.funcvals[:x.pointcount], sqrt(x.chi2/x.pointcount)
00182 #\endcode
00183 #To add more than one point at a time, do
00184 #\code
00185 #   x.add_points(<array of independent variables>, <array of dependent variables>, x_transpose=<0 | 1>)
00186 #\endcode
00187 #If the independent variable is a vector and the independent array has the components of the vector in columns, 
00188 #so each column is one vector, the default x_transpose=0 is correct.  
00189 #If the components are along rows, and each row is a vector, use x_transpose=1.
00190 #
00191 #For a more robust fitter, use the Levenberg-Marquardt stepping as follows (\a e.g. ) (this example shows one convergence checker):
00192 #\code  
00193 #   savechi2=1e30       
00194 #   acceptcount=0
00195 #   for i in range(maxloops):
00196 #       reject=x.lm_compute_fit(trace=1)
00197 #       chi2=sqrt(x.chi2/x.pointcount)      
00198 #       if not reject:
00199 #           if chi2/savechi2 >0.99 and acceptcount > 5:     break
00200 #           else:   
00201 #               savechi2=chi2
00202 #               acceptcount += 1
00203 #       elif chi2/savechi2 > 1.01: #only penalize for really bad steps, not  noise!
00204 #           acceptcount=0
00205 #   
00206 #   print x.funcparams, chi2
00207 #\endcode
00208 #For more advanced fitting (variable weight, analytic derivatives, etc.) other methods can be overridden.  Note that
00209 #except for the function() override, all other function use raw access to the internal variables of the fitter.  Since the data are
00210 #stored in arrays which are larger than the number of points, one must always calculate based on
00211 #self.xarray[:,self.pointcount] and self.yarray[:self.pointcount] to return data of the right length.
00212 #
00213 #If weighted fitting is desired, include the function weight_func(self) in the subclass, \e e.g.  \code
00214 #   def weight_func(self): #throw out points with < 5 a/d counts in the bin.  
00215 #       #This is a bad way to do this if lots of points are being tossed, 
00216 #       #but doesn't cost much for the expected case that it doesn't happen often
00217 #       return Numeric.greater(self.yarray[:self.pointcount], 5.0)      
00218 #\endcode
00219 #If analytic derivatives are desired, do, \e e.g. \code
00220 #   class gauss_fit_2d(fit):
00221 #       def function(self, p, r):
00222 #           #print p
00223 #           z0, a, xmu, xsigma, ymu, ysigma = p
00224 #           xsigi=-1.0/(2.0*xsigma**2)
00225 #           ysigi=-1.0/(2.0*ysigma**2)
00226 #           return z0+a*Numeric.exp(xsigi*(r[0]-xmu)**2+ysigi*(r[1]-ymu)**2)
00227 #   
00228 #       def derivs(self): 
00229 #           #analytic derivatives for a 2-d gaussian
00230 #           #z0+a*exp( -(x-xmu)**2/(2*xsig**2) -(y-ymu)**2/(2.0*ysig**2))
00231 #           z0, a, xmu, xsigma, ymu, ysigma = self.funcparams
00232 #           n=self.pointcount
00233 #           x=self.xarray[0,:n]
00234 #           y=self.xarray[1,:n]
00235 #           xsigi=-1.0/(2.0*xsigma**2)
00236 #           ysigi=-1.0/(2.0*ysigma**2)
00237 #           dx=x-xmu
00238 #           dx2=dx*dx
00239 #           dy=y-ymu
00240 #           dy2=dy*dy
00241 #           expfact=Numeric.exp(xsigi*dx2+ysigi*dy2)
00242 #           z=a*expfact
00243 #           
00244 #           dd = zeros((n, 6), self.atype)
00245 #           dd[:,0]=1.0
00246 #           dd[:,1]=expfact
00247 #           dd[:,2]=(-2.0*xsigi)*(dx*z)
00248 #           dd[:,3]=(-2.0*xsigi/xsigma)*(dx2*z)
00249 #           dd[:,4]=(-2.0*ysigi)*(dy*z)
00250 #           dd[:,5]= (-2.0*ysigi/ysigma)*(dy2*z)
00251 #           
00252 #           return dd   
00253 #\endcode
00254 
00255 ##
00256 ##The main class which is the host for all the fitting techniques
00257 class fit:
00258     ## Create the fitter, and give it a hint as to the size blocks to allocate for data arrays.
00259     def __init__(self, pointhint=1000):
00260         "create the fitter, and give it a hint as to the size blocks to allocate for data arrays"
00261         self.pointhint=pointhint
00262         self.pointcount=0
00263         self.arraysexist=0
00264         self.firstpass=1
00265         self.atype=self.DefaultArrayType()
00266     
00267     ##
00268     ## Override this function if you want to fit in single precision, \e e.g.  
00269     # Default is numeric_float i.e. double precision
00270     def DefaultArrayType(self):
00271         """override this function if you want to fit in single precision, \e e.g.
00272             Default is numeric_float i.e. double precision"""
00273         return numeric_float
00274     
00275     ##
00276     ## Set up initial values for function parameters to get the fit off to a good start.
00277     # 
00278     # \param params A array of starting values. The array has one value per free parameter in the fit, and must be ordered as it will be used by function()
00279     def set_initial_params(self, params):
00280         "set up initial values for function parameters to get the fit off to a good start"
00281         self.funcparams=array(params, self.atype)
00282         self.firstpass=1    #probably need to recompute function if we did this
00283         self.param_count=len(params)
00284         if not hasattr(self, "deriv_step"):
00285             self.deriv_step=self.funcparams*0.001
00286     
00287     ##
00288     ## Define the step sizes to be used by auto_derivs()
00289     # \param deriv_step an indexable object with the same number of elements as free parameters.
00290     #                                       
00291     def auto_deriv_steps(self, deriv_step):
00292         "set the step sizes used for each parameter if numerical derivatives are to be used"
00293         self.deriv_step=array(deriv_step, self.atype)
00294 
00295     ##
00296     ##  Make sure arrays are big enough to add the specified number of points
00297     # \param sample_x A sample abscissa vector or scalar to determine the shape of the arrays to build.
00298     # \param points_to_add The number of points to be appended in the next operation (at least).
00299     def check_arrays(self, sample_x, points_to_add=1):
00300         "make sure arrays are big enough to add the specified number of points"
00301         if not self.arraysexist:
00302             if operator.isSequenceType(sample_x):
00303                 self.arg_count=len(sample_x)
00304             else:
00305                 self.arg_count=1
00306             self.frozen=zeros(self.param_count)
00307             self.xarray=zeros((self.arg_count, self.pointhint), self.atype)
00308             self.yarray=zeros(self.pointhint, self.atype )
00309             self.currentlen=self.pointhint
00310             self.arraysexist=1
00311         
00312         if self.pointcount+points_to_add  >= self.currentlen:
00313             #expand arrays to accept more data
00314             realmax = max(2*self.currentlen, self.currentlen+2*points_to_add) 
00315             xarray=zeros((self.arg_count, realmax ), self.atype)
00316             yarray=zeros(realmax, self.atype)
00317             xarray[:, :self.pointcount]=self.xarray[:,:self.pointcount]
00318             yarray[:self.pointcount]=self.yarray[:self.pointcount]
00319             self.xarray=xarray
00320             self.yarray=yarray
00321             self.currentlen=realmax 
00322     
00323     ##
00324     ## Set the indexed parameter to be frozen in the fit.
00325     #
00326     # Freezing the parameter cuases its row and column to be set to the identity in the design matrix.   
00327     # It can be used to prevent very unstable parameters in a fit from drifting away from their starting values until more stable
00328     # ones have been allowed to converge.  
00329     def freeze_parameter(self, param_index):        
00330         self.frozen[param_index]=1
00331     
00332     ##
00333     ## Unfreeze a previously-frozen parameter.  See freeze_parameter()
00334     #
00335     def unfreeze_parameter(self, param_index):
00336         self.frozen[param_index]=0  
00337     
00338     ##
00339     # Add one data point to the fit.
00340     #\param xvector A scalar (for fits with a single independent variable) or a vector (multiple independent variables)
00341     #\param yval A scalar value for the function at the specified point
00342     #
00343     def add_point(self, xvector, yval):
00344         self.check_arrays(xvector)
00345         n=self.pointcount           
00346         if self.arg_count > 1:
00347             self.xarray[:,n]=xvector
00348         else:
00349             self.xarray[0,n]=xvector
00350         self.yarray[n]=yval
00351         self.pointcount=self.pointcount+1
00352 
00353     ##
00354     ## Add multiple points to the fit simultaneously.
00355     #\param xvector A 1-d or 2-d array of abscissas
00356     #\param yval A 1-d array of ordinates
00357     #\param x_transpose If x_transpose is False or 0, xvector has one point per column.  If True, xvector has one point per row.
00358     def add_points(self, xvector, yval, x_transpose=0):
00359         n=self.pointcount   
00360         
00361         if x_transpose:
00362             xv=Numeric.transpose(array(xvector))
00363         else:
00364             xv=array(xvector)
00365         
00366         n1=xv.shape[-1]
00367         if len(xv.shape) == 1:  
00368             self.check_arrays(1.0, n1) #elements of x are scalar
00369         else:
00370             self.check_arrays(xv[:,0], n1) #elements of x are vector
00371             
00372         if self.arg_count > 1:
00373             self.xarray[:,n:n+n1]=xv
00374         else:
00375             self.xarray[0,n:n+n1]=xv
00376         self.yarray[n:n+n1]=yval
00377         self.pointcount=self.pointcount+n1
00378     
00379     ##
00380     ## compute the set of values for the function evaluated at the positions provided, and with the paramaters provided.
00381     #\param xvals An array of abscissas
00382     #\param params An array of the parameters to be used.  If None, use whatever the current fit's funcparams are.
00383     #\param x_transpose If False or 0, each point is a column in xvals.  If True, each point is a row in xvals.
00384     def compute_funcvals(self, xvals=None, params=None, x_transpose=0):
00385         "evaluate the fitter's function, providing some convenient glue for passing in data arrays of various shapes"
00386         if params is None:
00387             params=self.funcparams
00388 
00389         if xvals is None:
00390             n=self.pointcount
00391             if self.arg_count > 1:
00392                 xvals=self.xarray[:,:n]
00393             else:
00394                 xvals=self.xarray[0, :n]
00395         elif x_transpose:
00396             xvals = Numeric.transpose(xvals) #allow array sideways to be handled
00397         
00398         return self.function(params, xvals)
00399     
00400     ## 
00401     ## Compute the numeric derivative of the target function with respect to the indexed parameter, using a stepsize of delta_x
00402     #\param param_index Index of the parameter in funcparams.
00403     #\param delta_x Full size of step to use in the numerical differentiation.  
00404     def numeric_deriv(self, param_index, delta_x):
00405         """numerically differentiate the fitter's function with respect
00406          to the indexed parameter, using the specified step size"""
00407         delta=zeros(self.param_count, self.atype)
00408         delta[param_index]=delta_x/2.0
00409         newrow=((self.compute_funcvals(params=self.funcparams+delta)-
00410                 self.compute_funcvals(params=self.funcparams-delta))/delta_x)
00411         return newrow
00412     
00413     ##
00414     #Compute derivatives by default numerical differentiation for all non-frozen parameters,
00415     # This must be overridden for fits which have analytic derivatives.  See example in class documentation.
00416     #Note in particular that the array returned is organized with all the values for a point in each row.  The columns correspond to the parameters.
00417     def derivs(self): 
00418         "default deriv is automatic numeric derivatives, override this for analytic derivatives"
00419         n=self.pointcount
00420         fxarray=zeros((n, self.param_count), self.atype)
00421         for i in range(self.param_count):
00422             if not self.frozen[i]:
00423                 fxarray[:,i]=self.numeric_deriv(i, self.deriv_step[i])
00424         return fxarray
00425     
00426     ##
00427     #return the weight values to use for each point in this fit.  If a variable exists in the class 'explicit_weightlist    ', return this.
00428     #If it does not exist, return 1.0 as the weight.  
00429     #
00430     #This method should be overridden if anything fancy is being done with weights (correlated fits, etc.) 
00431     def weight_func(self):
00432         "default weight is 1 or, if explicit_weightlist exists, that is returned"
00433         if not hasattr(self, "explicit_weightlist") or self.explicit_weightlist is None:
00434             return 1.0
00435         else:
00436             return self.explicit_weightlist
00437     
00438     ##
00439     ## This prepares the fitter for doing resampling (bootstrapping) to estimate the true shape of the chi^2 surface.
00440     # Makes shadow copies of the main data arrays so they resampled data
00441     def setup_resampling(self):
00442         "setup_resampling() caches the 'real' arrays of x and y, so they can be resampled for bootstrapping, and seeds a random generator"
00443         assert not hasattr(self, "saved_xarray"), "Don't even think of initializing the resampling more than once!"
00444         self.saved_xarray=array(self.xarray[:,:self.pointcount]) #these must be copies, not slices!
00445         self.saved_yarray=array(self.yarray[:self.pointcount]) 
00446         self.initialize_random_generator()
00447     
00448     ##
00449     ## This resets the fitter back to non-resampling mode so all the original data are available.
00450     # Removes shadow copies of the main data arrays so they resampled data
00451     def clear_resampling(self):
00452         "clear_resampling() removes resampling machinery"
00453         self.xarray=self.saved_xarray
00454         self.yarray=self.saved_yarray
00455         self.firstpass=1
00456         del self.saved_xarray, self.saved_yarray
00457 
00458     ##
00459     ## Initialize the random number generator to be used in resampling.  Override this for non-default radnom generators.
00460     def initialize_random_generator(self):
00461         "initialize the random number generator to be used in resampling.  Overide to use other than random.Random. r250_521 is much faster and better!"
00462         self.randoms=random.Random()
00463     
00464     ##
00465     ##return a list of \a count randoms, possibly by an efficient technique, if the generator supports it.  
00466     def get_random_list(self, count):
00467         "return a list of count randoms on [0,1) for resampling.  Override to pick a different random generator"
00468         r=self.randoms.random
00469         return array([r() for i in range(count)])
00470     
00471     ##
00472     ## Take a new sample of the data for  resampling/bootstrapping.  
00473     ## can be put in the main arrays.
00474     def resample(self):
00475         "resample() randomly draws a set of points equal in size to the original set from the cached data for bootstrapping"
00476         assert hasattr(self, "saved_xarray"), "resampling not set up yet.  Call setup_resampling() first."
00477         ranlist=Numeric.floor(self.get_random_list(self.pointcount)*self.pointcount).astype(Numeric.Int)
00478         self.xarray=Numeric.take(self.saved_xarray, ranlist, -1) #take columns since vectors lie this way
00479         self.yarray=Numeric.take(self.saved_yarray, ranlist)
00480         self.firstpass=1
00481     
00482     ## 
00483     ## Evaluate the weight function, and flag whether the weights are scalar or not.
00484     ## (Internal use mostly)
00485     def set_weights(self):
00486         self.weights=self.weight_func()
00487         self.scalar_weights = (type(self.weights) is type(1.0) or len(self.weights.shape)==1)       
00488     
00489     ##
00490     ## Left-multiply a y-vector or function matrix by the weights.  If the weights are special (sparse, etc.), override this. 
00491     def weights_multiply(self, right_array):
00492         "left multiply  vector or matrix by weights... override if weights are sparse matrix, etc."
00493         if self.scalar_weights:
00494             if len(right_array.shape)==1:
00495                 return right_array*self.weights
00496             else:
00497                 r=copy.copy(right_array)
00498                 for k in range(self.param_count):
00499                     r[k]*=self.weights
00500                 return r
00501         else:
00502             return dot(self.weights, right_array)   
00503 
00504     ##
00505     ## Evaluate residuals, and compute proper chi^2 with weights.       
00506     def compute_chi2(self):
00507         self.residuals=self.yarray[:self.pointcount]-self.funcvals
00508         self.chi2=float(dot(self.residuals,self.weights_multiply(self.residuals)))
00509         self.reduced_chi2=float(self.chi2/(self.pointcount-self.param_count))
00510 
00511     ##
00512     ## Take one inverse-Hessian or Levenberg-Marquardt step in a fit.  
00513     #\param lm_lambda The Levenberg-Marquardt  lambda parameter
00514     def hessian_compute_fit(self, lm_lambda=None): 
00515         "take one Hessian fitting step  if lm_lambda undefined, otherwise make Levenberg-Marquardt adjustment"
00516         
00517         n=self.pointcount       
00518         fxarray=self.derivs()
00519 
00520         self.set_weights()
00521         fxwarray=self.weights_multiply(Numeric.transpose(fxarray))
00522         self.fitmat=dot(fxwarray, fxarray)
00523         
00524         if lm_lambda is not None: #make Levenberg-Marquardt correction to inverse-covariance matrix
00525             for i in range(self.param_count):
00526                 self.fitmat[i,i]*=(1.0+lm_lambda)
00527                 
00528         for i in range(self.param_count): #handle frozen parameters
00529             if self.frozen[i]:
00530                 self.fitmat[i,:]=0.0
00531                 self.fitmat[:,i]=0.0
00532                 self.fitmat[i,i]=1.0
00533 
00534         if(self.firstpass):
00535             self.funcvals=self.compute_funcvals()
00536             self.firstpass=0
00537 
00538         self.fitvector=solve_linear_equations(self.fitmat, dot(fxwarray, self.yarray[:n]-self.funcvals) )
00539         self.funcparams=self.funcparams+self.fitvector*(1-self.frozen)
00540         self.funcvals=self.compute_funcvals()
00541         self.compute_chi2()         
00542 
00543     ##
00544     ## Take one inverse-Hessian step, using SVD to control funny steps
00545     #\param damping A damping factor.  The computed step size is multiplied by this before taking the step.
00546     def svd_hessian_compute_fit(self, damping=None): 
00547         "take one Hessian fitting step  using singular-value-decomposition"
00548         
00549         try:
00550             n=self.pointcount       
00551             self.set_weights()
00552     
00553             if not self.scalar_weights:
00554                 raise exceptions.AssertionError, "SVD solutions require, for now, scalar weights"
00555                             
00556             sigi=Numeric.sqrt(self.weights)
00557             if not operator.isSequenceType(sigi):
00558                 design=self.derivs()*float(sigi)
00559             else:
00560                 design=self.derivs()*sigi[:,Numeric.NewAxis] #w should be a column vector
00561                         
00562             if(self.firstpass):
00563                 self.funcvals=self.compute_funcvals()
00564                 self.firstpass=0
00565     
00566             u, w, v = singular_value_decomposition(design)
00567             w=self.singular_value_edit(w)
00568             for i in range(self.param_count):
00569                 if w[i] != 0:
00570                     w[i] = 1.0/w[i]
00571             b=(self.yarray[:n]-self.funcvals)*sigi
00572             self.fitvector=Numeric.sum(dot(Numeric.transpose(u*w),b)*Numeric.transpose(v), -1)
00573             self.svd_u=u
00574             self.svd_v=v
00575             self.svd_winv=w
00576         except:
00577             import traceback
00578             traceback.print_exc()
00579             raise
00580                     
00581         if damping is None:
00582             self.funcparams=self.funcparams+self.fitvector*(1-self.frozen)
00583         else:
00584             self.funcparams=self.funcparams+self.fitvector*(1-self.frozen)*damping
00585             
00586         self.funcvals=self.compute_funcvals()
00587         self.compute_chi2()         
00588     ##
00589     ##Edit the singular values.  By default, look at the variable svd_tolerance, and if it exists
00590     #clip the singular values to that factor less than the largest.  If the variable does not exist, use 10^-10     
00591     def singular_value_edit(self, values):
00592         "zap unreasonably small singular values based on svd_tolerance"
00593         if hasattr(self, "svd_tolerance"):
00594             tol=self.svd_tolerance
00595         else:
00596             tol=1e-10
00597         clip=max(values)*tol
00598         for i in range(len(values)):
00599             if values[i]<clip: values[i]=0.0
00600         return values 
00601         
00602     ##
00603     ##This is just a shortcut so a class can select which type of kernel is used. 
00604     # If you directly call one of the specific fitters (hessian or svd), you may never use this.
00605     def compute_fit(self, lm_lambda=None):
00606         "override this to select which fitting kernel is being used"
00607         self.hessian_compute_fit(lm_lambda)
00608     
00609     ##
00610     ## Return the variance-covariance matrix for the fit.
00611     def covariance_matrix(self):
00612         "return the variance-covariance matrix resulting from this fit"
00613         return matinverse(self.fitmat)
00614     
00615     ##
00616     ## Return the variance-covariance matrix for the fit if svd fitting was used.
00617     def svd_covariance_matrix(self):
00618         "return the variance-covariance matrix resulting from this fit when svd stepping was used"
00619         return dot(self.svd_v, Numeric.transpose(self.svd_winv**2*self.svd_v))
00620     
00621     ##
00622     ##One choice of a schedule for adjusting the Levenberg-Marquardt parameter.  Not the only one!
00623     def lm_lambda_recipe(self, old_chi2=None, new_chi2=None):
00624         "adjust the Levenberg-marquardt lambda parameter based on the old and new chi^2 values"
00625         if old_chi2 is None:
00626             self.lm_lambda=0.001
00627         elif old_chi2 < new_chi2:
00628             if new_chi2/old_chi2 < 1.1:
00629                 self.lm_lambda *=10.0 #on a slightly bad fit (bobbling), don't penalize too badly
00630             else:
00631                 self.lm_lambda = max(self.lm_lambda*10.0, 1.0) #always jump to at least 1 on a bad fit
00632         else:
00633             self.lm_lambda *= 0.1
00634     
00635     ##
00636     ## Take an L-M fit step, and adjust the L-M parameter based on the behavior of chi^2.
00637     ##\param trace If True (or non-zero), print out diagnostics on each step.
00638     def lm_fit_step(self, trace=0): 
00639         "take one Levenberg-Marquardt step and adjust lambda based on chi^2"
00640         n=self.pointcount
00641         if self.firstpass: #do an initial measurement of chi2 first time through
00642             self.lm_lambda_recipe()
00643             self.funcvals=self.compute_funcvals()
00644             self.set_weights()
00645             self.compute_chi2()
00646             self.firstpass=0 #we've done all the first-pass stuff now
00647             
00648         save_chi2=self.chi2
00649         save_params=self.funcparams
00650         save_vals=self.funcvals
00651         self.hessian_compute_fit(self.lm_lambda)
00652         self.lm_lambda_recipe(save_chi2, self.chi2)
00653         if save_chi2 < self.chi2: #ouch, bad step, back up
00654             if trace:
00655                 print "rejected step: old chi2=%.3e, new chi2=%.3e, lambda=%.3e" % (save_chi2, self.chi2, self.lm_lambda)
00656                 print "params =", self.funcparams
00657             self.funcparams=save_params
00658             self.funcvals=save_vals
00659             self.chi2=save_chi2
00660             return 1 #flag rejected step
00661         else:
00662             if trace:
00663                 print "accepted step: old chi2=%.3e, new chi2=%.3e, lambda=%.3e" % (save_chi2, self.chi2, self.lm_lambda)
00664                 print "params =", self.funcparams
00665             return 0 #flag accepted step
00666 
00667     ##
00668     ## A lot like lm_lambda_recipe but for svd fits.  It  adjusts the damping depending on the behavior of chi^2
00669     ##\param old_chi2 The value of chi^2 from the previous fit step
00670     ##\param new_chi2 The value of chi^2 from the current fit step.
00671     def svd_damping_recipe(self, old_chi2=None, new_chi2=None):
00672         "adjust the damping parameter based on the old and new chi^2 values"
00673         if old_chi2 is None:
00674             self.svd_damping=1.0
00675         elif old_chi2 < new_chi2:
00676             if new_chi2/old_chi2 < 1.1:
00677                 self.svd_damping *=0.8 #on a slightly bad fit (bobbling), don't penalize too badly
00678             else:
00679                 self.svd_damping  *= 0.25 
00680         else:
00681             self.svd_damping   = min(self.svd_damping *2.0, 1.0)
00682 
00683     ##
00684     ## A lot like lm_fit_step
00685     def damped_svd_fit_step(self, trace=0): 
00686         "take one svd-hessian step and adjust damping  based on chi^2"
00687         n=self.pointcount
00688         if self.firstpass: #do an initial measurement of chi2 first time through
00689             self.svd_damping_recipe()
00690             self.funcvals=self.compute_funcvals()
00691             self.set_weights()
00692             self.compute_chi2()
00693             self.firstpass=0 #we've done all the first-pass stuff now
00694             
00695         save_chi2=self.chi2
00696         save_params=self.funcparams
00697         save_vals=self.funcvals
00698         try:
00699             self.svd_hessian_compute_fit(self.svd_damping)
00700         except KeyboardInterrupt:
00701             raise
00702         except:
00703             self.chi2=2.0*save_chi2 #just consider a failure to be a bad step
00704         self.svd_damping_recipe(save_chi2, self.chi2)
00705         if save_chi2 < self.chi2: #ouch, bad step, back up
00706             if trace:
00707                 print "rejected step: old chi2=%.3e, new chi2=%.3e, lambda=%.3e" % (save_chi2, self.chi2, self.svd_damping)
00708                 print "params =", self.funcparams
00709             self.funcparams=save_params
00710             self.chi2=save_chi2
00711             self.funcvals=save_vals
00712             return 1 #flag rejected step
00713         else:
00714             if trace:
00715                 print "accepted step: old chi2=%.3e, new chi2=%.3e, lambda=%.3e" % (save_chi2, self.chi2, self.svd_damping)
00716                 print "params =", self.funcparams
00717             return 0 #flag accepted step
00718 
00719 ##
00720 ## Fit to a function which is a linear combination of basis functions, so no iteration is needed.
00721 class linear_combination_fit(fit):
00722     "fit a linear combination of basis functions"
00723     
00724     ##
00725     ## Set up the fit.  
00726     ##\param funclist a list of functions to be evaluated at the points which will be added to the fit. The functions are evaluated a f(parmindex, x)
00727     # so the function can parametrically depend on its position in the parameter list (for example, it may be an n'th power for position n).
00728     #\param pointhint An estimate of the number of points to be added in a block.
00729     def __init__(self, funclist, pointhint=1000):
00730         fit.__init__(self, pointhint)
00731         self.basis=funclist
00732         self.param_count=len(funclist)
00733 
00734     ##  
00735     ## Evaluate the appropriate linear combination
00736     def function(self, p, x):
00737         if self.firstpass: #first call from hessian_fit is meaningless, all coefficients zero, save time
00738             return 0.0
00739 
00740         sumarray=zeros(x.shape[-1], self.atype)
00741         for i in range(self.param_count):
00742             sumarray+=p[i]*self.basis[i](i, x)
00743         return sumarray
00744     
00745     ##  
00746     ## Compute the (trivial) derivatives for a linear combination
00747     def derivs(self):
00748         if self.firstpass: #may get used more than once if weights are not constant, but no need to recompute
00749             n=self.pointcount
00750             self.funcparams=zeros(self.param_count, self.atype)
00751             dd = zeros((n, self.param_count), self.atype)
00752             if self.arg_count==1:
00753                 x=self.xarray[0,:n]
00754             else:
00755                 x=self.xarray[:,:n]
00756             for i in range(self.param_count):
00757                 dd[:,i]=self.basis[i](i,x)
00758             self.saved_derivs=dd                    
00759         return self.saved_derivs
00760 
00761     ##
00762     ## Given a vector of x values and y values, and optional weights, compute the fit.              
00763     def fit_data(self, xvector, yvector, weightlist=None):
00764         "compute the fit of the supplied data, all in one call"
00765         self.pointcount=0 #reset the fitter for easy reuse once set up
00766         self.firstpass=1
00767         self.add_points(xvector, yvector)
00768         self.explicit_weightlist=weightlist
00769         self.compute_fit() #one pass needed, no fancies, since the system is linear
00770 
00771 ##
00772 ## Fit a polynomial to data.  This is a special case of a linear combination fit, but code is optimized here
00773 class polynomial_fit(fit):
00774     "fit a polynomial of selected degree, with x shifted by xcenter"
00775     
00776     ##
00777     ## Set up the fit
00778     #\param degree The degree of the polynomial (number of parameters=1+degree)
00779     #\param pointhint An estimate of how many points to allocate at a time. 
00780     #\param xcenter Expand the polynomial around this point
00781     def __init__(self, degree, pointhint=1000, xcenter=0.0):
00782         fit.__init__(self, pointhint)
00783         self.xcenter=xcenter
00784         self.param_count=degree+1
00785     
00786     ##
00787     ## compute the polynomial
00788     def function(self, coefs, xlist):
00789         if self.firstpass: #first call from hessian_fit is meaningless, all coefficients zero, save time
00790             return 0.0
00791 
00792         xc=xlist-self.xcenter
00793         y=xc*coefs[-1]
00794         for i in coefs[-2:0:-1]:
00795             y=xc*(i+y)
00796         return y+coefs[0]
00797 
00798     ##
00799     ## Compute the derivatives.
00800     def derivs(self):
00801         if self.firstpass: #may get used more than once if weights are not constant, but no need to recompute
00802             self.funcparams=zeros(self.param_count, self.atype)
00803             n=self.pointcount
00804             dd = zeros((n, self.param_count), self.atype)
00805             dd[:,0]=1.0
00806             xc=self.xarray[0,:n]-self.xcenter
00807             for i in range(1,self.param_count):
00808                     dd[:,i]=dd[:,i-1]*xc
00809             self.saved_derivs=dd            
00810         return self.saved_derivs
00811 
00812     ##
00813     ## Given a vector of x values and y values, and optional weights, compute the fit.              
00814     def fit_data(self, xvector, yvector, weightlist=None, xcenter=0.0):
00815         "compute the fit of the supplied data, all in one call"
00816         self.pointcount=0 #reset the fitter for easy reuse once set up
00817         self.firstpass=1
00818         self.add_points(xvector, yvector)
00819         self.explicit_weightlist=weightlist
00820         self.xcenter=xcenter
00821         self.compute_fit() #one pass needed, no fancies, since the system is linear
00822 
00823 #a few exemplary (and possibly useful?) examples of subclasses of the fitter
00824 
00825 find_peak_fitter=polynomial_fit(2, pointhint=100) #save this for reuse, so find_peak can be called vey quickly
00826 
00827 ##
00828 ##Find a positive peak in a data set, using a robust parabolic fitter.
00829 #
00830 #This looks for the highest channel in \a data and then fits a parabola to the half-height points around it.  This tends to be
00831 # a very stable way to find a peak, even on a somewhat sloping background, and with little knowledge of the real shape.
00832 # It does require that the peak be wide enough that at least 3 points fit between its half-height points.
00833 #
00834 #\param data either a list of ordinates (abscissas assumed to be integers starting at 0) or a list of x,y pairs.
00835 def find_peak(data):
00836     """find a positive peak in data, assuming the background is 0.  
00837     Data can either be a list of (x,y) or just a list of y, in which case integer x is assumed.
00838     find_peak returns xcenter, hwhm, height.  
00839     Algorithm is parabolic fit to data between half-height points around max of data.  
00840     This makes sense if hwhm > 1 channel, so at least 3 points
00841     get included in the fit.  It breaks for peaks narrower than this, 
00842     but in that case, using the highest point is about the best one can do, anyway. 
00843     """
00844     da=array(data, numeric_float) #put it in a well-known format
00845     if type(data[0]) is type(1.0):
00846         x=range(len(data))
00847         y=data
00848     else:
00849         x=data[:,0]
00850         y=data[:,1]
00851     
00852     topchan=Numeric.argmax(y)
00853     topy=y[topchan]
00854     #find half-height points
00855     startx=topchan
00856     while(y[startx] >= 0.5*topy): startx -= 1
00857     endx=topchan
00858     while(y[endx] >= 0.5*topy): endx += 1
00859     
00860     #f=polynomial_fit(2, xcenter=x[topchan])
00861     f=find_peak_fitter
00862     f.fit_data(x[startx:endx+1], y[startx:endx+1], xcenter=x[topchan]) #clears the fitter and does  fit
00863     c,b,a=f.funcparams #a*(x-x0)^2+b*(x-x0)+c
00864     x0=-b/(2.0*a)+x[topchan]
00865     hwhm=math.sqrt(-c/(2.0*a))
00866     return x0, hwhm, c #returns center, hwhm, height
00867             
00868 ##
00869 ##Fit y0+a*exp( -(x-xmu)**2/(2*xsig**2) ) to a data set
00870 class gauss_fit(fit):
00871     "fit a constant baseline + gaussian y=y0+a*exp( -(x-xmu)**2/(2*xsig**2) )"
00872     def function(self, p, r):
00873         z0, a, xmu, xsigma = p
00874         xsigi=-1.0/(2.0*xsigma**2)
00875         return z0+a*Numeric.exp(xsigi*(r-xmu)**2)
00876 
00877     def derivs(self): 
00878         #analytic derivatives for a 1-d gaussian
00879         #z0+a*exp( -(x-xmu)**2/(2*xsig**2) )
00880         z0, a, xmu, xsigma = self.funcparams
00881         n=self.pointcount
00882         x=self.xarray[0,:n]
00883         xsigi=-1.0/(2.0*xsigma**2)
00884         dx=x-xmu
00885         dx2=dx*dx
00886         expfact=Numeric.exp(xsigi*dx2)
00887         z=a*expfact
00888         
00889         dd = zeros((n, 4), self.atype)
00890         dd[:,0]=1.0
00891         dd[:,1]=expfact
00892         dd[:,2]=(-2.0*xsigi)*(dx*z)
00893         dd[:,3]=(-2.0*xsigi/xsigma)*(dx2*z)
00894         
00895         return dd   
00896 
00897 ##
00898 ## Fit z0+a*exp( -(x-xmu)**2/(2*xsig**2) -(y-ymu)**2/(2.0*ysig**2)), an elliptical Gaussian
00899 class gauss_fit_2d(fit):
00900     "fit a constant baseline + gaussian z=z0+a*exp( -(x-xmu)**2/(2*xsig**2) -(y-ymu)**2/(2.0*ysig**2))"
00901     def function(self, p, r):
00902         z0, a, xmu, xsigma, ymu, ysigma = p
00903         xsigi=-1.0/(2.0*xsigma**2)
00904         ysigi=-1.0/(2.0*ysigma**2)
00905         return z0+a*Numeric.exp(xsigi*(r[0]-xmu)**2+ysigi*(r[1]-ymu)**2)
00906 
00907     def derivs(self): 
00908         #analytic derivatives for a 2-d gaussian
00909         #z0+a*exp( -(x-xmu)**2/(2*xsig**2) -(y-ymu)**2/(2.0*ysig**2))
00910         z0, a, xmu, xsigma, ymu, ysigma = self.funcparams
00911         n=self.pointcount
00912         x=self.xarray[0,:n]
00913         y=self.xarray[1,:n]
00914         xsigi=-1.0/(2.0*xsigma**2)
00915         ysigi=-1.0/(2.0*ysigma**2)
00916         dx=x-xmu
00917         dx2=dx*dx
00918         dy=y-ymu
00919         dy2=dy*dy
00920         expfact=Numeric.exp(xsigi*dx2+ysigi*dy2)
00921         z=a*expfact
00922         
00923         dd = zeros((n, 6), self.atype)
00924         dd[:,0]=1.0
00925         dd[:,1]=expfact
00926         dd[:,2]=(-2.0*xsigi)*(dx*z)
00927         dd[:,3]=(-2.0*xsigi/xsigma)*(dx2*z)
00928         dd[:,4]=(-2.0*ysigi)*(dy*z)
00929         dd[:,5]= (-2.0*ysigi/ysigma)*(dy2*z)
00930         
00931         return dd   
00932 ##
00933 ## Fit z0+(a*hwhm**2)/((x-xmu)**2+hwhm**2)
00934 #
00935 #This is a sample of a minimal fitting class which uses numerical derivatives, so all it has to declare is the function.
00936 class cauchy_fit(fit):
00937     "Since class cauchy_fit provides no analytic derivatives, automatic numeric differentiation will be used.  This is a minimal fitter example"
00938     def function(self, p, x):
00939         z0, a, xmu, hwhm = p
00940         return z0+(a*hwhm**2)/((x-xmu)**2+hwhm**2)
00941 ##
00942 ## Fit z0+(a*hwhm**2)/((x-xmu)**2+hwhm**2)**(n/2) where n=2 fits a normal Cauchy/Lorentz
00943 #
00944 class generalized_cauchy_fit(fit):
00945     "generalized_cauchy_fit fits a cauchy-like distribution with a variable exponent in the tails.  exponent=2 is true cauchy."
00946     def function(self, p, x):
00947         z0, a, xmu, hwhm, exponent = p
00948         return z0+a*(hwhm**2/((x-xmu)**2+hwhm**2))**(0.5*exponent)
00949 
00950 if __name__=="__main__":
00951     ##
00952     ## Sample/test code for this module
00953     class myfit(fit):
00954         def function(self, params, x):
00955             return params[0]+params[1]*x+params[2]*x**params[3]
00956     
00957     x=myfit()
00958     x.set_initial_params([1.,1.,1., 3])
00959     x.auto_deriv_steps((.1,.1,.1, 0.01))
00960     
00961     x.add_point(0, 2)
00962     x.add_point(1, 5)
00963     x.add_point(2, 10)
00964     x.add_point(3, 17)
00965     x.add_point(4, 26.1)
00966     x.add_point(5, 36.9)
00967     
00968     print "\n\n***Start nonlinear test fit***" 
00969     for i in range(10):
00970         x.lm_fit_step()
00971         print array_str(x.funcparams, precision=5), array_str(x.funcvals, precision=3), sqrt(x.reduced_chi2)
00972         
00973     print "\n\n***Start polynomial test fit***" 
00974     x=polynomial_fit(2)
00975     x.add_point(0, 2)
00976     x.add_point(1, 5)
00977     x.add_point(2, 10)
00978     x.add_point(3, 17)
00979     x.add_point(4, 26.1)
00980     x.add_point(5, 36.9)
00981     x.compute_fit()
00982     print x.funcparams, x.funcvals, sqrt(x.reduced_chi2)
00983 
00984     print "\n\n***Start svd polynomial test fit with funny weights ***" 
00985     ##
00986     ## More sample/test code for this module
00987     class my_svd_fit(polynomial_fit):
00988         def compute_fit(self, lm_lambda=None):
00989             self.svd_hessian_compute_fit(lm_lambda)
00990         def weight_func(self):
00991             return array(range(self.pointcount), self.atype)**2+2.0
00992         
00993     x=my_svd_fit(2)
00994     x.add_point(0, 2)
00995     x.add_point(1, 5)
00996     x.add_point(2, 10)
00997     x.add_point(3, 17)
00998     x.add_point(4, 26.1)
00999     x.add_point(5, 36.9)
01000     x.compute_fit()
01001     print array_str(x.funcparams, precision=5), array_str(x.funcvals, precision=3), sqrt(x.reduced_chi2)
01002 
01003     print "\n\n***Start svd degenerate function nonlinear test fit ***" 
01004     ##
01005     ## More sample/test code for this module
01006     class my_svd_bad_fit(fit):
01007         def function(self, p, x):
01008             return p[0]*Numeric.exp(-p[1]+p[2]*x) #p[0] and p[1] are degenerate
01009         
01010     x=my_svd_bad_fit()
01011     x.set_initial_params((1.,1.,2.2))
01012     x.add_point(0, 2)
01013     x.add_point(1, 5)
01014     x.add_point(2, 10)
01015     x.add_point(3, 17)
01016     x.add_point(4, 26.1)
01017     x.add_point(5, 36.9)
01018     for i in range(25):
01019         x.damped_svd_fit_step(trace=0)
01020         print array_str(x.funcparams, precision=5), array_str(x.funcvals, precision=3), sqrt(x.reduced_chi2)
01021     print x.svd_covariance_matrix()
01022     
01023     

Generated on Wed Nov 21 10:18:31 2007 for analysis by  doxygen 1.5.4