def test_gls_chi2_full_cov(): model = get_model( StringIO(""" PSRJ J1234+5678 ELAT 0 ELONG 0 DM 10 F0 1 PEPOCH 58000 TNRedAmp -14.227505410948254 TNRedGam 4.91353 TNRedC 45 """)) model.free_params = ["ELAT", "ELONG"] toas = make_fake_toas(57000, 59000, 100, model=model, error=1 * u.us) np.random.seed(0) toas.adjust_TOAs(TimeDelta(np.random.randn(len(toas)) * u.us)) r = Residuals(toas, model) assert_allclose(r.calc_chi2(full_cov=True), r.calc_chi2(full_cov=False))
class Fitter(object): """ Base class for fitter. The fitting function should be defined as the fit_toas() method. Note that the Fitter object makes a deepcopy of the model, so changes to the model will not be noticed after the Fitter has been instantiated! Use Fitter.model instead. The Fitter also caches a copy of the original model so it can be restored with reset_model() Parameters ---------- toas : a pint TOAs instance The input toas. model : a pint timing model instance The initial timing model for fitting. """ def __init__(self, toas, model, residuals=None): self.toas = toas self.model_init = model if residuals is None: self.resids_init = Residuals(toas=toas, model=model) self.reset_model() else: # residuals were provided, we're just going to use them # probably using GLSFitter to compute a chi-squared self.model = copy.deepcopy(self.model_init) self.resids = residuals self.fitresult = [] self.method = None def reset_model(self): """Reset the current model to the initial model.""" self.model = copy.deepcopy(self.model_init) self.update_resids() self.fitresult = [] def update_resids(self): """Update the residuals. Run after updating a model parameter.""" self.resids = Residuals(toas=self.toas, model=self.model) def set_fitparams(self, *params): """Update the "frozen" attribute of model parameters. Ex. fitter.set_fitparams('F0','F1') """ fit_params_name = [] for pn in params: if pn in self.model.params: fit_params_name.append(pn) else: rn = self.model.match_param_aliases(pn) if rn != "": fit_params_name.append(rn) for p in self.model.params: getattr(self.model, p).frozen = p not in fit_params_name def get_allparams(self): """Return a dict of all param names and values.""" return collections.OrderedDict((k, getattr(self.model, k).quantity) for k in self.model.params_ordered) def get_fitparams(self): """Return a dict of fittable param names and quantity.""" return collections.OrderedDict((k, getattr(self.model, k)) for k in self.model.params if not getattr(self.model, k).frozen) def get_fitparams_num(self): """Return a dict of fittable param names and numeric values.""" return collections.OrderedDict((k, getattr(self.model, k).value) for k in self.model.params if not getattr(self.model, k).frozen) def get_fitparams_uncertainty(self): return collections.OrderedDict( (k, getattr(self.model, k).uncertainty_value) for k in self.model.params if not getattr(self.model, k).frozen) def set_params(self, fitp): """Set the model parameters to the value contained in the input dict. Ex. fitter.set_params({'F0':60.1,'F1':-1.3e-15}) """ # In Powell fitter this sometimes fails because after some iterations the values change from # plain float to Quantities. No idea why. if len(fitp.values()) < 1: return if isinstance(list(fitp.values())[0], u.Quantity): for k, v in fitp.items(): getattr(self.model, k).value = v.value else: for k, v in fitp.items(): getattr(self.model, k).value = v def set_param_uncertainties(self, fitp): for k, v in fitp.items(): parunit = getattr(self.model, k).units getattr(self.model, k).uncertainty = v * parunit def get_designmatrix(self): return self.model.designmatrix(toas=self.toas, incfrozen=False, incoffset=True) def minimize_func(self, x, *args): """Wrapper function for the residual class, meant to be passed to scipy.optimize.minimize. The function must take a single list of input values, x, and a second optional tuple of input arguments. It returns a quantity to be minimized (in this case chi^2). """ self.set_params({k: v for k, v in zip(args, x)}) self.update_resids() # Return chi^2 return self.resids.chi2 def fit_toas(self, maxiter=None): raise NotImplementedError def plot(self): """Make residuals plot""" import matplotlib.pyplot as plt from astropy.visualization import quantity_support quantity_support() fig, ax = plt.subplots(figsize=(16, 9)) mjds = self.toas.get_mjds() ax.errorbar(mjds, self.resids.time_resids, yerr=self.toas.get_errors(), fmt="+") ax.set_xlabel("MJD") ax.set_ylabel("Residuals") try: psr = self.model.PSR except: psr = self.model.PSRJ else: psr = "Residuals" ax.set_title(psr) ax.grid(True) plt.show() def get_summary(self, nodmx=False): """Return a human-readable summary of the Fitter results. Parameters ---------- nodmx : bool Set to True to suppress printing DMX parameters in summary """ # Need to check that fit has been done first! if not hasattr(self, "covariance_matrix"): log.warning( "fit_toas() has not been run, so pre-fit and post-fit will be the same!" ) from uncertainties import ufloat import uncertainties.umath as um # First, print fit quality metrics s = "Fitted model using {} method with {} free parameters to {} TOAs\n".format( self.method, len(self.get_fitparams()), self.toas.ntoas) s += "Prefit residuals Wrms = {}, Postfit residuals Wrms = {}\n".format( self.resids_init.rms_weighted(), self.resids.rms_weighted()) s += "Chisq = {:.3f} for {} d.o.f. for reduced Chisq of {:.3f}\n".format( self.resids.chi2, self.resids.dof, self.resids.chi2_reduced) s += "\n" # Next, print the model parameters s += "{:<14s} {:^20s} {:^28s} {}\n".format("PAR", "Prefit", "Postfit", "Units") s += "{:<14s} {:>20s} {:>28s} {}\n".format("=" * 14, "=" * 20, "=" * 28, "=" * 5) for pn in list(self.get_allparams().keys()): if nodmx and pn.startswith("DMX"): continue prefitpar = getattr(self.model_init, pn) par = getattr(self.model, pn) if par.value is not None: if isinstance(par, strParameter): s += "{:14s} {:>20s} {:28s} {}\n".format( pn, prefitpar.value, "", par.units) elif isinstance(par, AngleParameter): # Add special handling here to put uncertainty into arcsec if par.frozen: s += "{:14s} {:>20s} {:>28s} {} \n".format( pn, str(prefitpar.quantity), "", par.units) else: if par.units == u.hourangle: uncertainty_unit = pint.hourangle_second else: uncertainty_unit = u.arcsec s += "{:14s} {:>20s} {:>16s} +/- {:.2g} \n".format( pn, str(prefitpar.quantity), str(par.quantity), par.uncertainty.to(uncertainty_unit), ) else: # Assume a numerical parameter if par.frozen: s += "{:14s} {:20g} {:28s} {} \n".format( pn, prefitpar.value, "", par.units) else: # s += "{:14s} {:20g} {:20g} {:20.2g} {} \n".format( # pn, # prefitpar.value, # par.value, # par.uncertainty.value, # par.units, # ) s += "{:14s} {:20g} {:28SP} {} \n".format( pn, prefitpar.value, ufloat(par.value, par.uncertainty.value), par.units, ) # Now print some useful derived parameters s += "\nDerived Parameters:\n" if hasattr(self.model, "F0"): F0 = self.model.F0.quantity if not self.model.F0.frozen: p, perr = pint.utils.pferrs(F0, self.model.F0.uncertainty) s += "Period = {} +/- {}\n".format(p.to(u.s), perr.to(u.s)) else: s += "Period = {}\n".format((1.0 / F0).to(u.s)) if hasattr(self.model, "F1"): F1 = self.model.F1.quantity if not any([self.model.F1.frozen, self.model.F0.frozen]): p, perr, pd, pderr = pint.utils.pferrs( F0, self.model.F0.uncertainty, F1, self.model.F1.uncertainty) s += "Pdot = {} +/- {}\n".format( pd.to(u.dimensionless_unscaled), pderr.to(u.dimensionless_unscaled)) brakingindex = 3 s += "Characteristic age = {:.4g} (braking index = {})\n".format( pint.utils.pulsar_age(F0, F1, n=brakingindex), brakingindex) s += "Surface magnetic field = {:.3g}\n".format( pint.utils.pulsar_B(F0, F1)) s += "Magnetic field at light cylinder = {:.4g}\n".format( pint.utils.pulsar_B_lightcyl(F0, F1)) I_NS = I = 1.0e45 * u.g * u.cm**2 s += "Spindown Edot = {:.4g} (I={})\n".format( pint.utils.pulsar_edot(F0, F1, I=I_NS), I_NS) if hasattr(self.model, "PX"): if not self.model.PX.frozen: s += "\n" px = ufloat( self.model.PX.quantity.to(u.arcsec).value, self.model.PX.uncertainty.to(u.arcsec).value, ) s += "Parallax distance = {:.3uP} pc\n".format(1.0 / px) # Now binary system derived parameters binary = None for x in self.model.components: if x.startswith("Binary"): binary = x if binary is not None: s += "\n" s += "Binary model {}\n".format(binary) if binary.startswith("BinaryELL1"): if not any([ self.model.EPS1.frozen, self.model.EPS2.frozen, self.model.TASC.frozen, self.model.PB.frozen, ]): eps1 = ufloat( self.model.EPS1.quantity.value, self.model.EPS1.uncertainty.value, ) eps2 = ufloat( self.model.EPS2.quantity.value, self.model.EPS2.uncertainty.value, ) tasc = ufloat( # This is a time in MJD self.model.TASC.quantity.mjd, self.model.TASC.uncertainty.to(u.d).value, ) pb = ufloat( self.model.PB.quantity.to(u.d).value, self.model.PB.uncertainty.to(u.d).value, ) s += "Conversion from ELL1 parameters:\n" ecc = um.sqrt(eps1**2 + eps2**2) s += "ECC = {:P}\n".format(ecc) om = um.atan2(eps1, eps2) * 180.0 / np.pi if om < 0.0: om += 360.0 s += "OM = {:P}\n".format(om) t0 = tasc + pb * om / 360.0 s += "T0 = {:SP}\n".format(t0) s += pint.utils.ELL1_check( self.model.A1.quantity, ecc.nominal_value, self.resids.rms_weighted(), self.toas.ntoas, outstring=True, ) s += "\n" # Masses and inclination if not any([self.model.PB.frozen, self.model.A1.frozen]): pbs = ufloat( self.model.PB.quantity.to(u.s).value, self.model.PB.uncertainty.to(u.s).value, ) a1 = ufloat( self.model.A1.quantity.to(pint.ls).value, self.model.A1.uncertainty.to(pint.ls).value, ) fm = 4.0 * np.pi**2 * a1**3 / (4.925490947e-6 * pbs**2) s += "Mass function = {:SP} Msun\n".format(fm) mcmed = pint.utils.companion_mass( self.model.PB.quantity, self.model.A1.quantity, inc=60.0 * u.deg, mpsr=1.4 * u.solMass, ) mcmin = pint.utils.companion_mass( self.model.PB.quantity, self.model.A1.quantity, inc=90.0 * u.deg, mpsr=1.4 * u.solMass, ) s += "Companion mass min, median (assuming Mpsr = 1.4 Msun) = {:.4f}, {:.4f} Msun\n".format( mcmin, mcmed) if hasattr(self.model, "SINI"): try: # Put this in a try in case SINI is UNSET or an illegal value if not self.model.SINI.frozen: si = ufloat( self.model.SINI.quantity.value, self.model.SINI.uncertainty.value, ) s += "From SINI in model:\n" s += " cos(i) = {:SP}\n".format( um.sqrt(1 - si**2)) s += " i = {:SP} deg\n".format( um.asin(si) * 180.0 / np.pi) psrmass = pint.utils.pulsar_mass( self.model.PB.quantity, self.model.A1.quantity, self.model.M2.quantity, np.arcsin(self.model.SINI.quantity), ) s += "Pulsar mass (Shapiro Delay) = {}".format(psrmass) except: pass return s def print_summary(self): """Write a summary of the TOAs to stdout.""" print(self.get_summary()) def get_covariance_matrix(self, with_phase=False, pretty_print=False, prec=3): """Show the parameter covariance matrix post-fit. If with_phase, then show and return the phase column as well. If pretty_print, then also pretty-print on stdout the matrix. prec is the precision of the floating point results. """ if hasattr(self, "covariance_matrix"): fps = list(self.get_fitparams().keys()) cm = self.covariance_matrix if with_phase: fps = ["PHASE"] + fps else: cm = cm[1:, 1:] if pretty_print: lens = [max(len(fp) + 2, prec + 8) for fp in fps] maxlen = max(lens) print("\nParameter covariance matrix:") line = "{0:^{width}}".format("", width=maxlen) for fp, ln in zip(fps, lens): line += "{0:^{width}}".format(fp, width=ln) print(line) for ii, fp1 in enumerate(fps): line = "{0:^{width}}".format(fp1, width=maxlen) for jj, (fp2, ln) in enumerate(zip(fps[:ii + 1], lens[:ii + 1])): line += "{0: {width}.{prec}e}".format(cm[ii, jj], width=ln, prec=prec) print(line) print("\n") return cm else: log.error( "You must run .fit_toas() before accessing the covariance matrix" ) raise AttributeError def get_correlation_matrix(self, with_phase=False, pretty_print=False, prec=3): """Show the parameter correlation matrix post-fit. If with_phase, then show and return the phase column as well. If pretty_print, then also pretty-print on stdout the matrix. prec is the precision of the floating point results. """ if hasattr(self, "correlation_matrix"): fps = list(self.get_fitparams().keys()) cm = self.correlation_matrix if with_phase: fps = ["PHASE"] + fps else: cm = cm[1:, 1:] if pretty_print: lens = [max(len(fp) + 2, prec + 4) for fp in fps] maxlen = max(lens) print("\nParameter correlation matrix:") line = "{0:^{width}}".format("", width=maxlen) for fp, ln in zip(fps, lens): line += "{0:^{width}}".format(fp, width=ln) print(line) for ii, fp1 in enumerate(fps): line = "{0:^{width}}".format(fp1, width=maxlen) for jj, (fp2, ln) in enumerate(zip(fps, lens)): line += "{0:^{width}.{prec}f}".format(cm[ii, jj], width=ln, prec=prec) print(line) print("\n") return cm else: log.error( "You must run .fit_toas() before accessing the correlation matrix" ) raise AttributeError def ftest(self, parameter, component, remove=False, full_output=False): """Compare the significance of adding/removing parameters to a timing model. Parameters ----------- parameter : PINT parameter object (may be a list of parameter objects) component : String Name of component of timing model that the parameter should be added to (may be a list) The number of components must equal number of parameters. remove : Bool If False, will add the listed parameters to the model. If True will remove the input parameters from the timing model. full_output : Bool If False, just returns the result of the F-Test. If True, will also return the new model's residual RMS (us), chi-squared, and number of degrees of freedom of new model. Returns -------- dictionary ft : Float F-test significance value for the model with the larger number of components over the other. Computed with pint.utils.FTest(). resid_rms_test : Float (Quantity) If full_output is True, returns the RMS of the residuals of the tested model fit. Will be in units of microseconds as an astropy quantity. resid_wrms_test : Float (Quantity) If full_output is True, returns the Weighted RMS of the residuals of the tested model fit. Will be in units of microseconds as an astropy quantity. chi2_test : Float If full_output is True, returns the chi-squared of the tested model. dof_test : Int If full_output is True, returns the degrees of freedom of the tested model. """ # Copy the fitter that we do not change the initial model and fitter fitter_copy = copy.deepcopy(self) # Number of times to run the fit NITS = 1 # We need the original degrees of freedome and chi-squared value # Because this applies to nested models, model 1 must always have fewer parameters if remove: dof_2 = self.resids.get_dof() chi2_2 = self.resids.calc_chi2() else: dof_1 = self.resids.get_dof() chi2_1 = self.resids.calc_chi2() # Single inputs are converted to lists to handle arb. number of parameteres if type(parameter) is not list: parameter = [parameter] # also do the components if type(component) is not list: component = [component] # if not the same length, exit with error if len(parameter) != len(component): raise RuntimeError( "Number of input parameters must match number of input components." ) # Now check if we want to remove or add components; start with removing if remove: # Set values to zero and freeze them for p in parameter: getattr(fitter_copy.model, "{:}".format(p.name)).value = 0.0 getattr(fitter_copy.model, "{:}".format(p.name)).uncertainty_value = 0.0 getattr(fitter_copy.model, "{:}".format(p.name)).frozen = True # validate and setup model fitter_copy.model.validate() fitter_copy.model.setup() # Now refit fitter_copy.fit_toas(NITS) # Now get the new values dof_1 = fitter_copy.resids.get_dof() chi2_1 = fitter_copy.resids.calc_chi2() else: # Dictionary of parameters to check to makes sure input value isn't zero check_params = { "M2": 0.25, "SINI": 0.8, "PB": 10.0, "T0": 54000.0, "FB0": 1.1574e-6, } # Add the parameters for ii in range(len(parameter)): # Check if parameter already exists in model if hasattr(fitter_copy.model, "{:}".format(parameter[ii].name)): # Set frozen to False getattr(fitter_copy.model, "{:}".format(parameter[ii].name)).frozen = False # Check if parameter is one that needs to be checked if parameter[ii].name in check_params.keys(): if parameter[ii].value == 0.0: log.warning( "Default value for %s cannot be 0, resetting to %s" % (parameter[ii].name, check_params[parameter[ii].name])) parameter[ii].value = check_params[ parameter[ii].name] getattr(fitter_copy.model, "{:}".format( parameter[ii].name)).value = parameter[ii].value # If not, add it to the model else: fitter_copy.model.components[component[ii]].add_param( parameter[ii], setup=True) # validate and setup model fitter_copy.model.validate() fitter_copy.model.setup() # Now refit fitter_copy.fit_toas(NITS) # Now get the new values dof_2 = fitter_copy.resids.get_dof() chi2_2 = fitter_copy.resids.calc_chi2() # Now run the actual F-test ft = FTest(chi2_1, dof_1, chi2_2, dof_2) if full_output: if remove: dof_test = dof_1 chi2_test = chi2_1 else: dof_test = dof_2 chi2_test = chi2_2 resid_rms_test = fitter_copy.resids.time_resids.std().to(u.us) resid_wrms_test = fitter_copy.resids.rms_weighted() # units: us return { "ft": ft, "resid_rms_test": resid_rms_test, "resid_wrms_test": resid_wrms_test, "chi2_test": chi2_test, "dof_test": dof_test, } else: return {"ft": ft}