Helpful (?) Advice

A Completely Unauthorized Users Guide
to EAZY-py

So You Want To Calculate Photometric Redshifts

Written by Kevin Hainline
EAZY can be found at gbrammer/eazy-py v0.8.5  ·  (Brammer, van Dokkum & Coppi, 2008)

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.

This is an unofficial guide, and as such, it is potentially (likely?) incorrect in a few places. Send me an email if you find something wrong, or if you have something I should add to the page to make it more helpful.

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.

1
First, EAZY reads in the User parameters, and from that, builds the template grid. EAZY integrates each template SED through each filter at every redshift on a grid from a minimum to a maximum redshift, with a defined grid spacing. This produces 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.
2
Next, EAZY fits each object at each user specified redshift. At every redshift grid point, EAZY solves for the non-negative linear combination of templates that best fits the observed photometry (NNLS), based on the minimum χ². The result is chi2_fit, an array of shape (NOBJ, NZ). The shape of this distribution with redshift is very helpful for understanding other, alternate χ² minima redshifts.
χ² = Σfilters [ (Fobs − Σj cj · Ftemp,j(z))² / σ² ]  where cj ≥ 0
3
At this point, EAZY converts the χ² values to a likelihood. The log-probability lnp is computed from the χ² distribution, with optional corrections from the user specified template error function (TEF) and any priors.
4
Finally, EAZY finds the ``best'' redshift. As we discuss below, there are multiple redshift values that EAZY produces based on the χ² distribution. The maximum of 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.

