01 What is EAZY-py?
EAZY-py is a Python version of the ``EAZY'' photometric redshift code originally written by Gabe Brammer and presented in Brammer, van Dokkum & Coppi (2008). The acronym EAZY, it turns out, stands for Easy and Accurate zphot from Yale. While originally written in C, this code has been superseded by a Python package, though you'll want to still download the template and filter files hosted at the eazy-photoz repository.
EAZY is designed to produce photometric redshift estimates by comparing observed photometry to physically motivated sets of galaxy SED templates, these are fit simultaneously (either all of the templates co-added, or individual templates, or user-selected template subsets) in a non-negative linear combination. The key insight of EAZY is that real galaxies rarely look like a single pure template. By allowing linear combinations of templates, EAZY dramatically improves photometric redshift accuracy.
Most importantly (and this is something I try to stress to anyone who uses EAZY): EAZY-py uses χ² minimization for comparing the templates to the observations, and as such, one of the most important outputs from the code is the χ² values as a function of redshift. You will get photometric redshifts derived from χ²(z), but you should always go back to the χ² values if you want to understand how accurate the redshifts are.
02 Installation and Setup
# Basic install
pip install eazy
# You can add in a built-in Dash/Plotly interactive visualization tool
pip install eazy[vistool]
# Optional, but you should go ahead and download these updated dust attenuation models
pip install git+https://github.com/karllark/dust_attenuation.git
# And you should download the template and filter files from eazy-photoz
python -c "import eazy; eazy.fetch_eazy_photoz()"
After installation, you need to make sure that the templates and filters are findable. The easiest approach, on a mac or in linux, is to symlink them into your working directory:
import eazy
# If using fetch_eazy_photoz(), this links from your eazy install:
eazy.symlink_eazy_inputs()
# If you have a local clone of eazy-photoz from the github repository, point to it:
eazy.symlink_eazy_inputs(path='/path/to/eazy-photoz', path_is_env=False)
# Or you can set the EAZYCODE environment variable:
eazy.symlink_eazy_inputs(path='EAZYCODE', path_is_env=True)
This creates symlinks like ./templates → and ./FILTER.RES.latest → in your current working directory, which EAZY will need to use when it runs.
03 A Rough Explanation of How EAZY Fits Sources
Understanding what EAZY is doing under the hood is essential for making sense of what is output from the code.
tempfilt, an array of shape (NZ, NTEMP, NFILT), which is the predicted flux of each template in each filter at each redshift. This is precomputed and cached as self.tempfilt. If you have a large span of redshift and a fine grid spacing, this step can take a while, and there are ways in which the user can save this tempfilt file as a pickle for use in future runs, given the same redshift parameters and template set.
chi2_fit, an array of shape (NOBJ, NZ). The shape of this distribution with redshift is very helpful for understanding other, alternate χ² minima redshifts.
lnp is computed from the χ² distribution, with optional corrections from the user specified template error function (TEF) and any priors.
lnp over the redshift grid gives z_ml (maximum likelihood). Derived quantities like percentiles come from integrating under the lnp curve.
04 Setting Up Your Catalog
Generally, with EAZY, I am trying to fit a large number of sources, and as such, I usually write all of the input properties and fluxes for my sources into a file. I'm going to write this guide as if you are doing the same. The input flux file needs to have columns representing the source fluxes, flux errors, and IDs for the objects, and you then pass this file to EAZY. To let EAZY know how to parse the file, the first two lines of the input file will provide information describing the individual columns. Those two lines will start with "# ", and note the space after the little octothorpe.
There is a required column: id — a unique integer identifier for each object.
There are also some non-required, but helpful columns: z_spec, ra, dec, and these can go in any place, so before the fluxes, or after the fluxes. I tend to put this before the fluxes.
You will include the above values on both of the two header lines. Now, at this point, you'll want to include a number of columns with source fluxes and uncertainties. Here is where it gets tricky. The first line of header will indicate the fluxes and errors, and I think it's not super important what this says, but it's good for you to have for your own sanity. When I do this, I name my flux columns f_{instrument}_{filter}, so, for instance, f_NRC_F200W. And as you might expect, the little f means flux, so for error, I just go with e_{instrument}_{filter} (e.g. e_NRC_F200W). You'll print both on the first header line of the input file. For the second line of the header file, you'll repeat the ID/z_spec/RA/DEC/etc lines as in the first line, but instead of f_{instrument}_{filter}, Flux columns are named F{n} and error columns E{n}. Here, the n is the 1-based index of the filter in FILTER.RES.latest, which is this enormous file (that you got when you downloaded the templates, you can find FILTERS.RES.latest.info here.) which has all of the various filters that EAZY currently uses. You can go and actually figure out what's in there by looking at FILTER.RES.latest.info. For example, filter #366 (JWST/NIRCam F200W) would be columns F366 and E366. If this is confusing, I have an example input file below. Note that you can include new filters at the end of FILTER.RES.latest, in the same format, and then these new filters would be accessed in the same way, increasing the index. I do that, since I am using specific updated JWST filters when I run EAZY.
Now that you have the header lines, you can start including the actual data. First, you'll include your integer ID numbers, and then your values for spectroscopic redshift, RA, and DEC, if you want. More importantly, however, your input catalog must contain photometric fluxes in units of Fν (or AB magnitudes, if you set MAGNITUDES = y within the parameter file, but I'm going to assume you're just doing this with observed fluxes). When I do this, I use nanojansky (nJy), because I primarily work with JWST/NIRCam data. You could also just use microjansky (µJy) or any consistent unit, because EAZY doesn't care about absolute units as it normalizes the templates. However, depending on the flux values that you specify, you'll need to update PRIOR_ABZP, the AB Zero point. If you're using nJy, PRIOR_ABZP will be 31.4. If fluxes are in µJy, PRIOR_ABZP should be 23.9.
Here's an example flux catalog:
# id z_spec f_HST_F435W e_HST_F435W f_HST_F606W e_HST_F606W f_NRC_F070W e_NRC_F070W f_HST_F775W e_HST_F775W f_HST_F814W e_HST_F814W f_HST_F850LP e_HST_F850LP f_NRC_F090W e_NRC_F090W f_NRC_F115W e_NRC_F115W f_NRC_F150W e_NRC_F150W f_NRC_F162M e_NRC_F162M f_NRC_F182M e_NRC_F182M f_NRC_F200W e_NRC_F200W f_NRC_F210M e_NRC_F210M f_NRC_F250M e_NRC_F250M f_NRC_F277W e_NRC_F277W f_NRC_F300M e_NRC_F300M f_NRC_F335M e_NRC_F335M f_NRC_F356W e_NRC_F356W f_NRC_F410M e_NRC_F410M f_NRC_F430M e_NRC_F430M f_NRC_F444W e_NRC_F444W f_NRC_F460M e_NRC_F460M f_NRC_F480M e_NRC_F480M
# id z_spec F428 E428 F429 E429 F442 E442 F430 E430 F431 E431 F432 E432 F443 E443 F444 E444 F445 E445 F446 E446 F447 E447 F448 E448 F449 E449 F450 E450 F451 E451 F454 E454 F455 E455 F458 E458 F462 E462 F465 E465 F467 E467 F470 E470 F473 E473
6137 -9999.0 0.8652090430259705 1.9152350425720215 1.0464285612106323 1.866912603378296 1.0355695486068726 1.3543280363082886 -1.1824941635131836 3.1081478595733643 0.9099689722061157 3.3087193965911865 -4.610594272613525 4.810393333435059 -1.999252200126648 1.6229639053344727 3.552814245223999 1.3917655944824219 7.9091644287109375 1.3670780658721924 -9999.0 -9999.0 -9999.0 -9999.0 8.000021934509277 1.2555376291275024 -9999.0 -9999.0 -9999.0 -9999.0 8.723151206970215 1.0130071640014648 -9999.0 -9999.0 -9999.0 -9999.0 9.757173538208008 0.9876021146774292 7.229896068572998 1.980042576789856 -9999.0 -9999.0 7.868745803833008 1.4598652124404907 -9999.0 -9999.0 -9999.0 -9999.0
6344 -9999.0 1.3033239841461182 1.7997325658798218 -0.21943077445030212 2.619831085205078 -1.4234607219696045 0.7520608901977539 -3.767868757247925 3.6438543796539307 4.429419040679932 2.2065176963806152 -9.91839599609375 8.691930770874023 -0.06491388380527496 0.5057762861251831 0.364867240190506 0.5430588126182556 0.24401046335697174 0.4989297688007355 -0.4772273600101471 0.6282024383544922 0.05435359850525856 0.44584786891937256 0.08488233387470245 0.4324727952480316 -0.19102150201797485 0.5252905488014221 0.04583820700645447 0.6130997538566589 2.553574323654175 0.32815033197402954 0.9084392189979553 0.4422816038131714 1.5835182666778564 0.39926978945732117 3.614173412322998 0.37787064909935 5.058170795440674 0.6488636136054993 -9999.0 -9999.0 4.7173380851745605 0.5026225447654724 -9999.0 -9999.0 -9999.0 -9999.0
Notice how, for values where I don't have an input value, I just go with -9999. I don't know the spectroscopic redshifts for these objects, so that's just -9999.
NOT_OBS_THRESHOLD (default −90) are masked. If your catalog uses 0.0 for non-detections, those will be treated as real zero-flux detections and will badly bias your fits. Make sure your missing-data sentinel matches the threshold.
Now, EAZY also requires a translate file which maps your column names to the F{n}/E{n} convention:
# zphot.translate
f_HST_F435W F428
e_HST_F435W E428
f_HST_F606W F429
e_HST_F606W E429
f_NRC_F070W F442
e_NRC_F070W E442
f_HST_F775W F430
e_HST_F775W E430
f_HST_F814W F431
e_HST_F814W E431
f_HST_F850LP F432
e_HST_F850LP E432
f_NRC_F090W F443
e_NRC_F090W E443
f_NRC_F115W F444
e_NRC_F115W E444
f_NRC_F150W F445
e_NRC_F150W E445
... (etc etc)
Eventually, you'll pass it to EAZY-py with translate_file='zphot.translate'.
Note: within EAZY, the N_MIN_COLORS parameter (default 5) sets the minimum number of valid filters required to fit an object. Objects with fewer detected bands are skipped. Maybe I'm getting ahead of myself. Once you've set up your input photometry file and your translate file, you can start setting up your EAZY parameters.
05 EAZY Parameters, Field-by-Field
Historically, EAZY was run using a separate parameters file (zphot.param), but in the Python implementation you can pass a params dictionary to PhotoZ. However, any parameters that are not specified fall back to the defaults in eazy/data/zphot.param.default, so be careful!
Catalog Parameters
CATALOG_FILECATALOG_FORMATascii.commented_header.MAGNITUDESy if your catalog fluxes are in AB magnitudes.NOT_OBS_THRESHOLDMW_EBVCAT_HAS_EXTCORRy (default), you're indicating that the catalog fluxes are already MW-extinction corrected. EAZY removes that correction before fitting templates.Redshift Grid Parameters
Z_MIN / Z_MAXZ_STEPtempfilt, and the longer it takes EAZY to run.Z_STEP_TYPEZ_STEP_TYPE=0, linear redshift spacing, which is what I use, or Z_STEP_TYPE=1, where the step is Z_STEP × (1+z) (logarithmic spacing).Template Parameters
TEMPLATES_FILEtemplates/fsps_full/tweak_fsps_QSF_12_v3.param — 12 FSPS-based galaxy templates covering a range of star formation histories and dust levels. Below, we will talk about the built-in templates, and how you can add in your own templates, and point to that templates file.TEMPLATE_COMBOSa (all templates simultaneously as non-negative linear combination). There are historical modes where you can specify which templates are combined, but I think this mode my depcrecated.APPLY_IGMy). Uses Inoue et al. (2014) prescription.IGM_SCALE_TAUADD_CGMSIGMOID_PARAM{n}TEMP_ERR_A2SYS_ERRFITTERnnls (non-negative least squares). Alternative: lstsq (unconstrained), which allows negative coefficients.SCALE_2175_BUMPPrior Parameters
APPLY_PRIORFalse, as I don't want to use a prior when fitting very high-redshift galaxies.PRIOR_FILEtemplates/prior_F160W_TAO.dat. Choose a prior matching your reference band.PRIOR_FILTERPRIOR_ABZP23.9, if fluxes are in nJy → use 31.4 If normalized to 1 "exposure unit" at 25 mag → use 25.0. Getting this wrong would be bad for normalization and comparing to fluxes.PRIOR_FLOOR06 Running a Fit: Step by Step
Here's some example Python for running an EAZY fit. You can run this as a script, or in a Jupyter notebook, or within an interactive python terminal:
import os
import numpy as np
import eazy
import h5py # this is for saving an h5 file if you want
import eazy.hdf5 # to resinstantiate EAZY, for plotting, later
import warnings
from astropy.utils.exceptions import AstropyWarning
warnings.simplefilter('ignore', category=AstropyWarning)
np.seterr(all='ignore')
# Step 1: Symlink templates and filters
eazy.symlink_eazy_inputs()
# Step 2: Set parameters
params = {}
params['CATALOG_FILE'] = 'my_flux_catalog.txt'
params['MAIN_OUTPUT_FILE'] = 'photz' # This is how the output filenames are specified
params['OUTPUT_DIRECTORY'] = 'OUTPUT' # Name of the directory to put output files in
params['FILTERS_RES'] = 'FILTER.RES.latest'
params['CATALOG_FORMAT'] = 'ascii.commented_header' # Format if not FITS
params['MAGNITUDES'] = 'n' # Catalog photometry in magnitudes rather than f_nu fluxes
params['NOT_OBS_THRESHOLD'] = -90 # Ignore flux point if
params['N_MIN_COLORS'] = 5 # Require N_MIN_COLORS to fit
params['FIX_ZSPEC'] = False # Fix redshift to catalog zspec
params['Z_MIN'] = 0.01
params['Z_MAX'] = 22.0
params['Z_STEP'] = 0.01
params['Z_STEP_TYPE'] = 0 # 0 = ZSTEP, 1 = Z_STEP*(1+z)
params['APPLY_PRIOR'] = False # some of the other lines below aren't necessary as a result.
params['PRIOR_ABZP'] = 31.4 # If fluxes are in nJy
params['PRIOR_FILTER'] = 366 # NIRCam F200W — change to match your ref band
params['PRIOR_FILE'] = 'templates/prior_F160W_TAO.dat'
params['TEMPLATES_FILE'] = 'templates/sfhz/agn_blue_sfhz_13.param'
params['TEMPLATE_COMBOS'] = 'a' # Combine all of the templates
params['WAVELENGTH_FILE'] = 'templates/uvista_nmf/lambda.def' # Wavelength grid definition file
params['TEMP_ERR_FILE'] = 'templates/TEMPLATE_ERROR.v2.0.zfourge' # Template error definition file
params['TEMP_ERR_A2'] = 1.00 # Template error amplitude
params['SYS_ERR'] = 0.00 # Systematic flux error (% of flux)
params['MW_EBV'] = 0.012 # Galactic reddening toward your field
params['ADD_CGM'] = False # Add Asada24 CGM damping wing absorption
params['SIGMOID_PARAM1'] = 3.5918 # Sigmoid func parameter (A) for the N_HI-z relation in Asada24
params['SIGMOID_PARAM2'] = 1.8414 # Sigmoid func parameter (a) for the N_HI-z relation in Asada24
params['SIGMOID_PARAM3'] = 18.001 # Sigmoid func parameter (C) for the N_HI-z relation in Asada24
params['SCALE_2175_BUMP'] = 0.00 # Scaling of 2175A bump. Values 0.13 (0.27) absorb ~10 (20) % at peak.
## Cosmology
params['H0'] = 70.0 # Hubble constant (km/s/Mpc)
params['OMEGA_M'] = 0.3 # Omega_matter
params['OMEGA_L'] = 0.7 # Omega_lambda
# Step 3: Initialize the PhotoZ object
# This step will take a while depending on your ZMIN, ZMAX, and Z_STEP values, as it creates the
# template grid. NOTE: I personally like to call this PhotoZ object "self," and this is how I will be referencing this throughout the remainder of the guide.
self = eazy.photoz.PhotoZ(params=params,
param_file=None,
translate_file='zphot.translate',
zeropoint_file=None,
load_prior=False,
load_products=False,
n_proc=0) # 0 = serial; set >0 for running in parallel
# Step 4: Fit the full catalog
self.fit_catalog(self.idx, n_proc=8)
# Step 5: Generate output table
zout, hdu = self.standard_output(
prior=False,
beta_prior=False,
rf_pad_width=0.5,
rf_max_err=2,
percentile_limits=[2.5, 16, 50, 84, 97.5]
)
# zout is an astropy Table; also saved to photz.zout.fits
# data = self.show_fit(object_ID, show_fnu = True, zshow = z_a_value, get_spec = True)
After running this, you can reload previously computed results without re-fitting:
eazy = photoz.PhotoZ(params=params, load_products=True)
# Reads photz.zout.fits and photz.data.fits automatically.
08 The χ² and lnp Arrays and Probability Distributions
The central object in EAZY-py is self.chi2_fit and self.lnp, 2D arrays of shape (NOBJ, NZ) containing the χ², and log-probability of each redshift for each object.
self.chi2_fit
The code will calculate the minimum χ² from co-adding the templates at each redshift value specified between Z_MIN and Z_MAX on the grid defined by Z_STEP (self.zgrid). You can access the χ² values as a function of redshift with self.chi2_fit, which is a 2D array of size Nobj by the length of self.zgrid. So, the fifth object in the flux file would have χ² values given in the array self.chi2_fit[4,:].
compute_lnp()
self.compute_lnp(prior=False, beta_prior=False, clip_wavelength=1100) converts chi2_fit to lnp:
The clip_wavelength parameter (default 1100 Å) sets lnp = −∞ at redshifts where clip_wavelength × (1+z) exceeds the reddest valid filter for a given object — preventing trivially good fits at redshifts where the SED has redshifted entirely beyond the photometric coverage. Similar to self.zgrid, you can access the lnp with self.lnp, which has the same size and shape as self.zgrid.
Accessing P(z) Directly
# Get normalized P(z) for object at index i
obj_idx = 42
chisq_obj = self.chi2_fit[obj_idx, :]
pz = np.exp((-1.0) * (chisq_obj - np.min(chisq_obj))/2.0)
import matplotlib.pyplot as plt
plt.plot(self.zgrid, pz)
plt.xlabel('Redshift')
plt.ylabel('P(z)')
07 Understanding EAZY's output: z_ml, z_best, z_phot, and z_min_chisq
This is one of the most confusing aspects of EAZY-py — there are multiple "best redshift" concepts that are closely related but subtly different. Here are the output values as I understand them.
zml (Maximum Likelihood Redshift)
self.zml is the primary internal redshift estimate, and it's defined as the redshift at the maximum of lnp. However, there's some nuance here, as it is computed by eazy.evaluate_zml(), which:
- Finds
iz_best = argmax(lnp[i, :])for each object - Fits a parabola around that peak for sub-grid-spacing precision
- Returns the continuous redshift at the parabola peak as
eazy.zml
It incorporates whatever priors were active when compute_lnp() was called:
self.compute_lnp(prior=True, beta_prior=True)
self.evaluate_zml(prior=True, beta_prior=True)
# Now self.zml reflects the prior-weighted maximum likelihood
z_best (zbest)
self.zbest is the redshift used for computing physical parameters (rest-frame colors, masses, etc.). It defaults to self.zml, but can be set to any user-specified value. If FIX_ZSPEC = True, zbest is set to z_spec for objects where it's available.
zout['z_phot']
In the output table, z_phot is the maximum-likelihood redshift used to compute everything in that table, effectively zml with whatever prior settings were passed to standard_output().
zout['z_raw_chi2']
This is the redshift that corresponds to the overall minimum χ² value across all of the redshifts in the redshift grid.
zout['z500'] (and z025, z160, z840, z975)
This redshift corresponding to the 50th percentile in the P(z) surface, derived from the output χ²(z), and a prior, if one is used. The other values are the 2.5, 16, 84, and 97.5th percentiles, for understanding uncertainty. You can specify these values with percentile_limits=[2.5, 16, 50, 84, 97.5] in self.standard_output().
Summary
| Name | Location | Description |
|---|---|---|
self.zml | PhotoZ attribute | argmax of lnp (parabola-refined), with/without priors |
self.zbest | PhotoZ attribute | Redshift used for physical params; defaults to zml |
self.chi2_fit | PhotoZ attribute | Raw χ² at every (object, z) grid point — shape (NOBJ, NZ) |
zout['z_phot'] | Output table | The z_ml used in standard_output |
zout['z_phot_chi2'] | Output table | χ² at z_phot |
zout['z500'] | Output table | Median of P(z) (50th percentile) |
zout['z_raw_chi2'] | Output table | Redshift corresponding to the minimum χ² . |
zout['raw_chi2'] | Output table | Minimum χ² at all redshifts. |
zout['z_phot_risk'] | Output table | "Risk" redshift minimizing catastrophic outlier rate |
zout['z_raw_chi2'], since it's easiest to re-calculate, given the fitting involved in estimating self.zml. I also think that zout['z500'] is helpful, and the percentile columns (z160–z840) characterize P(z) width, so they are useful for understanding uncertainties. I tend to avoid self.zml, but I think it's the standard value used across the literature.
09 Priors: Magnitude Prior & Beta Prior
The Apparent Magnitude Prior
The magnitude prior encodes the expectation that fainter sources are more likely to be at higher redshift. It is read from a file (default templates/prior_F160W_TAO.dat) that contains P(m, z) — the probability of a source being at redshift z given its apparent magnitude m.
PRIOR_FILTERmust be the filter number of your reference bandPRIOR_ABZPmust match your flux zeropoint (23.9 for µJy)PRIOR_FILEshould match your reference band
# Recompute without prior to diagnose bias
ez.compute_lnp(prior=False, beta_prior=False)
ez.evaluate_zml(prior=False, beta_prior=False)
The Beta Prior
The UV slope beta prior (beta_prior=True) adds a soft constraint on the UV spectral slope, discouraging fits where the galaxy would need an unusual UV slope at the fitted redshift. The prior width is a sigmoid function of redshift:
- At z < z_split (default 4): tight, σ = 0.5
- At z > z_split: widens significantly toward σ = 20
Particularly useful for JWST high-z work where Lyman-break galaxies tend to have very blue UV slopes, and the beta prior helps avoid low-z degeneracies.
# Customize the beta prior shape
ez.prior_beta(width_params={
'k': -5, 'z_split': 4,
'sigma0': 20, 'sigma1': 0.5, 'center': -1.5
})
10 The Template Error Function (TEF)
The TEF addresses a fundamental problem: even perfect photometry gives poor χ² if the templates don't perfectly match your galaxies. The TEF adds wavelength-dependent "template uncertainty" to the error budget:
The TEF is read from TEMP_ERR_FILE and scaled by TEMP_ERR_A2. Increasing TEMP_ERR_A2 broadens all P(z) distributions, and could be used when you don't fully trust your templates.
11 Running standard_output: What You Get
self.standard_output() is the main output-generation function. It:
- Refits template coefficients at
zbest(orz_ml) - Computes redshift statistics (percentiles of P(z), risk function, etc.)
- Computes rest-frame photometry in UBVJ and any other specified filters
- Optionally computes SPS-derived physical parameters (stellar mass, SFR, Lv, Av)
- Saves
{MAIN_OUTPUT_FILE}.zout.fitsand{MAIN_OUTPUT_FILE}.data.fits
zout, hdu = ez.standard_output(
zbest=None, # Use z_ml by default; pass array for custom redshifts
prior=False, # Apply magnitude prior
beta_prior=False, # Apply beta prior
UBVJ=[153, 154, 155, 161], # Filter numbers for rest-frame U, B, V, J
extra_rf_filters=[270, 274, 120, 121, ...], # In case you want to calculate rest-frame photometry through these filters
rf_pad_width=0.5, # Padding (fraction of filter width) for rest-frame interp
rf_max_err=2, # Max allowed fractional error on rest-frame flux
percentile_limits=[2.5, 16, 50, 84, 97.5], # For z500, z160, etc.
get_err=True, # Compute rest-frame flux uncertainties via random draws
save_fits=True,
n_proc=0 # Parallelization for rest-frame computation
)
rf_pad_width & rf_max_err: These control rest-frame flux estimation. rf_pad_width=0.5 uses observed filters within ±50% of the rest-frame filter's effective wavelength. rf_max_err=2 (generous) vs 0.5 (conservative) governs how poorly constrained rest-frame fluxes are allowed to be.
To reload products later:
from astropy.table import Table
zout = Table.read('photz.zout.fits')
# Or reload the full PhotoZ state
self = photoz.PhotoZ(params=params, load_products=True)
# self.zout, self.chi2_fit, self.fit_coeffs, self.ubvj all populated
NOTE: You will need to specify trhe rest-frame UBVJ filters, or else the code fails, so if you are defining your own filter file, either point these to arbitrary filter numbers (that exist!) or include the UBVJ filters from FILTER.RES.latest. I tend to just add on filters to the end of this file, as shown above.
12 The zout Table: Column-by-Column
| Column | Description |
|---|---|
id | Object ID from input catalog |
z_spec | Spectroscopic redshift from catalog (−99 if not available) |
nusefilt | Number of filters actually used in the fit |
z_phot | Best photometric redshift (z_ml with priors) |
z_phot_chi2 | χ² at z_phot |
z_phot_risk | Redshift minimizing the "risk" of catastrophic outliers |
z_min_risk | Redshift at minimum risk |
min_risk | Value of the risk function at z_min_risk |
z025, z160, z500, z840, z975 | Percentiles of P(z): 2.5th, 16th, 50th, 84th, 97.5th |
restU, restB, restV, restJ | Rest-frame flux densities in U, B, V, J bands |
restU_err ... restJ_err | Uncertainties on rest-frame fluxes |
Lv | Rest-frame V-band luminosity (L☉) |
MLv | Mass-to-light ratio in V band |
Av | V-band dust attenuation (magnitudes) |
mass | Stellar mass (M☉) from SPS template fitting |
SFR | Star formation rate (M☉/yr) |
LIR | Integrated IR luminosity (8–1000 µm, L☉) |
Computing UVJ Colors
uv = -2.5 * np.log10(zout['restU'] / zout['restV'])
vj = -2.5 * np.log10(zout['restV'] / zout['restJ'])
uverr = 2.5 * np.sqrt((zout['restU_err']/zout['restU'])**2 +
(zout['restV_err']/zout['restV'])**2)
vjerr = 2.5 * np.sqrt((zout['restV_err']/zout['restV'])**2 +
(zout['restJ_err']/zout['restJ'])**2)
13 Iterative Zeropoint Corrections
There may be underlying differences in the fluxes between the photometric datapoints that can be ascertained with EAZY fits. The function iterate_zp_templates() iteratively adjusts per-filter flux zeropoints to minimize the template residuals as compared to the observed flues. After running, self.zp will contain the multiplicative correction factors per filter (close to 1.0 for well-calibrated data), which are normalized to the params['PRIOR_FILTER'] filter.
NITER = 3
NBIN = min(self.NOBJ // 100, 180)
for i in range(NITER):
sn = self.fnu / self.efnu
clip = (sn > 3).sum(axis=1) > 4 # objects with good multi-band SNR, you can adjust this to your liking
self.iterate_zp_templates(
idx=self.idx[clip],
update_templates=False,
update_zeropoints=True,
iter=i,
n_proc=4,
error_residuals=(i > 0), # rescale errors after first iteration
NBIN=NBIN,
get_spatial_offset=False
)
The error_residuals=True option rescales per-filter error arrays so that the normalized χ² per filter is ~1, effectively correcting for over/underestimated uncertainties.
14 Template Sets: Which One to Use
agn_blue_sfhz_13.paramtweak_fsps_QSF_12_v3.paramfsps_QSF_12_v3.paramtweak_ version unless you have a specific reason not to.Examine the templates after initialization:
import matplotlib.pyplot as plt
for i, temp in enumerate(self.templates):
plt.plot(temp.wave / 1e4, temp.flux[0,:], label=temp.name, alpha=0.7)
plt.xlabel('Wavelength (µm)')
plt.xscale('log')
plt.yscale('log')
plt.legend(fontsize=6)
15 Visualizing Results
Individual SED Fits
# By object ID (from catalog)
fig, data = self.show_fit(
id=12345,
xlim=[0.3, 9], # Wavelength range in µm
show_components=True, # Show individual template contributions
logpz=True, # Log scale for P(z)
zr=[0, 7], # Redshift range for P(z) panel
show_prior=True, # Overlay the magnitude prior
show_stars=True, # Overlay best-fit stellar templates
delta_chi2_stars=-20, # χ² threshold for flagging stars
snr_thresh=2.0, # SNR threshold for detections vs. limits
showpz=0.6 # Fraction of figure height for P(z) panel
)
# By index rather than ID
fig, data = self.show_fit(id=42, id_is_idx=True)
# At a specific redshift (e.g., z_spec)
fig, data = self.show_fit(id=12345, zshow=1.234)
The data return value is a dict containing the SED, template fluxes, and P(z) arrays — useful for customizing the plot.
plot_fit_EAZY()
While the built-in function self.show_fit() works well, here is a python function I wrote which will allow you to plot the fit for individual sources with the minimum χ² value as well as the χ² surface, while also allowing the user to plot alternate redshift solutions and save the plot, or the SED fit and/or χ²(z) values.
Primarily I am including this to show the user how to go and grab information from self and zout, and specifically the power of self.show_fit() to extract information without making a plot.
import numpy as np
import matplotlib.pyplot as plt
def plot_fit_EAZY(object_ID, self, zout, primary_z = -9999, alt_z = 0.0, h5file = False, savefile = None, savesed = False, savechisq = False):
template_color = '#56B4E9'#'
NIRC_photometry_color = '#D55E00'
HST_photometry_color = '#F786AA'
chisq_surface_color = '#E69F00'
zspec_color = '#332288'
alternate_color = 'grey'
# First, the redshift grid for the chisq surface.
output_zgrid = self.zgrid
# the index of the source.
i = np.where(self.OBJID == object_ID)[0][0]
# the chisq values.
if (h5file == False):
output_chisq = self.chi2_fit[i,:]
else:
_data = self.get_object_data(i)
z, fnu_i, efnu_i, ra_i, dec_i, output_chisq, zspec_i, ok_i = _data
z_a_value = np.round(zout['z_raw_chi2'][i],2)
if (primary_z < 0):
data = self.show_fit(object_ID, show_fnu = True, zshow = z_a_value, get_spec = True)
else:
data = self.show_fit(object_ID, show_fnu = True, zshow = primary_z, get_spec = True)
output_wavelength = data['templz']
output_flux = data['templf']*1e3
output_phot_wavelength = data['pivot']
output_phot_template = data['model']*1e3
output_phot_err_template = data['emodel']*1e3
output_phot = data['fobs']*1e3
output_phot_err = data['efobs']*1e3
output_tef = data['tef']
pos_flux_errors = np.where(output_phot_err > 0)[0]
plt.figure(figsize = (12, 5))
plt.subplot(121)
if (primary_z < 0):
plt.plot(output_wavelength/1e4, output_flux, color = template_color, lw = 3, zorder = 0, label = 'z = '+str(round(zout['z_raw_chi2'][i],2)))
else:
plt.plot(output_wavelength/1e4, output_flux, color = template_color, lw = 3, zorder = 0, label = 'z = '+str(round(primary_z,2)))
plt.scatter(output_phot_wavelength/1e4, output_phot_template, marker = 's', s = 110, linewidth = 3, edgecolor = template_color, color = 'None', zorder = 5)
plt.scatter(output_phot_wavelength[pos_flux_errors]/1e4, output_phot[pos_flux_errors], color = NIRC_photometry_color, s = 50, linewidth = 3, zorder = 10)
plt.errorbar(output_phot_wavelength[pos_flux_errors]/1e4, output_phot[pos_flux_errors], yerr = output_phot_err[pos_flux_errors], color = 'black', ls = 'None', alpha = 0.4)
plt.semilogy()
plt.xlabel('Observed Wavelength ($\mu$m)')
plt.ylabel('Flux (nJy)')
plt.xlim(0.3, 5.0)
pos_fluxes = np.where(output_phot > 0)[0]
ymin = np.min(output_phot[pos_fluxes]) - (0.4 * np.min(output_phot[pos_fluxes]))
if (ymin < 0.01):
ymin = 0.01
ymax = np.max(output_phot[pos_fluxes]) + (10.0 * np.max(output_phot[pos_fluxes]))
if (ymax > (100.*np.median(output_phot[np.where(output_phot > 0)[0]]))):
ymax = 100.*np.median(output_phot[np.where(output_phot > 0)[0]])
print(ymin, ymax)
plt.ylim(ymin, ymax)
plt.title(self.OBJID[i])
if (alt_z < 0):
alt_z_value = alt_z * -1.0
if (zout['z_raw_chi2'][i] > alt_z_value):
zgrid_lt_7 = np.where(output_zgrid <= alt_z_value)[0]
z_a_lt_7 = np.round(output_zgrid[zgrid_lt_7][np.argmin(output_chisq[zgrid_lt_7])],2)
data_zlt_7 = self.show_fit(object_ID, show_fnu = True, zshow = z_a_lt_7, get_spec = True)
delta_chisq = output_chisq[np.argmin(output_chisq[zgrid_lt_7])] - output_chisq[np.argmin(output_chisq)]
print('Delta chisq = '+str(np.round(delta_chisq,2)))
output_wavelength_zlt_7 = data_zlt_7['templz']
output_flux_zlt_7 = data_zlt_7['templf']*1e3
output_phot_template_zlt_7 = data_zlt_7['model']*1e3
plt.plot(output_wavelength_zlt_7/1e4, output_flux_zlt_7, color = alternate_color, lw = 2, zorder = 0, label = 'z = '+str(round(z_a_lt_7,2)), alpha = 0.4)
else:
zgrid_gt_7 = np.where(output_zgrid > alt_z_value)[0]
z_a_gt_7 = np.round(output_zgrid[zgrid_gt_7][np.argmin(output_chisq[zgrid_gt_7])],2)
data_zgt_7 = self.show_fit(object_ID, show_fnu = True, zshow = z_a_gt_7, get_spec = True)
delta_chisq = output_chisq[np.argmin(output_chisq[zgrid_gt_7])] - output_chisq[np.argmin(output_chisq)]
print('Delta chisq = -'+str(np.round(delta_chisq,2)))
output_wavelength_zgt_7 = data_zgt_7['templz']
output_flux_zgt_7 = data_zgt_7['templf']*1e3
output_phot_template_zgt_7 = data_zgt_7['model']*1e3
plt.plot(output_wavelength_zgt_7/1e4, output_flux_zgt_7, color = alternate_color, lw = 2, zorder = 0, label = 'z = '+str(round(z_a_gt_7,2)), alpha = 0.4)
if (alt_z > 0):
alt_z_value = alt_z
data_zalt = self.show_fit(object_ID, show_fnu = True, zshow = alt_z_value, get_spec = True)
delta_chisq = output_chisq[np.where(output_zgrid == np.round(alt_z_value,2))[0]] - output_chisq[np.argmin(output_chisq)]
print('Delta chisq = '+str(np.round(delta_chisq,2)))
output_wavelength_zalt = data_zalt['templz']
output_flux_zalt = data_zalt['templf']*1e3
output_phot_template_zalt = data_zalt['model']*1e3
plt.plot(output_wavelength_zalt/1e4, output_flux_zalt, color = alternate_color, lw = 2, zorder = 0, label = 'z = '+str(round(alt_z_value,2)), alpha = 0.4)
plt.legend()
plt.subplot(122)
plt.plot(output_zgrid, output_chisq, color = chisq_surface_color, lw = 3, zorder = 10)
if (primary_z < 0):
plt.axvline(zout['z_raw_chi2'][i], lw = 3, zorder = 0)
else:
plt.axvline(primary_z, lw = 3, zorder = 0)
if (alt_z < 0):
if (zout['z_raw_chi2'][i] > 7.0):
plt.axvline(z_a_lt_7, color = alternate_color, lw = 3, alpha = 0.4, zorder = 0)
else:
plt.axvline(z_a_gt_7, color = alternate_color, lw = 3, alpha = 0.4, zorder = 0)
if (alt_z > 0):
plt.axvline(alt_z_value, color = alternate_color, lw = 3, alpha = 0.4, zorder = 0)
plt.xlabel('Redshift')
plt.ylabel('$\chi^2$')
plt.tight_layout()
if (savefile == None):
plt.show()
else:
savefile_name = str(object_ID)+'_wave_sed.png'
plt.savefig(savefile_name, dpi = 300)
if (savesed == True):
np.savetxt(str(object_ID)+'_wave_sed.dat', np.c_[output_wavelength/1e4, output_flux])
np.savetxt(str(object_ID)+'_wave_sed_photometry.dat', np.c_[output_phot_wavelength/1e4, output_phot_template])
if (savechisq == True):
np.savetxt(str(object_ID)+'_z_chisq.dat', np.c_[output_zgrid, output_chisq])
z_phot vs z_spec Comparison
If you happen to have spectroscopic redshifts for your sources, you could use the built-in function self.zphot_zspec():
fig = self.zphot_zspec() # Produces a standard comparison plot
Interactive Dashboard
Similarly, there's a Plotly/Dash browser app that you can run which will allow you to explore the source fits interactively:
from eazy import visualization
app = visualization.EazyExplorer(photoz=self, zout=zout)
app.make_dash_app(server_mode='external', port=8050)
# Opens a Plotly/Dash browser app for interactive exploration
16 Common Pitfalls & Tips
PRIOR_ABZP = 31.4. If fluxes are in µJy, use PRIOR_ABZP = 23.9. If they're normalized to a "25.0 = 1 unit" convention, use 25.0. Getting this wrong silently biases redshifts.
FILTER.RES.latest.info. You can also find a filter you're interested in with:
for i, filt in enumerate(self.RES.filters):
if 'f160w' in filt.name.lower():
print(i+1, filt.name) # +1 because 1-indexed
import pickle
with open("tempfilt_example.pkl","wb") as f:
pickle.dump(self.tempfilt,f)
...and then you can read it in before running EAZY:
with open("tempfilt_example.pkl","rb") as inp:
test_tempfilt = pickle.load(inp)
...and at this point:
self = eazy.photoz.PhotoZ(params=params,
param_file=None,
translate_file='zphot.translate',
tempfilt = test_tempfilt, # <-- right here!
zeropoint_file=None,
load_prior=False,
load_products=False,
n_proc=0)
NOT_OBS_THRESHOLD (default −90) are masked. If your catalog uses 0.0 for non-detections, those are treated as real zero-flux detections, which you likely do not want, given that this will break your fits.
self.nusefilt[i] — if too few filters pass the threshold, the object is skipped.
unfitted = (self.chi2_fit == 0).all(axis=1)
print(f"{unfitted.sum()} objects not fit")
random_draws=100, self.coeffs_draws has shape (NOBJ, 100, NTEMP). For 100k objects × 12 templates, this is ~5 GB. Reduce random_draws if memory is tight.
chi2_best = self.chi2_fit[np.arange(self.NOBJ), ez.izbest]
chinu = chi2_best / np.maximum(self.nusefilt - 1, 1)
bad_fits = chinu > 5 # Flag very poor fits
17 Key Internal Attributes Reference
| Attribute | Shape | Description |
|---|---|---|
self.zgrid | (NZ,) | Redshift grid |
self.NZ | scalar | Number of redshift grid points |
self.NOBJ | scalar | Number of objects in catalog |
self.NFILT | scalar | Number of filters |
self.NTEMP | scalar | Number of templates |
self.fnu | (NOBJ, NFILT) | Observed fluxes (after zp correction) |
self.efnu | (NOBJ, NFILT) | Flux errors (after any rescaling) |
self.efnu_orig | (NOBJ, NFILT) | Original errors before rescaling |
self.ok_data | (NOBJ, NFILT) | Boolean mask of valid flux measurements |
self.nusefilt | (NOBJ,) | Number of valid filters per object |
self.chi2_fit | (NOBJ, NZ) | χ² at each (object, z) grid point |
self.fit_coeffs | (NOBJ, NZ, NTEMP) | Template coefficients at each (object, z) |
self.lnp | (NOBJ, NZ) | Log-probability from compute_lnp() |
self.full_logprior | (NOBJ, NZ) | Log of magnitude prior |
self.lnp_beta | (NOBJ, NZ) | Log of beta prior |
self.zml | (NOBJ,) | Maximum likelihood redshift |
self.izbest | (NOBJ,) | Index into zgrid of best redshift |
self.zbest | (NOBJ,) | Best redshift used for physical params |
self.chi2_best | (NOBJ,) | χ² at zbest |
self.coeffs_best | (NOBJ, NTEMP) | Template coefficients at zbest |
self.fmodel | (NOBJ, NFILT) | Model fluxes at zbest |
self.coeffs_draws | (NOBJ, N_draws, NTEMP) | Random coefficient draws for uncertainties |
self.zp | (NFILT,) | Multiplicative zeropoint corrections |
self.zout | Table | Output redshift table (from standard_output) |
self.cat | Table | Input catalog |
self.filters | list | Filter objects |
self.templates | list | Template objects |
self.pivot | (NFILT,) | Pivot wavelengths of filters (Angstroms) |
self.flux_columns | list | Names of flux columns in catalog |
self.f_numbers | (NFILT,) | Filter numbers (1-indexed into FILTER.RES.latest, if that's what you're using.) |
self.lc_reddest | (NOBJ,) | Reddest valid filter pivot wavelength per object |
Further Resources
Community guide · Based on eazy-py v0.8.5 · May 2026