Non-detection convention: Fluxes below 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_FILE
Path to your input photometric catalog.
CATALOG_FORMAT
Format for non-FITS catalogs. Generally I just use the default: ascii.commented_header.
MAGNITUDES
Set to y if your catalog fluxes are in AB magnitudes.
NOT_OBS_THRESHOLD
Flux values below this (default −90) are treated as missing/unobserved.
MW_EBV
Milky Way E(B-V) toward your field. You can look this up at IRSA Dust Tool, if you want to include this effect.
CAT_HAS_EXTCORR
If y (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_MAX
Redshift search range (defaults: 0.01 – 12.0). Restrict to your science range to save compute time. I tend to use 0.01 to 22.0.
Z_STEP
Redshift step size (default 0.01). This is the grid spacing that you want EAZY to use. The smaller this is, the longer it takes to generate the tempfilt, and the longer it takes EAZY to run.
Z_STEP_TYPE
You can specify Z_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_FILE
The file listing your SED templates. Default: templates/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_COMBOS
How templates are combined. In eazy-py, you'll generally want to go with a (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_IGM
Apply IGM absorption to templates (default y). Uses Inoue et al. (2014) prescription.
IGM_SCALE_TAU
Scale factor for IGM optical depth (default 1.0). Values >1 increase absorption strength — sometimes useful for high-redshift studies.
ADD_CGM
Apply CGM damping wing absorption from Asada et al. (2024). Relevant for z > 6 studies.
SIGMOID_PARAM{n}
These are the parameters that define the Asada et al. (2024) CGM model. These are set to the finalized published values by default.
TEMP_ERR_A2
Amplitude scaling for the template error function (default 0.20). Increasing this makes EAZY more forgiving of template-data mismatches, effectively broadening P(z).
SYS_ERR
Systematic flux error added in quadrature as a fraction of the flux (default 0.01 = 1%). Prevents bright sources from being over-constrained. Increase to 0.02–0.05 for surveys with known systematics.
FITTER
Template fitting algorithm. Default nnls (non-negative least squares). Alternative: lstsq (unconstrained), which allows negative coefficients.
SCALE_2175_BUMP
Adds a 2175Å dust bump. Value 0.13 ≈ 10% absorption at peak; 0.27 ≈ 20%. Default 0.00.

Prior Parameters

APPLY_PRIOR
Apply apparent magnitude prior. I tend to specify False, as I don't want to use a prior when fitting very high-redshift galaxies.
PRIOR_FILE
If you do want to specify a prior, you point to the prior grid file here. The most common EAZY prior, and the default is templates/prior_F160W_TAO.dat. Choose a prior matching your reference band.
PRIOR_FILTER
Filter number of your magnitude reference band (for the prior lookup). Must match the band used to build the prior file. This filter is also the filter by which the photometric zeropoints are derived, as discussed below.
PRIOR_ABZP
AB zeropoint of your flux units. If fluxes are in µJy → use 23.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_FLOOR
Minimum value of normalized prior (default 1e-2). Prevents P(z) from hitting exactly zero anywhere.

06 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:

lnp[i, iz] = −½ χ²[i, iz] + lnpprior[i, iz] + lnptef[i, iz]

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:

  1. Finds iz_best = argmax(lnp[i, :]) for each object
  2. Fits a parabola around that peak for sub-grid-spacing precision
  3. 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

NameLocationDescription
self.zmlPhotoZ attributeargmax of lnp (parabola-refined), with/without priors
self.zbestPhotoZ attributeRedshift used for physical params; defaults to zml
self.chi2_fitPhotoZ attributeRaw χ² at every (object, z) grid point — shape (NOBJ, NZ)
zout['z_phot']Output tableThe z_ml used in standard_output
zout['z_phot_chi2']Output tableχ² at z_phot
zout['z500']Output tableMedian of P(z) (50th percentile)
zout['z_raw_chi2']Output tableRedshift corresponding to the minimum χ² .
zout['raw_chi2']Output tableMinimum χ² at all redshifts.
zout['z_phot_risk']Output table"Risk" redshift minimizing catastrophic outlier rate
Which to use? I personally use 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 (z160z840) 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.

Setup checklist:
  • PRIOR_FILTER must be the filter number of your reference band
  • PRIOR_ABZP must match your flux zeropoint (23.9 for µJy)
  • PRIOR_FILE should 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:

σ²total = σ²phot + (TEMP_ERR_A2 · TEF(λ))² · F²template

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:

  1. Refits template coefficients at zbest (or z_ml)
  2. Computes redshift statistics (percentiles of P(z), risk function, etc.)
  3. Computes rest-frame photometry in UBVJ and any other specified filters
  4. Optionally computes SPS-derived physical parameters (stellar mass, SFR, Lv, Av)
  5. Saves {MAIN_OUTPUT_FILE}.zout.fits and {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

ColumnDescription
idObject ID from input catalog
z_specSpectroscopic redshift from catalog (−99 if not available)
nusefiltNumber of filters actually used in the fit
z_photBest photometric redshift (z_ml with priors)
z_phot_chi2χ² at z_phot
z_phot_riskRedshift minimizing the "risk" of catastrophic outliers
z_min_riskRedshift at minimum risk
min_riskValue of the risk function at z_min_risk
z025, z160, z500, z840, z975Percentiles of P(z): 2.5th, 16th, 50th, 84th, 97.5th
restU, restB, restV, restJRest-frame flux densities in U, B, V, J bands
restU_err ... restJ_errUncertainties on rest-frame fluxes
LvRest-frame V-band luminosity (L)
MLvMass-to-light ratio in V band
AvV-band dust attenuation (magnitudes)
massStellar mass (M) from SPS template fitting
SFRStar formation rate (M/yr)
LIRIntegrated 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.

If you have fewer than ~50 sources, the corrections may be poorly constrained, so only use this function when you have enough objects for meaningful statistics per filter.

14 Template Sets: Which One to Use

agn_blue_sfhz_13.param
(Recommended) 13 SFHZ-derived templates covering a range of star formation histories and dust levels, with a few updated templates including an AGN ('j0647agn+torus.fits'). Works quite well even out to high redshifts (z > 8).
tweak_fsps_QSF_12_v3.param
12 FSPS templates covering a range of star formation histories and dust levels, empirically adjusted for better photo-z performance. Works well for most survey applications from z ~ 0.1–6.
fsps_QSF_12_v3.param
Similar to the above but without the empirical tweaks. Generally use the tweak_ 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)
For z > 6 JWST work: The default FSPS templates work reasonably but do not capture extremely blue UV slopes common at high-z. There are many template sets available online which can be used for these high-redshift sources:
  1. Larson et al. (2023) - download here.
  2. Steinhardt et al. (2023) - download here
  3. Hainline et al. (2024) - download here
  4. Luberto et al. (2025) - download here

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

1
PRIOR_ABZP mismatch. If fluxes are in nJy, use 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.
2
Filter numbers. Numbers like 205 or 153 are 1-based line indices specified in 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
3
The creation of the tempfilt object can take a long time, especially if you have a lot of filters, and lot of redshift values. A quick hack, if you want to fit additional sources in the future with the same filters, templates, and redshift grid (ie, the exact same fitting setup, just new sources), is that you can save the tempfilt to something like a pickle file:
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)
4
Non-detection convention. Fluxes below 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.
5
If a source has χ² = 0, that means the object was not fit. Check 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")
6
Multiple χ² minima. It is very important that you look at χ²(z) to explore the goodness of fit for alternate redshift solutions. Plotting these alternate solutions, as described above, will help to see how much you should trust your own photometric redshift. But know that, from fitting sources with known spectroscopic redshifts, the P(z) width is often underestimated, meaning your uncertainties on the photo-z from these fits will be too low.
7
Memory usage. With 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.
8
You could do a simple estimate to explore the reduced χ² values:
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

AttributeShapeDescription
self.zgrid(NZ,)Redshift grid
self.NZscalarNumber of redshift grid points
self.NOBJscalarNumber of objects in catalog
self.NFILTscalarNumber of filters
self.NTEMPscalarNumber 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.zoutTableOutput redshift table (from standard_output)
self.catTableInput catalog
self.filterslistFilter objects
self.templateslistTemplate objects
self.pivot(NFILT,)Pivot wavelengths of filters (Angstroms)
self.flux_columnslistNames 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