diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5054e3d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release +on: + release: + types: [created] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_wheels: + name: Build wheel for ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - "ubuntu-22.04" + - "macos-14" + steps: + - uses: pypa/cibuildwheel@v2.16.5 + - uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.os }} + path: ./wheelhouse/*.whl + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + - uses: actions/setup-python@v5 + name: Install Python + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U build twine + - name: Build sdist + run: | + python -m pip install -U pip + python -m build --sdist . + - uses: actions/upload-artifact@v4 + with: + path: dist/*.tar.gz + + publish: + environment: + name: pypi + url: https://pypi.org/p/fsps + permissions: + id-token: write + needs: [build_wheels, build_sdist] + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@v1.8.12 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 842a7ac7..0b720d3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,9 +1,9 @@ name: Tests on: push: - branches: [main] + branches: [main, v2.0] pull_request: - branches: [main] + branches: [main, v2.0] jobs: tests: @@ -12,17 +12,17 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9"] + python-version: ["3.9", "3.11"] os: [ubuntu-latest, macos-latest] steps: - name: Clone the repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Clone fsps - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: repository: cconroy20/fsps path: fsps @@ -30,11 +30,12 @@ jobs: run: | python -m pip install -U pip pytest python -m pip install -U fsps astro-sedpy astropy - python -m pip install -U six dynesty + python -m pip install -U scipy + python -m pip install -U dynesty python -m pip install . env: SPS_HOME: ${{ github.workspace }}/fsps - name: Run tests - run: python -m pytest --durations=0 --maxfail=1 -vs tests/ + run: python -m pytest --durations=0 --maxfail=1 -W ignore::DeprecationWarning --ignore tests/misc/ -vs tests/ env: SPS_HOME: ${{ github.workspace }}/fsps diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 47b238c2..a7d3d299 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,8 +1,14 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.10" + sphinx: configuration: doc/conf.py python: - version: 3.7 install: - requirements: doc/requirements.txt - method: pip diff --git a/AUTHORS.rst b/AUTHORS.rst index afe47f28..bc2b2c50 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,26 +1 @@ -Author: - -- `Ben Johnson (Harvard) `_ - -Direct contributions to the code base: - -- `Gabe Brammer `_ -- `James Guillochon `_ -- `Joel Leja `_ -- `John Moustakas `_ -- `Josh Speagle `_ - -Comments, corrections & suggestions: - -- Antara Basu-Zych -- Kevin Bundy -- Phill Cargile -- Charlie Conroy -- Johnny Greco -- Song Huang -- Gourav Khullar -- Sidney Lower -- Dylan Nelson -- Imad Pasha -- Dan Weisz -- Tom Zick \ No newline at end of file +The list of contributors can be found `on GitHub `_. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7de0ee6f..a2288ed8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,26 @@ .. :changelog: +v1.4.0 (2024-07-06) ++++++++++++++++++++ +- Adds the stochastic SFH hyper-prior courtesy @jwan22 +- Adds an explicit check of any provided emission line names and fixes a bug + when multiple emission lines are ignored + +Last release before v2.0 + +`Full Changelog `_ + + +v1.3.0 (2024-03-27) ++++++++++++++++++++ +- Adds the prospector-beta SFH priors and documentation courtesy @wangbingjie +- Bugfixes in emission line masking, polynomial regularization, sfr_ratio + clipping (h/t @mjastro, @wangbingjie, @davidjsetton) +- Documentation updates + +`Full Changelog `_ + + v1.2.0 (2022-12-31) +++++++++++++++++++ @@ -10,7 +31,7 @@ v1.2.0 (2022-12-31) - Fixes to the dynesty interface for dynesty >= 2.0 (h/t @mjastro) - Fix sign error in Powell minimization (h/t @blanton144) - Fix bugs in parameter template for emission line fitting. -- numeropus documentation updates including nebular emission details. +- numerous documentation updates including nebular emission details. v1.1.0 (2022-02-20) diff --git a/README.md b/README.md index 60ed8ce0..80fb0c6a 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,122 @@ ========== +Version 2.0! (in progress) +-------------------------- + +This is a major refactor to allow for multiple datasets (i.e. multiple spectra) +with different noise models and instrument parameters to constrain a single +galaxy model. Other updates may include cleaner stored outputs, interfaces with +additional nested samplers, and improved tretments of smoothing. + +Work to do includes: + +- [x] Convert to/from old style observation dictionaries +- [x] Put responsibility for Noise Models including outlier modeling in individual Observation instances +- [x] Make predictions even when there is no fittable data (e.g. model spectra when fitting photometry only) +- [x] Store Observation objects in HDF5, FITS, etc as structured arrays with metadata +- [x] Catch (and handle) emission line marginalization if spectra overlap. +- [x] Structured ndarray for output chains and lnlikehoods +- [x] Update docs +- [x] Update demo scripts +- [x] Account for undersampled spectra via explicit rebinning. +- [ ] Account for undersampled spectra via a square convolution in pixel space. +- [x] Implement UltraNest and Nautilus backends +- [x] Test i/o with structured arrays +- [ ] Structured ndarray for derived parameters +- [ ] Store samples of spectra, photometry, and mfrac (blobs) +- [ ] Update notebooks +- [ ] Update plotting module +- [ ] Test multi-spectral calibration, smoothing, and noise modeling +- [ ] Test smoothing accounting for library, instrumental & physical smoothing +- [ ] Implement an emulator-based SpecModel class + +Nebular emission is computed using [`cuejax`](https://github.com/efburnham/cue), a jax-compatible version of [`cue`](https://github.com/yi-jia-li/cue) ([Li et al. 2024](https://ui.adsabs.harvard.edu/abs/2025ApJ...986....9L/abstract)). + +To install the `cuejax` version adapted to work with this branch of `prospector`: +```bash +git clone https://github.com/yi-jia-li/cue.git +cd cue +git checkout cuejax +pip install . +``` + +The ionizing spectrum that powers the nebular emission can either be modeled as a 4-segment power-law or fixed to the spectrum of stellar populations ([example](https://github.com/yi-jia-li/prospector/tree/add_cue/demo/prospector+cue.ipynb)): +```py +from prospect.models.templates import TemplateLibrary +TemplateLibrary["cue_nebular"] # Uses a power-law ionizing spectrum +TemplateLibrary["cue_stellar_nebular"] # Uses the stellar ionizing spectrum +``` +**Note:** Dust emission is not yet fully implemented for compatibility with nebular emission. + +Migration from < v2.0 +--------------------- + +For many users the primary difference from v1.X will be that the data to predict +and fit a model to is now specified as a list of +`prospect.observation.Observation()` instances, instead of a single 'obs' +dictionary. There is a new convenience method to convert from the old 'obs' +dictionary format to the new specification. This can be used with existing +scripts as follows: + +```py +# old build_obs function giving a dictionary +obs_dict = build_obs(**run_params) +# get convenience method +from prospect.observation import from_oldstyle +# make a new list of Observation instances from the dictionary +observations = from_oldstyle(obs_dict) +# verify and prepare for fitting; similar to 'obsutils.fix_obs()' +[obs.rectify() for obs in observations] +print(observations) +``` + +It is recommended to do the conversion within the `build_obs()` method, if +possible. This list of observations is then supplied to `fit_model`. Because +noise models are now attached explicitly to each observation, they do not need +to be generated separately or supplied to `fit_model()`, which no longer accepts +a `noise=` argument. For outlier models, the the noise model should be +instantiated with names for the outlier model that correspond to fixed or free +parameters of the model. + +```py +from prospect.fitting import fit_model +output = fit_model(observations, model, sps, **config) +``` + +Another change is that spectral response functions (i.e. calibration vectors) +are now handled by specialized sub-classes of these `Observation` classes. See +the [spectroscopy docs](doc/spectra.rst) for details. + +The interface to `write_model` has been changed and simplified. See +[usage](doc/usage.rst) for details. + +Finally, the output chain or samples is now stored as a structured array, where +each row corresponds to a sample, and each column is a parameter (possibly +multidimensional). Additional information (such as sample weights, likelihoods, +and poster probabilities) are stored as additional datasets in the output. The +`unstructured_chain` dataset of the output contains an old-style simple +`numpy.ndarray` of shape `(nsample, ndim)` + + +Purpose +------- + [![Docs](https://readthedocs.org/projects/prospect/badge/?version=latest)](https://readthedocs.org/projects/prospect/badge/?version=latest) +[![Tests](https://github.com/bd-j/prospector/workflows/Tests/badge.svg)](https://github.com/bd-j/prospector/actions?query=workflow%3ATests) [![arXiv](https://img.shields.io/badge/arXiv-2012.01426-b31b1b.svg)](https://arxiv.org/abs/2012.01426) [![LICENSE](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/bd-j/prospector/blob/main/LICENSE) -Purpose -------- + Conduct principled inference of stellar population properties from photometric and/or spectroscopic data. Prospector allows you to: * Infer high-dimensional stellar population properties using parametric or highly flexible SFHs (with nested or ensemble Monte Carlo sampling) -* Combine photometric and spectroscopic data from the UV to Far-IR rigorously - using a flexible spectroscopic calibration model and forward modeling many - aspects of spectroscopic data analysis. +* Combine multiple photometric, spectroscopic, and/or line flux datasets from + the UV to Far-IR rigorously using a flexible spectroscopic calibration model + and forward modeling many aspects of spectroscopic data analysis. Read the [documentation](http://prospect.readthedocs.io/en/latest/) and the code [paper](https://ui.adsabs.harvard.edu/abs/2021ApJS..254...22J/abstract). diff --git a/conda_install.sh b/conda_install.sh index 19bb4d45..3b9e5f07 100644 --- a/conda_install.sh +++ b/conda_install.sh @@ -9,10 +9,9 @@ cd $CODEDIR git clone https://github.com/cconroy20/fsps.git export SPS_HOME="$PWD/fsps" -# Create and activate environment (named 'prospector') git clone https://github.com/bd-j/prospector.git cd prospector -conda env create -f environment.yml +conda env create -f environment.yml -n prospector conda activate prospector python -m pip install . diff --git a/demo/demo_mock_params.py b/demo/demo_mock_params.py index b1d927c0..a8cbea57 100644 --- a/demo/demo_mock_params.py +++ b/demo/demo_mock_params.py @@ -18,56 +18,9 @@ spitzer = ['spitzer_irac_ch'+n for n in '1234'] -# -------------- -# RUN_PARAMS -# When running as a script with argparsing, these are ignored. Kept here for backwards compatibility. -# -------------- - -run_params = {'verbose': True, - 'debug': False, - 'outfile': 'output/demo_mock', - 'output_pickles': False, - # Optimization parameters - 'do_powell': False, - 'ftol': 0.5e-5, 'maxfev': 5000, - 'do_levenberg': True, - 'nmin': 10, - # emcee Fitter parameters - 'nwalkers': 64, - 'nburn': [32, 32, 64], - 'niter': 256, - 'interval': 0.25, - 'initial_disp': 0.1, - # dynesty Fitter parameters - 'nested_bound': 'multi', # bounding method - 'nested_sample': 'unif', # sampling method - 'nested_nlive_init': 100, - 'nested_nlive_batch': 100, - 'nested_bootstrap': 0, - 'nested_dlogz_init': 0.05, - 'nested_weight_kwargs': {"pfrac": 1.0}, - 'nested_target_n_effective': 10000, - # Mock data parameters - 'snr': 20.0, - 'add_noise': False, - 'filterset': galex + sdss + twomass, - # Input mock model parameters - 'mass': 1e10, - 'logzsol': -0.5, - 'tage': 12., - 'tau': 3., - 'dust2': 0.3, - 'zred': 0.1, - 'add_neb': False, - # SPS parameters - 'zcontinuous': 1, - } - - -# -------------- +# ---------------- # Model Definition -# -------------- - +# ---------------- def build_model(zred=0.0, add_neb=True, **extras): """Instantiate and return a ProspectorParams model subclass. @@ -116,12 +69,12 @@ def build_model(zred=0.0, add_neb=True, **extras): # --- Set initial values --- model_params["zred"]["init"] = zred - return sedmodel.SedModel(model_params) + return sedmodel.SpecModel(model_params) + # ------------------ # Observational Data # ------------------ - def build_obs(snr=10.0, filterset=["sdss_g0", "sdss_r0"], add_noise=True, **kwargs): """Make a mock dataset. Feel free to add more complicated kwargs, and put @@ -139,57 +92,53 @@ def build_obs(snr=10.0, filterset=["sdss_g0", "sdss_r0"], :param add_noise: (optional, boolean, default: True) If True, add a realization of the noise to the mock spectrum """ - from prospect.utils.obsutils import fix_obs + from prospect.observation import Photometry, Spectrum # We'll put the mock data in this dictionary, just as we would for real # data. But we need to know which bands (and wavelengths if doing # spectroscopy) in which to generate mock data. - mock = {} - mock['wavelength'] = None # No spectrum - mock['spectrum'] = None # No spectrum - mock['filters'] = load_filters(filterset) + smock = Spectrum() # no spectrum + pmock = Photometry(filters=filterset) # We need the models to make a mock sps = build_sps(**kwargs) - mod = build_model(**kwargs) + mock_model = build_model(**kwargs) # Now we get the mock params from the kwargs dict params = {} - for p in mod.params.keys(): + for p in mock_model.params.keys(): if p in kwargs: params[p] = np.atleast_1d(kwargs[p]) - # And build the mock - mod.params.update(params) - spec, phot, _ = mod.mean_model(mod.theta, mock, sps=sps) + # And build the mock spectrum and photometry + mock_model.params.update(params) + mock_theta = mock_model.theta + (spec, phot), _ = mock_model.predict(mock_theta, [smock, pmock], sps=sps) # Now store some ancillary, helpful info; # this information is not required to run a fit. - mock['true_spectrum'] = spec.copy() - mock['true_maggies'] = phot.copy() - mock['mock_params'] = deepcopy(mod.params) - mock['mock_snr'] = snr - mock["phot_wave"] = np.array([f.wave_effective for f in mock["filters"]]) + mock_info = dict(true_spectrum=spec.copy(), true_phot=phot.copy(), + mock_params=deepcopy(mock_model.params), mock_theta=mock_theta.copy(), + mock_snr=snr, mock_filters=filterset) # And store the photometry, adding noise if desired + pmock.flux = phot.copy() pnoise_sigma = phot / snr + pmock.uncertainty = pnoise_sigma if add_noise: pnoise = np.random.normal(0, 1, len(phot)) * pnoise_sigma - mock['maggies'] = phot + pnoise - else: - mock['maggies'] = phot.copy() - mock['maggies_unc'] = pnoise_sigma - mock['phot_mask'] = np.ones(len(phot), dtype=bool) + pmock.flux += pnoise + mock_info["noise_realization"] = pnoise + + # This ensures all required keys are present for fitting + pmock.rectify() - # This ensures all required keys are present - mock = fix_obs(mock) + return [smock, pmock], mock_info - return mock # -------------- # SPS Object # -------------- - def build_sps(zcontinuous=1, **extras): """Instantiate and return the Stellar Population Synthesis object. @@ -207,21 +156,28 @@ def build_sps(zcontinuous=1, **extras): compute_vega_mags=False) return sps + # ----------------- -# Noise Model +# Noise Modeling? # ------------------ +def build_noise(observations, **extras): + # use the defaults + return observations -def build_noise(**extras): - return None, None # ----------- # Everything # ------------ +def build_all(config): -def build_all(**kwargs): + observations, mock_info = build_obs(**config) + observations = build_noise(observations, **config) + model = build_model(**config) + sps = build_sps(**config) - return (build_obs(**kwargs), build_model(**kwargs), - build_sps(**kwargs), build_noise(**kwargs)) + config["mock_info"] = mock_info + + return (observations, model, sps) if __name__ == '__main__': @@ -251,27 +207,36 @@ def build_all(**kwargs): parser.add_argument('--mass', type=float, default=1e10, help="Stellar mass of the mock; solar masses formed") + # --- Configure --- args = parser.parse_args() - run_params = vars(args) - obs, model, sps, noise = build_all(**run_params) - - run_params["sps_libraries"] = sps.ssp.libraries - run_params["param_file"] = __file__ + config = vars(args) + config["param_file"] = __file__ + # --- Get fitting ingredients --- + obs, model, sps = build_all(config) + config["sps_libraries"] = sps.ssp.libraries print(model) if args.debug: sys.exit() - #hfile = setup_h5(model=model, obs=obs, **run_params) - hfile = "{0}_{1}_mcmc.h5".format(args.outfile, int(time.time())) - output = fit_model(obs, model, sps, noise, **run_params) + # --- Set up output --- + ts = time.strftime("%y%b%d-%H.%M", time.localtime()) + hfile = f"{args.outfile}_{ts}_result.h5" + + # --- Run the actual fit --- + output = fit_model(obs, model, sps, **config) + + print("writing to {}".format(hfile)) + writer.write_hdf5(hfile, + config, + model, + obs, + output["sampling"], + output["optimization"], + sps=sps + ) - writer.write_hdf5(hfile, run_params, model, obs, - output["sampling"][0], output["optimization"][0], - tsample=output["sampling"][1], - toptimize=output["optimization"][1], - sps=sps) try: hfile.close() diff --git a/demo/demo_mpi_params.py b/demo/demo_mpi_params.py index 8660e967..b8f6b7db 100644 --- a/demo/demo_mpi_params.py +++ b/demo/demo_mpi_params.py @@ -351,11 +351,15 @@ def build_all(**kwargs): hfile = "{0}_{1}_mcmc.h5".format(args.outfile, int(time.time())) - writer.write_hdf5(hfile, run_params, model, obs, - output["sampling"][0], output["optimization"][0], - tsample=output["sampling"][1], - toptimize=output["optimization"][1], - sps=sps) + writer.write_hdf5(hfile, + run_params, + model, + obs, + output["sampling"], + output["optimization"], + sps=sps + ) + try: hfile.close() diff --git a/demo/demo_params.py b/demo/demo_params.py index 09102465..36fa1ed2 100644 --- a/demo/demo_params.py +++ b/demo/demo_params.py @@ -8,46 +8,6 @@ from prospect.io import write_results as writer -# -------------- -# RUN_PARAMS -# When running as a script with argparsing, these are ignored. Kept here for backwards compatibility. -# -------------- - -run_params = {'verbose': True, - 'debug': False, - 'outfile': 'demo_galphot', - 'output_pickles': False, - # Optimization parameters - 'do_powell': False, - 'ftol': 0.5e-5, 'maxfev': 5000, - 'do_levenberg': True, - 'nmin': 10, - # emcee fitting parameters - 'nwalkers': 128, - 'nburn': [16, 32, 64], - 'niter': 512, - 'interval': 0.25, - 'initial_disp': 0.1, - # dynesty Fitter parameters - 'nested_bound': 'multi', # bounding method - 'nested_sample': 'unif', # sampling method - 'nested_nlive_init': 100, - 'nested_nlive_batch': 100, - 'nested_bootstrap': 0, - 'nested_dlogz_init': 0.05, - 'nested_weight_kwargs': {"pfrac": 1.0}, - 'nested_target_n_effective': 10000, - # Obs data parameters - 'objid': 0, - 'phottable': 'demo_photometry.dat', - 'luminosity_distance': 1e-5, # in Mpc - # Model parameters - 'add_neb': False, - 'add_duste': False, - # SPS parameters - 'zcontinuous': 1, - } - # -------------- # Model Definition # -------------- @@ -91,7 +51,7 @@ def build_model(object_redshift=0.0, fixed_metallicity=None, add_duste=False, # controlled by the "zred" parameter and a WMAP9 cosmology. if luminosity_distance > 0: model_params["lumdist"] = {"N": 1, "isfree": False, - "init": luminosity_distance, "units":"Mpc"} + "init": luminosity_distance, "units": "Mpc"} # Adjust model initial values (only important for optimization or emcee) model_params["dust2"]["init"] = 0.1 @@ -135,7 +95,7 @@ def build_model(object_redshift=0.0, fixed_metallicity=None, add_duste=False, model_params.update(TemplateLibrary["nebular"]) # Now instantiate the model using this new dictionary of parameter specifications - model = sedmodel.SedModel(model_params) + model = sedmodel.SpecModel(model_params) return model @@ -188,7 +148,7 @@ def build_obs(objid=0, phottable='demo_photometry.dat', # import astropy.io.fits as pyfits # catalog = pyfits.getdata(phottable) - from prospect.utils.obsutils import fix_obs + from prospect.observation import Photometry, Spectrum # Here we will read in an ascii catalog of magnitudes as a numpy structured # array @@ -214,28 +174,22 @@ def build_obs(objid=0, phottable='demo_photometry.dat', # Build output dictionary. obs = {} - # This is a list of sedpy filter objects. See the - # sedpy.observate.load_filters command for more details on its syntax. - obs['filters'] = load_filters(filternames) # This is a list of maggies, converted from mags. It should have the same - # order as `filters` above. - obs['maggies'] = np.squeeze(10**(-mags/2.5)) - # HACK. You should use real flux uncertainties - obs['maggies_unc'] = obs['maggies'] * 0.07 - # Here we mask out any NaNs or infs - obs['phot_mask'] = np.isfinite(np.squeeze(mags)) - # We have no spectrum. - obs['wavelength'] = None - obs['spectrum'] = None + # order as `filternames` above. + maggies = np.squeeze(10**(-mags/2.5)) + pdat = Photometry(filters=filternames, flux=maggies, + uncertainty=maggies * 0.07, mask=np.isfinite(maggies)) + # We have no spectral data, but we still want to see the predicted spectrum + sdat = Spectrum() # Add unessential bonus info. This will be stored in output - #obs['dmod'] = catalog[ind]['dmod'] - obs['objid'] = objid + pdat.distance_modulus = dm + pdat.objid = objid - # This ensures all required keys are present and adds some extra useful info - obs = fix_obs(obs) + # This ensures all required keys are present + pdat.rectify() - return obs + return [sdat, pdat] # -------------- # SPS Object @@ -251,24 +205,28 @@ def build_sps(zcontinuous=1, compute_vega_mags=False, **extras): # Noise Model # ------------------ -def build_noise(**extras): - return None, None +def build_noise(observations, **extras): + # use the defaults + return observations # ----------- # Everything # ------------ def build_all(**kwargs): + observations = build_obs(**kwargs) + observations = build_noise(observations, **kwargs) + model = build_model(**kwargs) + sps = build_sps(**kwargs) - return (build_obs(**kwargs), build_model(**kwargs), - build_sps(**kwargs), build_noise(**kwargs)) + return (observations, model, sps) if __name__ == '__main__': - # - Parser with default arguments - + # --- Parser with default arguments --- parser = prospect_args.get_parser() - # - Add custom arguments - + # --- Add custom arguments --- parser.add_argument('--object_redshift', type=float, default=0.0, help=("Redshift for the model")) parser.add_argument('--add_neb', action="store_true", @@ -283,30 +241,35 @@ def build_all(**kwargs): parser.add_argument('--objid', type=int, default=0, help="zero-index row number in the table to fit.") + # --- Configure --- args = parser.parse_args() - run_params = vars(args) - obs, model, sps, noise = build_all(**run_params) - - run_params["sps_libraries"] = sps.ssp.libraries - run_params["param_file"] = __file__ + config = vars(args) + config["param_file"] = __file__ + # --- Get fitting ingredients --- + obs, model, sps = build_all(**config) + config["sps_libraries"] = sps.ssp.libraries print(model) if args.debug: sys.exit() - #hfile = setup_h5(model=model, obs=obs, **run_params) + # --- Set up output --- ts = time.strftime("%y%b%d-%H.%M", time.localtime()) - hfile = "{0}_{1}_result.h5".format(args.outfile, ts) + hfile = f"{args.outfile}_{ts}_result.h5" - output = fit_model(obs, model, sps, noise, **run_params) + # --- Run the actual fit --- + output = fit_model(obs, model, sps, **config) print("writing to {}".format(hfile)) - writer.write_hdf5(hfile, run_params, model, obs, - output["sampling"][0], output["optimization"][0], - tsample=output["sampling"][1], - toptimize=output["optimization"][1], - sps=sps) + writer.write_hdf5(hfile, + config, + model, + obs, + output["sampling"], + output["optimization"], + sps=sps + ) try: hfile.close() diff --git a/demo/prospector+cue.ipynb b/demo/prospector+cue.ipynb new file mode 100644 index 00000000..ae40675f --- /dev/null +++ b/demo/prospector+cue.ipynb @@ -0,0 +1,1028 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1e1551e6", + "metadata": {}, + "source": [ + "**An example of using [`Cue`]( https://github.com/yi-jia-li/cue) to predict nebular emission in prospector**" + ] + }, + { + "cell_type": "markdown", + "id": "c9fde360", + "metadata": {}, + "source": [ + "We provide three SPS classes in [nebssp_basis.py](https://github.com/yi-jia-li/prospector/tree/add_cue/prospect/sources/nebssp_basis.py) that use the nebular continuum and emission lines predicted by Cue. Previously, nebular emission was added within FSPS using the precomputed [`CloudyFSPS`](https://github.com/nell-byler/cloudyfsps) nebular grid, which was based on SSPs and included two free parameters: the ionization parameter U and the gas-phas metallicity O/H. Switching to Cue allows additional flexibility in modeling the ionizing spectrum, gas density, N/O, and C/O, where we model the ionizing spectrum with 4-piecewise power laws.\n", + "\n", + "Dust attenuation and IGM absorption have been moved out of FSPS and are applied to the nebular emission in [fake_fsps.py](https://github.com/yi-jia-li/prospector/tree/add_cue/prospect/sources/fake_fsps.py). Dust emission has not yet been fully implemented." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d93f5b30", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:21:56.591776Z", + "start_time": "2025-06-13T13:21:48.751716Z" + } + }, + "outputs": [], + "source": [ + "# NebStepBasis and NebSSPBasis are adapted from FastStepBasis, SSPBasis to use Cue predictions\n", + "from prospect.sources.nebssp_basis import NebStepBasis, NebSSPBasis\n", + "import numpy as np\n", + "from matplotlib import pyplot as plt\n", + "\n", + "from sedpy import observate\n", + "from prospect.observation import from_oldstyle\n", + "from prospect.models.templates import TemplateLibrary\n", + "from prospect.models.sedmodel import SpecModel" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1183eeea", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:21:56.635596Z", + "start_time": "2025-06-13T13:21:56.596694Z" + } + }, + "outputs": [], + "source": [ + "# Build obs with a wide wavelength grid\n", + "def build_obs(filts):\n", + " obs = dict(filters=filts,\n", + " wavelength=np.linspace(3000, 30000, 1000),\n", + " spectrum=np.ones(1000),\n", + " unc=np.ones(1000)*0.1,\n", + " maggies=np.ones(len(filts))*1e-7,\n", + " maggies_unc=np.ones(len(filts))*1e-8)\n", + " sdat, pdat = from_oldstyle(obs)\n", + " obslist = [sdat, pdat]\n", + " [obs.rectify() for obs in obslist]\n", + " return obslist\n", + "\n", + "filters = observate.load_filters([f\"sdss_{b}0\" for b in \"ugriz\"])\n", + "obslist = build_obs(filters)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "d8f32478", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:22:25.137793Z", + "start_time": "2025-06-13T13:21:56.639786Z" + } + }, + "outputs": [], + "source": [ + "# Build SPS with cue SPS classes\n", + "def get_sps():\n", + " sps = NebStepBasis(zcontinuous=1)\n", + " return sps\n", + "sps = get_sps()" + ] + }, + { + "cell_type": "markdown", + "id": "581d24c1", + "metadata": {}, + "source": [ + "Cue parameters and their priors are in:\n", + "```py\n", + "TemplateLibrary[\"cue_stellar_nebular\"]\n", + "TemplateLibrary[\"cue_nebular\"]\n", + "```\n", + "\n", + "Using the `cue_stellar_nebular` option fixes the ionizing sources to the stellar populations. In this case, we fit the ionizing spectra of the young and old stellar population to get the power-law parameters and the nebular emission is modeled with 5 free paramters: \n", + "- gas_logu, gas_lognH, gas_logz, gas_logno, gas_logco.\n", + "\n", + "Using the option `cue_nebular` fits the ionizing spectrum. In this case, the nebular emission is modeled with 13 free paramters: \n", + "- 5 parameters for the gas properties (same as above)\n", + "- 8 parameters describing the ionizing radiation \n", + " - power-law index: ionspec_index1, ionspec_index2, ionspec_index3, ionspec_index4\n", + " - ratios of luminosities between adjacent bins: ionspec_logLratio1, ionspec_logLratio2, ionspec_logLratio3\n", + " - total ionizing photon output used to scale the cue prediction: gas_logqion\n", + "\n", + "The option `use_eline_nn_unc` adds uncertainties from cue to the likelihood when fitting emission lines. This is set to False by default." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e867c8c5", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:22:25.162359Z", + "start_time": "2025-06-13T13:22:25.143613Z" + }, + "scrolled": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + ":::::::\n", + "\n", + "\n", + "Free Parameters: (name: prior) \n", + "-----------\n", + " logzsol: (mini=-2,maxi=0.19)\n", + " dust2: (mini=0.0,maxi=2.0)\n", + " logmass: (mini=7,maxi=12)\n", + " logsfr_ratios: (mean=[0. 0.],scale=[0.3 0.3],df=[2 2])\n", + " gas_logz: (mini=-2.2,maxi=0.5)\n", + " gas_logu: (mini=-4.0,maxi=-1.0)\n", + " gas_lognH: (mini=1.0,maxi=4.0)\n", + " gas_logno: (mini=-1.0,maxi=0.7323937598229685)\n", + " gas_logco: (mini=-1.0,maxi=0.7323937598229685)\n", + "\n", + "Fixed Parameters: (name: value [, depends_on]) \n", + "-----------\n", + " zred: [1.] \n", + " mass: [1000000.] \n", + " sfh: [3] \n", + " imf_type: [2] \n", + " dust_type: [0] \n", + " agebins: [[ 0. 8.]\n", + " [ 8. 9.]\n", + " [ 9. 10.]] \n", + " add_neb_emission: [ True] \n", + " add_neb_continuum: [ True] \n", + " nebemlineinspec: [False] \n", + " use_eline_nn_unc: [False] \n", + " use_stellar_ionizing: [ True] " + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Build a model with flexible SFH, dust, and nebular emission\n", + "model_pars = TemplateLibrary[\"continuity_sfh\"]\n", + "model_pars[\"zred\"][\"init\"] = 1.0\n", + "model_pars.update(TemplateLibrary[\"cue_stellar_nebular\"])\n", + "model_pars[\"nebemlineinspec\"][\"init\"] = False\n", + "model_pars['use_eline_nn_unc'][\"init\"] = False\n", + "model = SpecModel(model_pars)\n", + "model" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "bdc8ad5d", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:22:25.181192Z", + "start_time": "2025-06-13T13:22:25.168693Z" + }, + "code_folding": [ + 0 + ], + "scrolled": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "zred 1.0\n", + "mass 1000000.0\n", + "logzsol -0.5\n", + "dust2 0.6\n", + "sfh 3\n", + "imf_type 2\n", + "dust_type 0\n", + "logmass 10\n", + "agebins [[ 0. 8.]\n", + " [ 8. 9.]\n", + " [ 9. 10.]]\n", + "logsfr_ratios [0. 0.]\n", + "add_neb_emission True\n", + "add_neb_continuum True\n", + "nebemlineinspec False\n", + "use_eline_nn_unc False\n", + "use_stellar_ionizing True\n", + "gas_logz 0.0\n", + "gas_logu -2.0\n", + "gas_lognH 2.0\n", + "gas_logno 0.0\n", + "gas_logco 0.0\n" + ] + } + ], + "source": [ + "# Model parameters\n", + "for k in model.params:\n", + " print(k, model.params[k].squeeze())" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "1b23e666", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:23:26.038297Z", + "start_time": "2025-06-13T13:22:25.187781Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "([array([3.32612734e-11, 3.34332987e-11, 3.36741710e-11, 2.61985867e-11,\n", + " 3.72183178e-11, 3.29318925e-11, 3.48133953e-11, 3.61936374e-11,\n", + " 3.55829416e-11, 3.46668826e-11, 3.60668389e-11, 3.77962145e-11,\n", + " 3.72069220e-11, 3.67232955e-11, 3.85776440e-11, 3.79396438e-11,\n", + " 3.53451581e-11, 3.87719199e-11, 3.81363788e-11, 3.95781581e-11,\n", + " 3.82215038e-11, 3.96516886e-11, 4.09839238e-11, 4.02916679e-11,\n", + " 3.96793528e-11, 4.02172005e-11, 3.79197082e-11, 4.00367562e-11,\n", + " 4.12465081e-11, 3.83181872e-11, 4.06302829e-11, 4.06338984e-11,\n", + " 3.95996814e-11, 4.25258434e-11, 4.14917596e-11, 4.36387153e-11,\n", + " 4.25311757e-11, 4.13927101e-11, 4.22285448e-11, 4.32672487e-11,\n", + " 4.14802306e-11, 4.38709008e-11, 4.33839027e-11, 4.45115256e-11,\n", + " 4.38628090e-11, 4.37768526e-11, 4.57835886e-11, 4.43125782e-11,\n", + " 4.54587921e-11, 4.55901004e-11, 4.50646474e-11, 4.57683708e-11,\n", + " 4.66663216e-11, 4.51069787e-11, 4.58706219e-11, 4.60621828e-11,\n", + " 4.50451712e-11, 4.68762419e-11, 4.73393019e-11, 4.56895606e-11,\n", + " 4.64332555e-11, 4.62403240e-11, 4.52153895e-11, 4.58615975e-11,\n", + " 4.50833055e-11, 4.49921103e-11, 4.55488049e-11, 4.46825042e-11,\n", + " 4.68210506e-11, 4.60633751e-11, 4.73996844e-11, 4.78546334e-11,\n", + " 4.71804749e-11, 4.73398245e-11, 4.82436931e-11, 4.75138139e-11,\n", + " 4.61743406e-11, 4.62934603e-11, 4.87026999e-11, 4.94673693e-11,\n", + " 4.98445445e-11, 4.95687109e-11, 4.82498217e-11, 4.98757761e-11,\n", + " 5.11354910e-11, 5.43581396e-11, 5.33379939e-11, 5.39296628e-11,\n", + " 5.44002612e-11, 5.42545543e-11, 5.26268910e-11, 5.29874880e-11,\n", + " 5.11242024e-11, 5.19786012e-11, 5.38554093e-11, 5.38564041e-11,\n", + " 4.79091738e-11, 5.45548116e-11, 5.77124625e-11, 5.62908247e-11,\n", + " 5.40522669e-11, 5.76984294e-11, 5.71965926e-11, 5.88548453e-11,\n", + " 6.25226706e-11, 6.30423899e-11, 6.17302172e-11, 6.15650760e-11,\n", + " 6.28135904e-11, 6.25687682e-11, 6.13830601e-11, 6.32568766e-11,\n", + " 6.50322783e-11, 6.53614638e-11, 6.56783141e-11, 6.69277464e-11,\n", + " 6.62575921e-11, 6.81368817e-11, 6.54889009e-11, 6.98360985e-11,\n", + " 6.80608737e-11, 6.89603774e-11, 6.97986805e-11, 6.79190093e-11,\n", + " 7.26585990e-11, 6.95334082e-11, 7.36352232e-11, 7.12860418e-11,\n", + " 7.04852694e-11, 7.43025265e-11, 7.65618799e-11, 7.68560281e-11,\n", + " 7.68102598e-11, 8.10721185e-11, 7.80218336e-11, 7.81431206e-11,\n", + " 7.81536659e-11, 7.87185875e-11, 7.64529855e-11, 7.84887691e-11,\n", + " 7.89451254e-11, 8.08656921e-11, 8.05237334e-11, 8.32686286e-11,\n", + " 8.05041011e-11, 8.30847404e-11, 8.46647121e-11, 8.72101515e-11,\n", + " 8.66449319e-11, 8.68822093e-11, 8.46304751e-11, 9.07829880e-11,\n", + " 8.95182137e-11, 8.62034722e-11, 8.47241222e-11, 9.06580501e-11,\n", + " 8.97857376e-11, 9.09107781e-11, 9.13029745e-11, 8.46655224e-11,\n", + " 8.93360741e-11, 8.57893790e-11, 8.45561353e-11, 8.67467091e-11,\n", + " 9.07875502e-11, 1.04791164e-10, 1.00220742e-10, 1.06799451e-10,\n", + " 9.75844703e-11, 1.34431092e-10, 1.00052268e-10, 1.34901195e-10,\n", + " 1.23675515e-10, 9.46073615e-11, 1.35438349e-10, 1.64194615e-10,\n", + " 1.29574880e-10, 1.12903558e-10, 1.40267677e-10, 1.49882952e-10,\n", + " 1.23680249e-10, 1.45332501e-10, 1.36312007e-10, 9.85706782e-11,\n", + " 1.53226309e-10, 1.66892255e-10, 1.71124642e-10, 1.69674778e-10,\n", + " 1.72844897e-10, 1.72512254e-10, 1.68461537e-10, 1.66836427e-10,\n", + " 1.50568583e-10, 1.42414642e-10, 1.67215162e-10, 1.72462930e-10,\n", + " 1.74926270e-10, 1.77577042e-10, 1.70952358e-10, 1.76467622e-10,\n", + " 1.74300358e-10, 1.78237567e-10, 1.74599509e-10, 1.80522168e-10,\n", + " 1.79591288e-10, 1.77351092e-10, 1.77309236e-10, 1.65995164e-10,\n", + " 1.61086870e-10, 1.72509254e-10, 1.55132404e-10, 1.60358154e-10,\n", + " 1.85605628e-10, 1.83558098e-10, 1.82441715e-10, 1.85886055e-10,\n", + " 1.92691424e-10, 1.95985928e-10, 1.96476758e-10, 1.91165037e-10,\n", + " 2.01554710e-10, 2.01060463e-10, 2.07780182e-10, 2.12081750e-10,\n", + " 2.00948984e-10, 2.03976359e-10, 2.04183012e-10, 2.10608807e-10,\n", + " 2.07305263e-10, 2.07664329e-10, 2.12471165e-10, 2.15137138e-10,\n", + " 2.16588049e-10, 2.16385258e-10, 2.17041979e-10, 2.18188993e-10,\n", + " 2.21501007e-10, 2.19507031e-10, 2.21792326e-10, 2.21566841e-10,\n", + " 2.24008501e-10, 2.25446307e-10, 2.24947515e-10, 2.26557663e-10,\n", + " 2.27027493e-10, 2.28014890e-10, 2.26732915e-10, 2.20951396e-10,\n", + " 2.05741889e-10, 2.24931958e-10, 2.12386367e-10, 2.21470393e-10,\n", + " 2.29719651e-10, 2.26416513e-10, 2.31920478e-10, 2.34906875e-10,\n", + " 3.28770247e-10, 2.35254436e-10, 2.28517449e-10, 2.30487655e-10,\n", + " 4.41414230e-10, 2.32621977e-10, 2.30117188e-10, 2.38700520e-10,\n", + " 2.38068426e-10, 2.32871365e-10, 2.35148858e-10, 2.35807372e-10,\n", + " 2.38146883e-10, 2.34698529e-10, 2.32657026e-10, 2.31276897e-10,\n", + " 2.24150199e-10, 2.32308336e-10, 2.35156622e-10, 2.40674481e-10,\n", + " 2.39580250e-10, 2.50020258e-10, 2.47121311e-10, 2.35373841e-10,\n", + " 2.52069612e-10, 2.54659328e-10, 2.59261298e-10, 2.49709133e-10,\n", + " 2.54316240e-10, 2.60405257e-10, 2.61021613e-10, 2.61349005e-10,\n", + " 2.58025368e-10, 2.52993536e-10, 2.62811065e-10, 2.59155050e-10,\n", + " 2.65039015e-10, 2.65300839e-10, 2.63494734e-10, 2.63617377e-10,\n", + " 2.67089896e-10, 2.65888508e-10, 2.68407935e-10, 2.75184081e-10,\n", + " 2.75472436e-10, 2.70373808e-10, 2.72114385e-10, 2.66073493e-10,\n", + " 2.75807107e-10, 2.74900187e-10, 2.79294407e-10, 2.77213800e-10,\n", + " 2.73899788e-10, 2.81800042e-10, 2.80188796e-10, 2.77937832e-10,\n", + " 2.80056496e-10, 2.88130440e-10, 2.88670896e-10, 2.86896824e-10,\n", + " 2.90382970e-10, 2.84576405e-10, 2.90671905e-10, 2.92270878e-10,\n", + " 2.96549343e-10, 2.95748719e-10, 2.91984867e-10, 2.92247464e-10,\n", + " 2.99273084e-10, 2.79858561e-10, 2.91420090e-10, 2.93995272e-10,\n", + " 2.97498569e-10, 2.97873454e-10, 2.99109292e-10, 3.01708496e-10,\n", + " 2.98659892e-10, 3.00341295e-10, 2.98876088e-10, 3.01539042e-10,\n", + " 3.07888426e-10, 3.07270079e-10, 3.06490931e-10, 3.07474035e-10,\n", + " 3.06329889e-10, 3.05779681e-10, 3.06665475e-10, 3.02514992e-10,\n", + " 3.05212945e-10, 3.00664699e-10, 3.04345578e-10, 3.07537167e-10,\n", + " 3.10010385e-10, 3.05068465e-10, 3.04606116e-10, 3.03209416e-10,\n", + " 3.01595138e-10, 3.09608827e-10, 3.13521048e-10, 3.13473395e-10,\n", + " 3.21851459e-10, 3.14440292e-10, 3.16210879e-10, 3.18092237e-10,\n", + " 3.17601025e-10, 3.22164570e-10, 3.22044841e-10, 3.20170155e-10,\n", + " 3.20228189e-10, 3.22661043e-10, 3.24111354e-10, 3.20379973e-10,\n", + " 3.25410697e-10, 3.25194617e-10, 3.18818085e-10, 3.29175070e-10,\n", + " 3.30176237e-10, 3.26978527e-10, 4.00987508e-10, 2.62199297e-09,\n", + " 5.91345875e-10, 3.27549552e-10, 3.31690875e-10, 3.36155304e-10,\n", + " 3.33892069e-10, 3.36356068e-10, 3.35388134e-10, 3.44570711e-10,\n", + " 3.38169181e-10, 3.37128476e-10, 4.61759273e-10, 4.83992808e-10,\n", + " 3.38020161e-10, 3.39143771e-10, 3.38865455e-10, 3.42309037e-10,\n", + " 3.41746619e-10, 3.41688988e-10, 3.45191848e-10, 3.43997864e-10,\n", + " 3.43820335e-10, 3.46542697e-10, 3.48284841e-10, 3.50971932e-10,\n", + " 3.51963325e-10, 3.50779053e-10, 3.51375729e-10, 3.51400192e-10,\n", + " 3.52887357e-10, 3.53120916e-10, 3.53576550e-10, 3.53745145e-10,\n", + " 3.52985689e-10, 3.53634160e-10, 3.57601871e-10, 3.56934809e-10,\n", + " 3.55895536e-10, 3.54289372e-10, 3.51956669e-10, 3.51722166e-10,\n", + " 3.50502355e-10, 4.04244959e-10, 3.50882610e-10, 3.52680391e-10,\n", + " 3.54592600e-10, 3.51614061e-10, 3.54495301e-10, 3.56727326e-10,\n", + " 3.61839796e-10, 3.64056836e-10, 3.64018310e-10, 3.66508552e-10,\n", + " 3.64819001e-10, 3.64545876e-10, 3.68462550e-10, 3.70971797e-10,\n", + " 3.74089034e-10, 3.73375290e-10, 3.73585632e-10, 3.76953659e-10,\n", + " 3.73539988e-10, 3.74115779e-10, 3.71888151e-10, 3.78520659e-10,\n", + " 3.79783550e-10, 3.81400335e-10, 3.83023035e-10, 3.83868673e-10,\n", + " 3.82366828e-10, 3.83743998e-10, 3.86746191e-10, 3.90773990e-10,\n", + " 3.90903920e-10, 3.90509089e-10, 3.88973118e-10, 3.89519611e-10,\n", + " 3.89271202e-10, 3.86961073e-10, 3.91019045e-10, 3.89146287e-10,\n", + " 3.80768126e-10, 3.86266088e-10, 3.89552910e-10, 3.87656317e-10,\n", + " 3.84962796e-10, 3.90237062e-10, 3.91083774e-10, 3.93587309e-10,\n", + " 3.94623654e-10, 3.93533276e-10, 3.95217776e-10, 3.98036549e-10,\n", + " 3.98670331e-10, 3.96536171e-10, 3.98381956e-10, 4.00579061e-10,\n", + " 3.99216586e-10, 3.97384806e-10, 3.95728138e-10, 3.94124645e-10,\n", + " 3.94130670e-10, 3.92186009e-10, 3.98402164e-10, 4.02185359e-10,\n", + " 4.02599913e-10, 4.02400328e-10, 4.05587483e-10, 4.05350996e-10,\n", + " 4.05871171e-10, 4.06257103e-10, 4.06846365e-10, 4.04646150e-10,\n", + " 4.04678097e-10, 4.07194153e-10, 4.09134722e-10, 4.11629256e-10,\n", + " 4.12717349e-10, 4.13578642e-10, 4.12345907e-10, 4.07015620e-10,\n", + " 4.03844825e-10, 4.05401476e-10, 4.08298780e-10, 4.09631506e-10,\n", + " 4.11250911e-10, 4.12896273e-10, 4.12404441e-10, 4.10050959e-10,\n", + " 4.08800321e-10, 4.07461632e-10, 4.05739745e-10, 4.09389852e-10,\n", + " 4.10110358e-10, 4.08919552e-10, 4.08743560e-10, 4.13829894e-10,\n", + " 4.13043189e-10, 4.08113595e-10, 4.08510616e-10, 4.12402857e-10,\n", + " 4.11260637e-10, 4.11321804e-10, 3.96171003e-10, 4.05528574e-10,\n", + " 4.07523215e-10, 3.75775736e-10, 3.90894675e-10, 4.20569625e-10,\n", + " 4.27386901e-10, 4.18954456e-10, 4.19261539e-10, 4.26594166e-10,\n", + " 4.31442737e-10, 4.20722247e-10, 3.87582003e-10, 4.02165029e-10,\n", + " 4.23274523e-10, 4.32370575e-10, 4.35783006e-10, 4.35513153e-10,\n", + " 4.26680722e-10, 4.22350513e-10, 4.33360450e-10, 4.41001919e-10,\n", + " 4.39861641e-10, 4.38088371e-10, 4.42227676e-10, 4.42627715e-10,\n", + " 4.36808653e-10, 4.24907864e-10, 4.32298309e-10, 4.42869821e-10,\n", + " 4.46694323e-10, 4.46608727e-10, 4.46486751e-10, 4.47471823e-10,\n", + " 4.49888152e-10, 4.48926064e-10, 4.47444443e-10, 4.39779194e-10,\n", + " 4.28831767e-10, 4.33953146e-10, 4.49818512e-10, 4.54907907e-10,\n", + " 7.75613370e-10, 4.55697583e-10, 4.48960168e-10, 4.53273214e-10,\n", + " 4.56604638e-10, 4.55922005e-10, 4.52970790e-10, 4.50222174e-10,\n", + " 4.47897596e-10, 4.46072801e-10, 4.41608231e-10, 4.34431507e-10,\n", + " 4.42229635e-10, 4.34705443e-10, 4.42249938e-10, 4.48509397e-10,\n", + " 4.52288529e-10, 4.53848866e-10, 4.55489007e-10, 4.56848027e-10,\n", + " 4.57140519e-10, 4.55240976e-10, 4.53081245e-10, 4.52990227e-10,\n", + " 4.52980030e-10, 4.51230118e-10, 4.50860172e-10, 4.52399280e-10,\n", + " 4.53865670e-10, 4.56236864e-10, 4.58616534e-10, 4.59836238e-10,\n", + " 4.60306472e-10, 4.59703818e-10, 6.11456675e-10, 5.41398951e-10,\n", + " 4.55379487e-10, 4.54843979e-10, 4.61057697e-10, 4.64210351e-10,\n", + " 4.65018549e-10, 4.64771346e-10, 4.63632105e-10, 4.62866231e-10,\n", + " 4.63092474e-10, 4.64183429e-10, 4.65428896e-10, 4.66590350e-10,\n", + " 4.67131280e-10, 4.66536727e-10, 4.66211737e-10, 4.67346939e-10,\n", + " 4.68093972e-10, 4.68362822e-10, 4.70702060e-10, 4.74208356e-10,\n", + " 4.76189475e-10, 4.76969493e-10, 4.78287415e-10, 4.78019310e-10,\n", + " 4.78253932e-10, 4.77894190e-10, 4.77692309e-10, 4.77974591e-10,\n", + " 4.78798468e-10, 4.80164659e-10, 4.81606731e-10, 4.82281277e-10,\n", + " 4.82068121e-10, 4.81584618e-10, 4.80730273e-10, 4.77253677e-10,\n", + " 4.70657299e-10, 5.90248461e-10, 4.71914851e-10, 4.79504137e-10,\n", + " 4.84843945e-10, 4.87548630e-10, 4.88007364e-10, 4.87603049e-10,\n", + " 4.87102633e-10, 4.88024781e-10, 4.89602992e-10, 4.90358971e-10,\n", + " 4.90435868e-10, 4.90840031e-10, 4.92286409e-10, 4.94040867e-10,\n", + " 4.94529767e-10, 4.94462165e-10, 4.94764870e-10, 4.95147279e-10,\n", + " 4.95049331e-10, 5.00455686e-10, 4.91309422e-10, 4.92061863e-10,\n", + " 4.93952548e-10, 4.94378702e-10, 4.94514061e-10, 4.95828534e-10,\n", + " 4.97583584e-10, 4.98699579e-10, 4.99231177e-10, 4.98749527e-10,\n", + " 4.98692305e-10, 4.99320433e-10, 4.99813358e-10, 5.00469736e-10,\n", + " 5.01129290e-10, 5.01855954e-10, 5.02433820e-10, 5.01407225e-10,\n", + " 4.98959842e-10, 4.97805954e-10, 4.99378860e-10, 5.02248690e-10,\n", + " 5.04418603e-10, 5.03669074e-10, 5.01098970e-10, 4.99169162e-10,\n", + " 4.98712274e-10, 5.00345259e-10, 5.01522977e-10, 5.01260777e-10,\n", + " 5.01890956e-10, 5.03915840e-10, 5.04915155e-10, 5.04442419e-10,\n", + " 5.04471625e-10, 5.04314981e-10, 5.18209274e-10, 6.69827597e-10,\n", + " 5.05442020e-10, 5.02916483e-10, 5.00352840e-10, 5.00480761e-10,\n", + " 5.00392327e-10, 4.94815260e-10, 4.87809987e-10, 4.87874240e-10,\n", + " 4.82726453e-10, 4.83116743e-10, 4.84691765e-10, 4.86732934e-10,\n", + " 4.88293481e-10, 4.90330275e-10, 4.93062162e-10, 4.95216089e-10,\n", + " 4.96574205e-10, 4.97600677e-10, 4.98261352e-10, 4.98302209e-10,\n", + " 4.98239688e-10, 4.98935234e-10, 5.00151574e-10, 5.01215438e-10,\n", + " 5.01824239e-10, 5.02145823e-10, 5.03326100e-10, 5.05168714e-10,\n", + " 5.05649451e-10, 5.04529726e-10, 5.03999535e-10, 5.05442159e-10,\n", + " 5.06391556e-10, 5.06059553e-10, 5.06123376e-10, 5.06566819e-10,\n", + " 5.06989013e-10, 5.07574350e-10, 5.07626295e-10, 5.06706904e-10,\n", + " 5.06140979e-10, 5.06928166e-10, 5.08594437e-10, 5.10519820e-10,\n", + " 5.12825122e-10, 5.14869058e-10, 5.15583098e-10, 5.15532830e-10,\n", + " 5.16208401e-10, 5.17558548e-10, 5.18986947e-10, 5.20463928e-10,\n", + " 5.21465753e-10, 5.21065337e-10, 5.18454903e-10, 5.14714368e-10,\n", + " 5.12985954e-10, 5.14378477e-10, 5.16769132e-10, 5.19003485e-10,\n", + " 5.20849500e-10, 5.21240077e-10, 5.21243872e-10, 5.22906118e-10,\n", + " 5.25582817e-10, 5.27057742e-10, 5.26717211e-10, 5.25087245e-10,\n", + " 5.23419247e-10, 5.23290511e-10, 5.24830279e-10, 5.25316599e-10,\n", + " 5.23757946e-10, 5.23941404e-10, 5.26889964e-10, 5.27493944e-10,\n", + " 5.24892870e-10, 5.24079532e-10, 5.27633553e-10, 5.31877377e-10,\n", + " 5.33455366e-10, 5.32112109e-10, 5.28787447e-10, 5.24957147e-10,\n", + " 5.23979599e-10, 5.27056634e-10, 5.30536197e-10, 5.31521212e-10,\n", + " 5.32095330e-10, 5.34005573e-10, 5.34839138e-10, 5.33963115e-10,\n", + " 5.33270494e-10, 5.34189055e-10, 5.36558979e-10, 5.39213413e-10,\n", + " 5.41074580e-10, 5.41759725e-10, 5.41591459e-10, 5.41392692e-10,\n", + " 5.41685972e-10, 5.42274058e-10, 5.42879601e-10, 5.43276415e-10,\n", + " 5.43014714e-10, 5.42500147e-10, 5.43045983e-10, 5.44528363e-10,\n", + " 5.45549826e-10, 5.45748946e-10, 5.45655719e-10, 5.45965104e-10,\n", + " 5.46659699e-10, 5.46984927e-10, 5.46700060e-10, 5.46289427e-10,\n", + " 5.45885735e-10, 5.46085362e-10, 5.47732314e-10, 5.49708531e-10,\n", + " 5.50863318e-10, 5.51197428e-10, 5.50764366e-10, 5.49808426e-10,\n", + " 5.49411941e-10, 5.50008874e-10, 5.50753026e-10, 5.57713970e-10,\n", + " 5.52521377e-10, 5.52943771e-10, 5.53313320e-10, 5.53394132e-10,\n", + " 5.53357115e-10, 5.53636062e-10, 5.54274187e-10, 5.55010218e-10,\n", + " 5.56090931e-10, 5.57388311e-10, 5.58163149e-10, 5.58269645e-10,\n", + " 5.58398861e-10, 5.58900467e-10, 5.58825407e-10, 5.56616813e-10,\n", + " 5.51263775e-10, 5.64960050e-10, 9.79792694e-10, 5.45818715e-10,\n", + " 5.52364192e-10, 5.57118351e-10, 5.58163221e-10, 5.57111956e-10,\n", + " 5.56613212e-10, 5.57846910e-10, 5.60009733e-10, 5.62204714e-10,\n", + " 5.63716106e-10, 5.64217504e-10, 5.64468953e-10, 5.64908560e-10,\n", + " 5.65259852e-10, 5.65629756e-10, 5.66394846e-10, 5.67476219e-10,\n", + " 5.68245002e-10, 5.68234667e-10, 5.67456715e-10, 5.65966244e-10,\n", + " 5.64294966e-10, 5.63518985e-10, 5.64160241e-10, 5.66229957e-10,\n", + " 5.68824580e-10, 5.70697655e-10, 5.71692536e-10, 5.72463025e-10,\n", + " 5.73275756e-10, 5.73407192e-10, 5.71987182e-10, 5.68903259e-10,\n", + " 5.65254217e-10, 5.63015561e-10, 5.62808929e-10, 5.63647153e-10,\n", + " 5.64486994e-10, 5.64418891e-10, 5.63447650e-10, 5.62463871e-10,\n", + " 5.62155516e-10, 5.62503020e-10, 5.63120635e-10, 5.64005704e-10,\n", + " 5.65085609e-10, 5.65766082e-10, 5.65665057e-10, 5.65040452e-10,\n", + " 5.64740397e-10, 5.65356114e-10, 5.66156259e-10, 5.66077010e-10,\n", + " 5.65306216e-10, 5.64735005e-10, 5.64621592e-10, 5.64723429e-10,\n", + " 5.64868415e-10, 5.64917353e-10, 5.65088348e-10, 5.65783867e-10,\n", + " 5.66630152e-10, 5.67030305e-10, 5.67289147e-10, 5.68042349e-10,\n", + " 5.69310673e-10, 5.70702060e-10, 5.71891037e-10, 5.72738547e-10,\n", + " 5.73364058e-10, 5.74016064e-10, 5.74678935e-10, 5.74922204e-10,\n", + " 5.74385547e-10, 5.73304922e-10, 5.72425798e-10, 5.72432667e-10,\n", + " 5.72892395e-10, 5.72392764e-10, 5.70868747e-10, 5.70318548e-10,\n", + " 5.71533286e-10, 5.73151166e-10, 5.74042216e-10, 5.74000473e-10,\n", + " 5.73290731e-10, 5.72967594e-10, 5.74016732e-10, 5.75711074e-10,\n", + " 5.76449362e-10, 5.75660177e-10, 5.74225827e-10, 5.73264779e-10,\n", + " 5.72693036e-10, 5.71669180e-10, 5.70344265e-10, 5.69948591e-10,\n", + " 5.70901242e-10, 5.72090974e-10, 5.72470955e-10, 5.72176602e-10,\n", + " 5.71715418e-10, 5.71092108e-10, 5.70429079e-10, 5.70522603e-10,\n", + " 5.71780930e-10, 5.73247250e-10, 5.74192330e-10, 5.75150385e-10,\n", + " 5.76622367e-10, 5.78238297e-10, 5.79563887e-10, 5.80620567e-10,\n", + " 5.81392735e-10, 5.81394423e-10, 5.80422509e-10, 5.79347061e-10,\n", + " 5.79213363e-10, 5.79949347e-10, 5.81426742e-10, 5.84095460e-10,\n", + " 5.86830543e-10, 5.87871488e-10, 5.87313114e-10, 5.86098812e-10,\n", + " 5.84653425e-10, 5.83044655e-10, 5.82291786e-10, 5.82983528e-10,\n", + " 5.83435196e-10, 5.81502138e-10, 5.78001571e-10, 5.76245978e-10,\n", + " 5.77288798e-10, 5.78789178e-10, 5.79554622e-10, 5.79817149e-10,\n", + " 5.80034867e-10, 5.81385641e-10, 5.83940001e-10, 5.86220206e-10,\n", + " 5.87361186e-10, 5.88078395e-10, 5.89320571e-10, 5.90943602e-10,\n", + " 5.92343941e-10, 5.93814488e-10, 5.95857228e-10, 5.97754466e-10,\n", + " 5.98347452e-10, 5.97288778e-10, 5.96172536e-10, 5.96983587e-10,\n", + " 5.99857760e-10, 6.03317323e-10, 6.05940879e-10, 6.07530544e-10,\n", + " 6.08777836e-10, 6.09848896e-10, 6.09376242e-10, 6.06023977e-10]),\n", + " array([3.86053688e-11, 4.64988371e-11, 6.70808604e-11, 1.18056933e-10,\n", + " 2.06797442e-10])],\n", + " 0.6242218508975912)" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model.predict(model.theta, obslist, sps)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f97f67bc", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:23:26.476298Z", + "start_time": "2025-06-13T13:23:26.042522Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the predicted spectrum\n", + "plt.figure(figsize=(7,4), dpi=120)\n", + "plt.scatter(obslist[1].wavelength/(1+model.params['zred']), \n", + " model.predict(model.theta, obslist, sps)[0][1], color='dodgerblue', alpha=0.6)\n", + "plt.plot(obslist[0].wavelength/(1+model.params['zred']), \n", + " model.predict(model.theta, obslist, sps)[0][0], color='C1', alpha=0.6)\n", + "plt.yscale('log')\n", + "plt.xlabel(r'rest-frame wavelength [$\\mathrm{\\AA}$]')\n", + "plt.ylabel(r'F$_\\nu$ [L$_\\odot$/Hz]');" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "53f80878", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:23:26.495515Z", + "start_time": "2025-06-13T13:23:26.482082Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + ":::::::\n", + "\n", + "\n", + "Free Parameters: (name: prior) \n", + "-----------\n", + " logzsol: (mini=-2,maxi=0.19)\n", + " dust2: (mini=0.0,maxi=2.0)\n", + " logmass: (mini=7,maxi=12)\n", + " logsfr_ratios: (mean=[0. 0.],scale=[0.3 0.3],df=[2 2])\n", + " gas_logz: (mini=-2.2,maxi=0.5)\n", + " gas_logu: (mini=-4.0,maxi=-1.0)\n", + " gas_lognH: (mini=1.0,maxi=4.0)\n", + " gas_logno: (mini=-1.0,maxi=0.7323937598229685)\n", + " gas_logco: (mini=-1.0,maxi=0.7323937598229685)\n", + " ionspec_index1: (mini=1.0,maxi=42.0)\n", + " ionspec_index2: (mini=-0.3,maxi=30.0)\n", + " ionspec_index3: (mini=-1.0,maxi=14.0)\n", + " ionspec_index4: (mini=-1.7,maxi=8.0)\n", + " ionspec_logLratio1: (mini=-1.0,maxi=10.1)\n", + " ionspec_logLratio2: (mini=-0.5,maxi=1.9)\n", + " ionspec_logLratio3: (mini=-0.4,maxi=2.2)\n", + " gas_logqion: (mini=35.0,maxi=65.0)\n", + "\n", + "Fixed Parameters: (name: value [, depends_on]) \n", + "-----------\n", + " zred: [1.] \n", + " mass: [1000000.] \n", + " sfh: [3] \n", + " imf_type: [2] \n", + " dust_type: [0] \n", + " agebins: [[ 0. 8.]\n", + " [ 8. 9.]\n", + " [ 9. 10.]] \n", + " add_neb_emission: [ True] \n", + " add_neb_continuum: [ True] \n", + " nebemlineinspec: [False] \n", + " use_eline_nn_unc: [False] \n", + " use_stellar_ionizing: [False] " + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Build a model with flexible SFH, dust, and nebular emission\n", + "model_pars = TemplateLibrary[\"continuity_sfh\"]\n", + "model_pars[\"zred\"][\"init\"] = 1.0\n", + "model_pars.update(TemplateLibrary[\"cue_nebular\"])\n", + "model_pars[\"gas_logqion\"][\"init\"] = 54.0 # set a high number of ionizing photons so the emission lines are stronger\n", + "model_pars[\"nebemlineinspec\"][\"init\"] = False\n", + "model_pars['use_eline_nn_unc'][\"init\"] = False\n", + "model = SpecModel(model_pars)\n", + "model" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "8c1b1a49", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:23:26.525653Z", + "start_time": "2025-06-13T13:23:26.499441Z" + }, + "code_folding": [ + 0 + ] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "zred 1.0\n", + "mass 1000000.0\n", + "logzsol -0.5\n", + "dust2 0.6\n", + "sfh 3\n", + "imf_type 2\n", + "dust_type 0\n", + "logmass 10\n", + "agebins [[ 0. 8.]\n", + " [ 8. 9.]\n", + " [ 9. 10.]]\n", + "logsfr_ratios [0. 0.]\n", + "add_neb_emission True\n", + "add_neb_continuum True\n", + "nebemlineinspec False\n", + "use_eline_nn_unc False\n", + "use_stellar_ionizing False\n", + "gas_logz 0.0\n", + "gas_logu -2.0\n", + "gas_lognH 2.0\n", + "gas_logno 0.0\n", + "gas_logco 0.0\n", + "ionspec_index1 3.3\n", + "ionspec_index2 15.0\n", + "ionspec_index3 8.0\n", + "ionspec_index4 3.0\n", + "ionspec_logLratio1 2.0\n", + "ionspec_logLratio2 1.0\n", + "ionspec_logLratio3 1.0\n", + "gas_logqion 54.0\n" + ] + } + ], + "source": [ + "# Model parameters\n", + "for k in model.params:\n", + " print(k, model.params[k].squeeze())" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "5c5a80f8", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:23:26.579467Z", + "start_time": "2025-06-13T13:23:26.529763Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "([array([3.92276552e-11, 3.94952600e-11, 3.98244811e-11, 3.24312388e-11,\n", + " 4.35279956e-11, 3.93136769e-11, 4.12619944e-11, 4.27053006e-11,\n", + " 4.21535344e-11, 4.12933253e-11, 4.27457497e-11, 4.45245509e-11,\n", + " 4.39781132e-11, 4.35432257e-11, 4.54395411e-11, 4.48412502e-11,\n", + " 4.22846284e-11, 4.57469263e-11, 4.51452532e-11, 4.66193524e-11,\n", + " 4.52930274e-11, 4.67523400e-11, 4.81122574e-11, 4.74463599e-11,\n", + " 4.68590494e-11, 4.74209094e-11, 4.51465510e-11, 4.75766858e-11,\n", + " 4.85163164e-11, 4.56554481e-11, 4.78858821e-11, 4.79618418e-11,\n", + " 4.69455965e-11, 4.98890244e-11, 4.88720643e-11, 5.10361395e-11,\n", + " 4.99458720e-11, 4.88252080e-11, 4.96798883e-11, 5.07387883e-11,\n", + " 4.89740536e-11, 5.13829961e-11, 5.09230743e-11, 5.20769383e-11,\n", + " 5.14551616e-11, 5.13974146e-11, 5.34338001e-11, 5.19934563e-11,\n", + " 5.31713101e-11, 5.33359068e-11, 5.28454852e-11, 5.35858214e-11,\n", + " 5.45219925e-11, 5.30024792e-11, 5.38082629e-11, 5.40440943e-11,\n", + " 5.30734289e-11, 5.49534083e-11, 5.54676005e-11, 5.38716686e-11,\n", + " 5.46716991e-11, 5.48869061e-11, 5.35753251e-11, 5.42868760e-11,\n", + " 5.35769794e-11, 5.35575177e-11, 5.41893039e-11, 5.34015972e-11,\n", + " 5.56226641e-11, 5.49502399e-11, 5.63744469e-11, 5.69210363e-11,\n", + " 5.65575244e-11, 5.66011233e-11, 5.76088563e-11, 5.69866114e-11,\n", + " 5.57593836e-11, 5.59953176e-11, 5.85252185e-11, 5.94149875e-11,\n", + " 5.99213851e-11, 5.97342089e-11, 5.84292808e-11, 6.01644642e-11,\n", + " 6.15733816e-11, 6.49516185e-11, 6.42121628e-11, 6.48523626e-11,\n", + " 6.54968521e-11, 6.55316043e-11, 6.40913056e-11, 6.46461634e-11,\n", + " 6.29843569e-11, 6.40471577e-11, 6.61397493e-11, 6.63639995e-11,\n", + " 6.10035808e-11, 6.75327823e-11, 7.09366597e-11, 6.97700620e-11,\n", + " 6.77951821e-11, 7.17134352e-11, 7.14919771e-11, 7.34396587e-11,\n", + " 7.74069600e-11, 7.82355098e-11, 7.72392512e-11, 7.74005263e-11,\n", + " 7.89854873e-11, 7.90854543e-11, 7.82558830e-11, 8.04964013e-11,\n", + " 8.26470554e-11, 8.33627530e-11, 8.40776081e-11, 8.57339963e-11,\n", + " 8.54819643e-11, 8.77920604e-11, 8.55844929e-11, 9.03757844e-11,\n", + " 8.90339885e-11, 9.03777318e-11, 9.16960491e-11, 9.03161344e-11,\n", + " 9.55673201e-11, 9.29656541e-11, 9.76061985e-11, 9.58076368e-11,\n", + " 9.55700454e-11, 9.99663694e-11, 1.02817502e-10, 1.03716309e-10,\n", + " 1.04291887e-10, 1.09188371e-10, 1.06785610e-10, 1.07571777e-10,\n", + " 1.08261026e-10, 1.09519418e-10, 1.07964096e-10, 1.10725696e-10,\n", + " 1.11915584e-10, 1.14553900e-10, 1.14451094e-10, 1.17344554e-10,\n", + " 1.15325871e-10, 1.18698286e-10, 1.21084079e-10, 1.24451769e-10,\n", + " 1.24726291e-10, 1.25817822e-10, 1.24438397e-10, 1.31480339e-10,\n", + " 1.31118954e-10, 1.28724480e-10, 1.28184087e-10, 1.35071698e-10,\n", + " 1.35169662e-10, 1.37278765e-10, 1.38140663e-10, 1.08200811e-10,\n", + " 1.00112440e-10, 9.23907471e-11, 9.09815602e-11, 9.31546658e-11,\n", + " 9.71996167e-11, 1.78257317e-10, 1.06637650e-10, 1.13216844e-10,\n", + " 1.04004912e-10, 1.40855107e-10, 1.14863323e-10, 1.41332681e-10,\n", + " 1.30111223e-10, 1.19811835e-10, 1.41883709e-10, 1.48871803e-10,\n", + " 1.36031253e-10, 1.42533259e-10, 1.46735462e-10, 1.56357151e-10,\n", + " 1.30161119e-10, 1.51820357e-10, 1.42807202e-10, 1.19725448e-10,\n", + " 1.59737247e-10, 1.73411466e-10, 1.77652360e-10, 1.76211437e-10,\n", + " 1.79390767e-10, 1.79067629e-10, 1.76834111e-10, 1.75026666e-10,\n", + " 1.57154675e-10, 1.70189148e-10, 1.73823415e-10, 1.79082853e-10,\n", + " 1.81558097e-10, 1.84221208e-10, 1.77609098e-10, 1.83137183e-10,\n", + " 1.80983416e-10, 1.84934350e-10, 1.81310217e-10, 1.87247439e-10,\n", + " 1.86331198e-10, 1.84105761e-10, 1.84079524e-10, 1.72781379e-10,\n", + " 1.67889223e-10, 1.79328096e-10, 2.01512696e-10, 1.67215533e-10,\n", + " 1.92001105e-10, 1.90446270e-10, 1.89348031e-10, 1.92811072e-10,\n", + " 1.99635534e-10, 2.02949444e-10, 2.03460048e-10, 1.98168513e-10,\n", + " 2.10163293e-10, 2.08105207e-10, 2.14846112e-10, 2.19169242e-10,\n", + " 2.08058515e-10, 2.11108285e-10, 2.11337621e-10, 2.17786454e-10,\n", + " 2.14506546e-10, 2.14889515e-10, 2.19720417e-10, 2.22410988e-10,\n", + " 2.23886893e-10, 2.23709494e-10, 2.24391996e-10, 2.25565182e-10,\n", + " 2.28783131e-10, 2.26936631e-10, 2.28703132e-10, 2.29051573e-10,\n", + " 2.31376102e-10, 2.32987552e-10, 2.32517690e-10, 2.34156936e-10,\n", + " 2.34656354e-10, 2.35673814e-10, 2.34422179e-10, 2.28671558e-10,\n", + " 2.13493655e-10, 3.71198315e-10, 2.20202420e-10, 2.29317759e-10,\n", + " 2.37599637e-10, 2.34329664e-10, 2.39867598e-10, 2.42889688e-10,\n", + " 2.76068247e-10, 2.43312666e-10, 2.36605108e-10, 2.38539905e-10,\n", + " 3.11087931e-10, 2.40816764e-10, 2.38346744e-10, 2.46967064e-10,\n", + " 2.46372468e-10, 2.41213165e-10, 2.43528881e-10, 2.44226244e-10,\n", + " 2.46604853e-10, 2.43196014e-10, 2.41194553e-10, 2.39854745e-10,\n", + " 2.32768720e-10, 2.40939554e-10, 2.43922415e-10, 2.49417582e-10,\n", + " 2.48365543e-10, 2.58848202e-10, 2.55992271e-10, 2.44288224e-10,\n", + " 2.61028043e-10, 2.63662150e-10, 2.68308823e-10, 2.58801784e-10,\n", + " 2.63454444e-10, 2.69589424e-10, 2.70252186e-10, 2.70626272e-10,\n", + " 2.67349721e-10, 2.62365638e-10, 2.72231143e-10, 2.68623507e-10,\n", + " 2.74556624e-10, 2.74867734e-10, 2.73111156e-10, 2.73283864e-10,\n", + " 2.76806828e-10, 2.75963826e-10, 2.78234486e-10, 2.86498174e-10,\n", + " 2.85395616e-10, 2.80349733e-10, 2.82136852e-10, 2.76155618e-10,\n", + " 2.85943284e-10, 2.85090625e-10, 2.89539406e-10, 2.87513818e-10,\n", + " 2.84255436e-10, 2.92211477e-10, 2.90655914e-10, 2.88460393e-10,\n", + " 2.90635154e-10, 2.98766090e-10, 2.99363913e-10, 2.98405745e-10,\n", + " 3.01191102e-10, 2.95442736e-10, 3.01596865e-10, 3.03254873e-10,\n", + " 3.07592844e-10, 3.06851976e-10, 3.03148146e-10, 3.03471198e-10,\n", + " 3.14566956e-10, 2.91204380e-10, 3.02827874e-10, 3.05465120e-10,\n", + " 3.09030625e-10, 3.09468125e-10, 3.10767102e-10, 3.13429754e-10,\n", + " 3.10444725e-10, 3.12190133e-10, 3.10789364e-10, 3.13517191e-10,\n", + " 3.19931831e-10, 3.19379157e-10, 3.18666115e-10, 3.19715332e-10,\n", + " 3.18637600e-10, 3.18154366e-10, 3.19107603e-10, 3.15024855e-10,\n", + " 3.17790791e-10, 3.13310957e-10, 3.17060583e-10, 3.20321238e-10,\n", + " 3.22863907e-10, 3.17991688e-10, 3.17599286e-10, 3.16272918e-10,\n", + " 3.14729443e-10, 3.22814407e-10, 3.26798315e-10, 3.27098415e-10,\n", + " 3.45647063e-10, 3.27934638e-10, 3.29777671e-10, 3.31732233e-10,\n", + " 3.31517973e-10, 3.35952185e-10, 3.35906467e-10, 3.34106102e-10,\n", + " 3.34239129e-10, 3.36747501e-10, 3.38273582e-10, 3.34618116e-10,\n", + " 3.39724978e-10, 3.39585425e-10, 3.33286031e-10, 3.43721172e-10,\n", + " 3.44802070e-10, 3.42080395e-10, 1.15771315e-09, 9.15026423e-09,\n", + " 2.75081426e-09, 3.44700964e-10, 3.46784998e-10, 3.51323364e-10,\n", + " 3.49131655e-10, 3.51667728e-10, 3.50775837e-10, 3.68418635e-10,\n", + " 3.53732481e-10, 3.52760355e-10, 8.91111047e-10, 9.82029966e-10,\n", + " 3.53923513e-10, 3.55106428e-10, 3.54911519e-10, 3.58438938e-10,\n", + " 3.57960934e-10, 3.57988197e-10, 3.61576160e-10, 3.60467462e-10,\n", + " 3.60375410e-10, 3.63183490e-10, 3.65011846e-10, 3.67785568e-10,\n", + " 3.68863832e-10, 3.67766730e-10, 3.68450986e-10, 3.68563518e-10,\n", + " 3.70138858e-10, 3.70460635e-10, 3.71004696e-10, 3.71262484e-10,\n", + " 3.70592633e-10, 3.71330704e-10, 3.75388136e-10, 3.74811258e-10,\n", + " 3.75129847e-10, 3.72347434e-10, 3.70105857e-10, 3.69963040e-10,\n", + " 3.68835222e-10, 4.22733240e-10, 3.69400043e-10, 3.71290230e-10,\n", + " 3.73295167e-10, 3.70409808e-10, 3.73384593e-10, 3.75710367e-10,\n", + " 3.80916798e-10, 3.83228215e-10, 3.83284568e-10, 3.85870044e-10,\n", + " 3.84275711e-10, 3.84097881e-10, 3.88109895e-10, 3.94519242e-10,\n", + " 3.95446334e-10, 3.93311363e-10, 3.93619089e-10, 3.97084542e-10,\n", + " 3.93768170e-10, 3.94440936e-10, 3.92309811e-10, 3.99037087e-10,\n", + " 4.00392409e-10, 4.02104096e-10, 4.03824388e-10, 4.04768873e-10,\n", + " 4.03366386e-10, 4.04843212e-10, 4.07945250e-10, 4.12072897e-10,\n", + " 4.12303169e-10, 4.12009489e-10, 4.10574860e-10, 4.11222536e-10,\n", + " 4.11075124e-10, 4.08866271e-10, 4.13025935e-10, 4.11255270e-10,\n", + " 4.02979453e-10, 4.08580115e-10, 4.11969933e-10, 4.10176308e-10,\n", + " 4.07586080e-10, 4.12963915e-10, 4.13913952e-10, 4.16511256e-10,\n", + " 4.17661808e-10, 4.16675871e-10, 4.18464837e-10, 4.21382877e-10,\n", + " 4.22098562e-10, 4.20012493e-10, 4.21883097e-10, 4.24113652e-10,\n", + " 4.22823337e-10, 4.21093367e-10, 4.19541829e-10, 4.18043806e-10,\n", + " 4.18155489e-10, 4.16316424e-10, 4.22638223e-10, 4.26527264e-10,\n", + " 4.27047932e-10, 4.26954536e-10, 4.30248191e-10, 4.30118686e-10,\n", + " 4.30745905e-10, 4.31239111e-10, 4.31936023e-10, 4.29843455e-10,\n", + " 4.29982875e-10, 4.32606399e-10, 4.34655008e-10, 4.37257582e-10,\n", + " 4.38446843e-10, 4.39287945e-10, 4.37204713e-10, 4.29279388e-10,\n", + " 4.21208801e-10, 4.18222916e-10, 4.18368078e-10, 4.18209871e-10,\n", + " 4.19231794e-10, 4.20802665e-10, 4.20319338e-10, 4.17980446e-10,\n", + " 4.16748158e-10, 4.15429305e-10, 4.13727727e-10, 4.17398252e-10,\n", + " 4.18139184e-10, 4.16968838e-10, 4.16813334e-10, 4.21920187e-10,\n", + " 4.21154119e-10, 4.16245245e-10, 4.16663007e-10, 4.20576057e-10,\n", + " 4.19454747e-10, 4.19536913e-10, 4.04407080e-10, 4.13785543e-10,\n", + " 4.15801076e-10, 3.84074678e-10, 3.99214842e-10, 4.28911251e-10,\n", + " 4.56014000e-10, 4.27338572e-10, 4.27666847e-10, 4.35021021e-10,\n", + " 4.39891017e-10, 4.29191844e-10, 3.96072961e-10, 4.10677508e-10,\n", + " 4.31808696e-10, 4.40926518e-10, 4.44360662e-10, 4.44113801e-10,\n", + " 4.35301907e-10, 4.30993711e-10, 4.42025694e-10, 4.49689071e-10,\n", + " 4.48570705e-10, 4.46819419e-10, 4.50980779e-10, 4.51403037e-10,\n", + " 4.45606290e-10, 4.33727742e-10, 4.41140296e-10, 4.51733957e-10,\n", + " 4.55580855e-10, 4.55517857e-10, 4.55418531e-10, 4.56426173e-10,\n", + " 4.58864991e-10, 4.57925311e-10, 4.56466060e-10, 4.48823324e-10,\n", + " 4.46536662e-10, 4.43176956e-10, 4.58929799e-10, 4.64039472e-10,\n", + " 1.43477666e-09, 4.71808817e-10, 4.58160368e-10, 4.62499156e-10,\n", + " 4.68647390e-10, 4.65319649e-10, 4.62267229e-10, 4.59541713e-10,\n", + " 4.57240347e-10, 4.55438840e-10, 4.50997605e-10, 4.43844255e-10,\n", + " 4.85091285e-10, 4.44198618e-10, 4.51732823e-10, 4.58015661e-10,\n", + " 4.61818251e-10, 4.63402173e-10, 4.65065933e-10, 4.66448542e-10,\n", + " 4.66764762e-10, 4.64889114e-10, 4.62753241e-10, 4.62685961e-10,\n", + " 4.62699497e-10, 4.60973454e-10, 4.60627477e-10, 4.62190616e-10,\n", + " 4.63681037e-10, 4.66076147e-10, 4.68479693e-10, 4.69723396e-10,\n", + " 4.70217684e-10, 4.69638577e-10, 9.38150171e-10, 7.39148557e-10,\n", + " 4.84638553e-10, 4.64875870e-10, 4.71114351e-10, 4.74291307e-10,\n", + " 4.75123818e-10, 4.74901123e-10, 4.73786442e-10, 4.73045108e-10,\n", + " 4.73295976e-10, 4.74411571e-10, 4.75681602e-10, 4.76867603e-10,\n", + " 4.77433144e-10, 4.76863292e-10, 4.76563128e-10, 4.77723177e-10,\n", + " 4.78495019e-10, 4.78788644e-10, 4.81152704e-10, 4.84683957e-10,\n", + " 4.86690144e-10, 4.87495277e-10, 4.91616743e-10, 4.88598883e-10,\n", + " 4.88854640e-10, 4.88519871e-10, 4.88343042e-10, 4.88650528e-10,\n", + " 4.89499673e-10, 4.90891088e-10, 4.92358367e-10, 4.93058123e-10,\n", + " 4.92870201e-10, 4.92412059e-10, 4.91583155e-10, 4.88131984e-10,\n", + " 4.82507954e-10, 9.48335951e-10, 4.82880355e-10, 4.90483952e-10,\n", + " 4.95849489e-10, 4.98579972e-10, 4.99064475e-10, 4.98685861e-10,\n", + " 4.98211084e-10, 4.99158874e-10, 5.00762772e-10, 5.01544482e-10,\n", + " 5.01647162e-10, 5.02077137e-10, 5.03549352e-10, 5.05329679e-10,\n", + " 5.05844454e-10, 5.05802751e-10, 5.06131422e-10, 5.06539848e-10,\n", + " 5.06486483e-10, 5.27668185e-10, 5.02782826e-10, 5.03558564e-10,\n", + " 5.05475312e-10, 5.05927601e-10, 5.06089201e-10, 5.07429969e-10,\n", + " 5.09211306e-10, 5.10353537e-10, 5.10911317e-10, 5.10455866e-10,\n", + " 5.10424912e-10, 5.11079398e-10, 5.11598773e-10, 5.12281624e-10,\n", + " 5.12967620e-10, 5.13720703e-10, 5.14324982e-10, 5.13324812e-10,\n", + " 5.10903923e-10, 5.09776614e-10, 5.11376126e-10, 5.14272560e-10,\n", + " 5.16469059e-10, 5.15746086e-10, 5.13202545e-10, 5.11299361e-10,\n", + " 5.10869160e-10, 5.12528870e-10, 5.13733334e-10, 5.13497909e-10,\n", + " 5.14154899e-10, 5.16206643e-10, 5.17232941e-10, 5.16787457e-10,\n", + " 5.16844173e-10, 5.16715069e-10, 5.42116141e-10, 8.13120414e-10,\n", + " 5.17923654e-10, 5.15423202e-10, 5.12886177e-10, 5.13041052e-10,\n", + " 5.12979744e-10, 5.07429814e-10, 5.02228195e-10, 5.13711015e-10,\n", + " 4.95422193e-10, 4.95839583e-10, 4.97441801e-10, 4.99510234e-10,\n", + " 5.01098065e-10, 5.03162149e-10, 5.05921323e-10, 5.08102531e-10,\n", + " 5.09487939e-10, 5.10541740e-10, 5.11229769e-10, 5.11297955e-10,\n", + " 5.11262659e-10, 5.11985205e-10, 5.13228363e-10, 5.14319026e-10,\n", + " 5.14954686e-10, 5.15303216e-10, 5.16510509e-10, 5.18380183e-10,\n", + " 5.18888069e-10, 5.17795591e-10, 5.17292707e-10, 5.18762705e-10,\n", + " 5.19739590e-10, 5.19435171e-10, 5.19526610e-10, 5.19997669e-10,\n", + " 5.20447464e-10, 5.21060385e-10, 5.21139899e-10, 5.20248035e-10,\n", + " 5.19709581e-10, 5.20524214e-10, 5.22217972e-10, 5.24170990e-10,\n", + " 5.26504043e-10, 5.28575760e-10, 5.29317567e-10, 5.29295036e-10,\n", + " 5.29998324e-10, 5.31376184e-10, 5.32832303e-10, 5.34337015e-10,\n", + " 5.35366583e-10, 5.34993904e-10, 5.32411193e-10, 5.28698377e-10,\n", + " 5.26997701e-10, 5.28418014e-10, 5.30836537e-10, 5.33098804e-10,\n", + " 5.34972736e-10, 5.35391216e-10, 5.35422885e-10, 5.37112977e-10,\n", + " 5.39817524e-10, 5.41320323e-10, 5.41007703e-10, 5.39405665e-10,\n", + " 5.37765605e-10, 5.37664823e-10, 5.39232566e-10, 5.39746898e-10,\n", + " 5.38216298e-10, 5.38427865e-10, 5.41404584e-10, 5.42036704e-10,\n", + " 5.39463661e-10, 5.38678197e-10, 5.42260003e-10, 5.46531640e-10,\n", + " 5.48137557e-10, 5.46822347e-10, 5.43525772e-10, 5.39723569e-10,\n", + " 5.38774138e-10, 5.41879316e-10, 5.45387037e-10, 5.46400193e-10,\n", + " 5.47002416e-10, 5.48940747e-10, 5.49802412e-10, 5.48954521e-10,\n", + " 5.48290051e-10, 5.49236767e-10, 5.51634860e-10, 5.54317492e-10,\n", + " 5.56206880e-10, 5.56920247e-10, 5.56780155e-10, 5.56609499e-10,\n", + " 5.56930869e-10, 5.57547078e-10, 5.58180806e-10, 5.58605864e-10,\n", + " 5.58372435e-10, 5.57886138e-10, 5.58460223e-10, 5.59970833e-10,\n", + " 5.61020542e-10, 5.61247944e-10, 5.61183035e-10, 5.61520752e-10,\n", + " 5.62243663e-10, 5.62597169e-10, 5.62340528e-10, 5.61958059e-10,\n", + " 5.61582395e-10, 5.61809785e-10, 5.63484250e-10, 5.65487868e-10,\n", + " 5.66669990e-10, 5.67031363e-10, 5.66625527e-10, 5.65696886e-10,\n", + " 5.65327923e-10, 5.65952664e-10, 5.66738862e-10, 5.96539614e-10,\n", + " 5.69693194e-10, 5.68999997e-10, 5.69397777e-10, 5.69506888e-10,\n", + " 5.69498280e-10, 5.69805726e-10, 5.70472379e-10, 5.71236899e-10,\n", + " 5.72346029e-10, 5.73671780e-10, 5.74474990e-10, 5.74609896e-10,\n", + " 5.74767574e-10, 5.75297793e-10, 5.75251875e-10, 5.73073403e-10,\n", + " 5.67751327e-10, 6.39867374e-10, 2.23480801e-09, 5.63605907e-10,\n", + " 5.68965627e-10, 5.73745583e-10, 5.74816957e-10, 5.73793142e-10,\n", + " 5.73322389e-10, 5.74584276e-10, 5.76775423e-10, 5.78998846e-10,\n", + " 5.80538715e-10, 5.81068554e-10, 5.81348371e-10, 5.81816300e-10,\n", + " 5.82195932e-10, 5.82594241e-10, 5.83387802e-10, 5.84497674e-10,\n", + " 5.85294911e-10, 5.85312923e-10, 5.84563205e-10, 5.83100937e-10,\n", + " 5.81457952e-10, 5.80710429e-10, 5.81380258e-10, 5.83478559e-10,\n", + " 5.86101693e-10, 5.88003157e-10, 5.89026355e-10, 5.89825155e-10,\n", + " 5.90666226e-10, 5.90826028e-10, 5.89434382e-10, 5.86378789e-10,\n", + " 5.82758030e-10, 5.80547624e-10, 5.80369251e-10, 5.81235785e-10,\n", + " 5.82104004e-10, 5.82064326e-10, 5.81121523e-10, 5.80166186e-10,\n", + " 5.79886281e-10, 5.80262233e-10, 5.80908271e-10, 5.81821708e-10,\n", + " 5.82929926e-10, 5.83638700e-10, 5.83566019e-10, 5.82969825e-10,\n", + " 5.82698215e-10, 5.83342285e-10, 5.84170527e-10, 5.84119057e-10,\n", + " 5.83375848e-10, 5.82832207e-10, 5.82746479e-10, 5.82876150e-10,\n", + " 5.83049088e-10, 5.83126090e-10, 5.83325279e-10, 5.84049096e-10,\n", + " 5.84923717e-10, 5.85352186e-10, 5.85639304e-10, 5.86420757e-10,\n", + " 5.87717330e-10, 5.89136967e-10, 5.90354180e-10, 5.91229913e-10,\n", + " 5.91883637e-10, 5.92563837e-10, 5.93254882e-10, 5.93526321e-10,\n", + " 5.93017855e-10, 5.91965446e-10, 5.91114554e-10, 5.91149650e-10,\n", + " 5.91637584e-10, 5.91166144e-10, 5.89670321e-10, 5.89148349e-10,\n", + " 5.90391374e-10, 5.92037598e-10, 5.92957013e-10, 5.92943582e-10,\n", + " 5.92262026e-10, 5.91966852e-10, 5.93043379e-10, 5.94763953e-10,\n", + " 5.95527072e-10, 5.94761450e-10, 5.93349028e-10, 5.92407527e-10,\n", + " 5.91853173e-10, 5.90846110e-10, 5.89539386e-10, 5.89164574e-10,\n", + " 5.90140617e-10, 5.91355279e-10, 5.91761182e-10, 5.91493687e-10,\n", + " 5.91060039e-10, 5.90464568e-10, 5.89829516e-10, 5.89951111e-10,\n", + " 5.91237532e-10, 5.92731875e-10, 5.93704855e-10, 5.94690706e-10,\n", + " 5.96190469e-10, 5.97834263e-10, 5.99187849e-10, 6.00272621e-10,\n", + " 6.01072912e-10, 6.01102705e-10, 6.00158862e-10, 5.99111458e-10,\n", + " 5.99005770e-10, 5.99769668e-10, 6.01274579e-10, 6.03969123e-10,\n", + " 6.06724990e-10, 6.07777125e-10, 6.07214604e-10, 6.05956132e-10,\n", + " 6.04384833e-10, 6.02525297e-10, 6.01303206e-10, 6.01127560e-10,\n", + " 6.00191496e-10, 5.96476974e-10, 5.91194375e-10, 5.88104763e-10,\n", + " 5.88473102e-10, 5.89775876e-10, 5.90517750e-10, 5.90788818e-10,\n", + " 5.91017818e-10, 5.92380341e-10, 5.94946779e-10, 5.97239577e-10,\n", + " 5.98393666e-10, 5.99124320e-10, 6.00380080e-10, 6.02016735e-10,\n", + " 6.03430709e-10, 6.04914893e-10, 6.06971264e-10, 6.08882123e-10,\n", + " 6.09488730e-10, 6.08443681e-10, 6.07341071e-10, 6.08165757e-10,\n", + " 6.11053568e-10, 6.14526760e-10, 6.17163933e-10, 6.18767201e-10,\n", + " 6.20028092e-10, 6.21112750e-10, 6.20653701e-10, 6.17315046e-10]),\n", + " array([4.58456835e-11, 5.54156286e-11, 8.76676321e-11, 1.71227199e-10,\n", + " 2.28355852e-10])],\n", + " 0.6242218508975912)" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model.predict(model.theta, obslist, sps)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "11eaf731", + "metadata": { + "ExecuteTime": { + "end_time": "2025-06-13T13:23:26.863387Z", + "start_time": "2025-06-13T13:23:26.583159Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the predicted spectrum\n", + "plt.figure(figsize=(7,4), dpi=120)\n", + "plt.scatter(obslist[1].wavelength/(1+model.params['zred']), \n", + " model.predict(model.theta, obslist, sps)[0][1], color='dodgerblue', alpha=0.6)\n", + "plt.plot(obslist[0].wavelength/(1+model.params['zred']), \n", + " model.predict(model.theta, obslist, sps)[0][0], color='C1', alpha=0.6)\n", + "plt.yscale('log')\n", + "plt.xlabel(r'rest-frame wavelength [$\\mathrm{\\AA}$]')\n", + "plt.ylabel(r'F$_\\nu$ [L$_\\odot$/Hz]');" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fad8e1f5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "python3.9-tensorflow", + "language": "python", + "name": "tensorflow" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.4" + }, + "toc": { + "base_numbering": 1, + "nav_menu": {}, + "number_sections": true, + "sideBar": true, + "skip_h1_title": false, + "title_cell": "Table of Contents", + "title_sidebar": "Contents", + "toc_cell": false, + "toc_position": {}, + "toc_section_display": true, + "toc_window_display": false + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/demo/tutorial.rst b/demo/tutorial.rst index 7d724805..d2396367 100644 --- a/demo/tutorial.rst +++ b/demo/tutorial.rst @@ -40,19 +40,20 @@ The executable portion of the parameter file that comes after the ``if __name__ == "__main__"`` line is run when the parameter file is called. Here the possible command line arguments and their default values are defined, including any custom arguments that you might add. In this example we have added several -command line arguments that control how the data is read and how the The -supplied command line arguments are then parsed and placed in a dictionary. This -dictionary is passed to all the ingredient building methods (described below), -which return the data dictionary and necessary model objects. The data -dictionary and model objects are passed to a function that runs the prospector -fit (:py:func:`prospect.fitting.fit_model`). Finally, the fit results are -written to an output file. +command line arguments that control how the data is read and how the model is +built. The supplied command line arguments are then parsed and placed in a +**configuration** dictionary. This dictionary is passed to all the ingredient +building methods (described below), which return the required +:py:class:`Observation` objects and necessary model objects. The data and model +objects are passed to a function that runs the prospector fit +(:py:func:`prospect.fitting.fit_model`). Finally, the fit results are written to +an output file. **Building the fit ingredients: build_model** Several methods must be defined in the parameter file to build the ingredients -for the fit. The purpose of these functions and their required output are +for the fit. The purpose of these methods and their required output are described here. You will want to modify some of these for your specific model and data. Note that each of these functions will be passed a dictionary of command line arguments. These command line arguments, including any you add to @@ -65,7 +66,7 @@ First, the :py:func:`build_model` function is where the model that we will fit will be constructed. The specific model that you choose to construct depends on your data and your scientific question. -We have to specify a dictionary or list of model parameter specifications (see +We have to specify a dictionary of model parameter specifications (see :doc:`models`). Each specification is a dictionary that describes a single parameter. We can build the model by adjusting predefined sets of model parameter specifications, stored in the @@ -103,17 +104,18 @@ specifications. Since ``model_params`` is a dictionary (of dictionaries), you can update it with other parameter set dictionaries from the :py:class:`TemplateLibrary`. -Finally, the :py:func:`build_model` function takes the ``model_params`` dictionary or list that you build and -uses it to instantiate a :py:class:`SedModel` object. +Finally, the :py:func:`build_model` function takes the ``model_params`` +dictionary that you build and uses it to instantiate a :py:class:`SedModel` +object. .. code-block:: python - from prospect.models import SedModel + from prospect.models import SpecModel model_params = TemplateLibrary["parametric_sfh"] # Turn on nebular emission and add associated parameters model_params.update(TemplateLibrary["nebular"]) model_params["gas_logu"]["isfree"] = True - model = SedModel(model_params) + model = SpecModel(model_params) print(model) @@ -128,25 +130,25 @@ nebular and/or dust emission parameters are added to the model. **Building the fit ingredients: build_obs** -The next thing to look at is the :py:func:`build_obs` function. -This is where you take the data from whatever format you have and -put it into the dictionary format required by |Codename| for a single object. -This means you will have to modify this function heavily for your own use. -But it also means you can use your existing data formats. +The next thing to look at is the :py:func:`build_obs` function. This is where +you take the data from whatever format you have and put it into the format +required by |Codename| for a single object. This means you will have to modify +this function heavily for your own use. But it also means you can use your +existing data formats. Right now, the :py:func:`build_obs` function just reads ascii data from a file, picks out a row (corresponding to the photometry of a single galaxy), and then -makes a dictionary using data in that row. You'll note that both the datafile +makes a set of :py:class:`Observation`s using data in that row. You'll note that both the datafile name and the object number are keyword arguments to this function. That means they can be set at execution time on the command line, by also including those -variables in the ``run_params`` dictionary. We'll see an example later. +variables in the configuration dictionary. We'll see an example later. When you write your own :py:func:`build_obs` function, you can add all sorts of keyword arguments that control its output (for example, an object name or ID number that can be used to choose or find a single object in your data file). You can also import helper functions and modules. These can be either things like astropy, h5py, and sqlite or your own project specific modules and -functions. As long as the output dictionary is in the right format (see +functions. As long as the output data is in the right format (see dataformat.rst), the body of this function can do anything. **Building the fit ingredients: the rest** @@ -162,25 +164,26 @@ that for now. Running a fit ---------------------- -There are two kinds of fitting packages that can be used with |Codename|. The -first is ``emcee`` which implements ensemble MCMC sampling, and the second is -``dynesty``, which implements dynamic nested sampling. It is also possible to -perform optimization. If ``emcee`` is used, the result of the optimization will -be used to initalize the ensemble of walkers. +There are a few kinds of fitting packages that can be used with |Codename|. The +first is ``emcee`` which implements ensemble MCMC sampling. A number of nested +samplers are also available (``dynesty``, ``nautilus``, and ``ultranest``). It +is also possible to perform optimization. If ``emcee`` is used, the result of +the optimization will be used to initalize the ensemble of walkers. The choice of which fitting algorithms to use is based on command line flags -(``--optimization``, ``--emcee``, and ``--dynesty``.) If no flags are set the -model and data objects will be generated and stored in the output file, but no -fitting will take place. To run the fit on object number 0 using ``emcee`` after -an initial optimization, we would do the following at the command line +(``--optimization``, ``--emcee``, and ``--nested_sampler ``.) +If no flags are set the model and data objects will be generated and stored in +the output file, but no fitting will take place. To run the fit on object number +0 using ``emcee`` after an initial optimization, we would do the following at +the command line .. code-block:: shell python demo_params.py --objid=0 --emcee --optimize \ --outfile=demo_obj0_emcee -If we wanted to change something about the MCMC parameters, or fit a different object, -we could also do that at the command line +If we wanted to change something about the MCMC parameters, or fit a different +object, we could also do that at the command line .. code-block:: shell @@ -191,7 +194,7 @@ And if we want to use nested sampling with ``dynesty`` we would do the following .. code-block:: shell - python demo_params.py --objid=0 --dynesty \ + python demo_params.py --objid=0 --nested_sampler dynesty \ --outfile=demo_obj0_dynesty Finally, it is sometimes useful to run the script from the interpreter to do @@ -337,7 +340,7 @@ chain. # Get the modeled spectra and photometry. # These have the same shape as the obs['spectrum'] and obs['maggies'] arrays. - spec, phot, mfrac = model.predict(theta, obs=res['obs'], sps=sps) + (spec, phot), mfrac = model.predict(theta, obs=res['obs'], sps=sps) # mfrac is the ratio of the surviving stellar mass to the formed mass (the ``"mass"`` parameter). # Plot the model SED diff --git a/doc/conf.py b/doc/conf.py index 29993edb..3b3950c6 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -38,15 +38,15 @@ # General information about the project. project = 'prospector' -copyright = '2014-2022, Benjamin Johnson and Contributors' +copyright = '2014-2023, Benjamin Johnson and Contributors' author = 'Benjamin Johnson' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # The short X.Y version. -version = '1.1' -release = '1.1' +version = '1.2' +release = '1.2' language = None exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] diff --git a/doc/dataformat.rst b/doc/dataformat.rst index ab5ac7be..1bba2862 100644 --- a/doc/dataformat.rst +++ b/doc/dataformat.rst @@ -1,84 +1,142 @@ Data Formats ============ -The ``obs`` Dictionary & Data Units + +The `Observation` class ----------------------------------- -|Codename| expects the data in the form of a dictionary, preferably returned by -a :py:meth:`build_obs` function (see below). This dictionary should have (at -least) the following keys and values: - -``"wavelength"`` - The wavelength vector for the spectrum, ndarray. - Units are vacuum Angstroms. - The model spectrum will be computed for each element of this vector. - Set to ``None`` if you have no spectrum. - If fitting observed frame photometry as well, - then these should be observed frame wavelengths. - -``"spectrum"`` - The flux vector for the spectrum, - ndarray of same length as the wavelength vector. - If absolute spectrophotometry is available, - the units of this spectrum should be Janskies divided by 3631 (i.e. maggies). - Also the ``rescale_spectrum`` run parameter should be False. - -``"unc"`` - The uncertainty vector (sigma), in same units as ``"spectrum"``, - ndarray of same length as the wavelength vector. - -``"mask"`` - A boolean array of same length as the wavelength vector, - where ``False`` elements are ignored in the likelihood calculation. - -``"filters"`` - A sequence of `sedpy `_ filter objects or filter names, - used to calculate model magnitudes. - -``"maggies"`` - An array of photometric flux densities, same length as ``"filters"``. The - units are *maggies*. Maggies are a linear flux density unit defined as - :math:`{\rm maggie} = 10^{-0.4 \, m_{AB}}` where :math:`m_{AB}` is the AB apparent - magnitude. That is, 1 maggie is the flux density in Janskys divided by 3631. - Set to ``None`` if you have no photometric data. - -``"maggies_unc"`` - An array of photometric flux uncertainties, same length as ``"filters"``, - that gives the photometric uncertainties in units of *maggies* - -``"phot_mask"`` - Like ``"mask"``, a boolean array, used to mask the - photometric data during the likelihood calculation. - Elements with ``False`` values are ignored in the likelihood calculation. - -If you do not have spectral or photometric data, you can set ``"wavelength": -None`` or ``"maggies": None`` respectively. Feel free to add keys that store -other metadata, these will be stored on output. However, for ease of storage -these keys should either be numpy arrays or basic python datatypes that are JSON -serializable (e.g. strings, ints, and floats and lists, dicts, and tuples -thereof.) - -The method :py:meth:`prospect.utils.obsutils.fix_obs` can be used as a shortcut -to add any of the missing required keys with their default values and ensure -that there is data to fit, e.g. +|Codename| expects the data in the form of list of ``Observations``, preferably +returned by :py:meth:`build_obs` (see below). Each Observation instance +corresponds to a single dataset, and is basically a namespace that also supports +dict-like accessing of important attributes. In addition to holding data and +uncertainties thereon, they tell |Codename| what data to predict, contain +dataset-specific information for how to predict that data, and can even store +methods for computing likelihoods in the case of complicated, dataset-specific +noise models. + +There are two fundamental kinds of data, :py:class:`Photometry` and +:py:class:`Spectrum` that are each sub-classes of :py:class:`Observation`. There +is also also a :py:class:`Lines` class for integrated emission line fluxes. They +have the following attributes, most of which can also be accessed as dictionary +keys: + + +- ``wavelength`` + The wavelength vector for a `Spectrum`` or the effective wavelengths of the + filters in a `Photometry` data set, ndarray. Units are vacuum Angstroms. + Generally these should be observed frame wavelengths. + +- ``flux`` + The flux vector for a :py:class:`Spectrum`, or the broadband fluxes for + :py:class:`Photometry` ndarray of same length as the wavelength vector. + + For `Photometry` the units are *maggies*. Maggies are a linear flux density + unit defined as :math:`{\rm maggie} = 10^{-0.4 \, m_{AB}}` where + :math:`m_{AB}` is the AB apparent magnitude. That is, 1 maggie is the flux + density in Janskys divided by 3631. If absolute spectrophotometry is + available, the units for a :py:class:`Spectrum`` should also be maggies, + otherwise photometry must be present and a calibration vector must be + supplied or fit. Note that for convenience there is a `maggies_to_nJy` + attribute of `Observation` that gives the conversion factor. For + :py:class:`Lines`, the units should be erg/s/cm^2 + +- ``uncertainty`` + The uncertainty vector (sigma), in same units as ``flux``, ndarray of same + length as the wavelength vector. + +- ``mask`` + A boolean array of same length as the wavelength vector, where ``False`` + elements are ignored in the likelihood calculation. + +- ``filters`` + For a `Photometry`, this is a list of strings corresponding to filter names + in `sedpy `_ + +- ``line_ind`` + For a `Lines` instance, the (zero-based) index of the emission line in the + FSPS emission line + `table `_ + +In addition to these attributes, several additional aspects of an observation +are used to help predict data or to compute likelihoods. The latter is +particularly important in the case of complicated noise models, including outlier +models, jitter terms, or covariant noise. + +- ``name`` + A string that can be used to identify the dataset. This can be useful for + dataset-specfic parameters. By default the name is constructed from the + `kind` and the memory location of the object. + +- ``resolution`` + For a `Spectrum` this defines the instrumental resolution. Analagously to + the ``filters`` attribute for `Photometry`, this knowledge is used to + accurately predict the model in the space of the data. + +- ``noise`` + A :py:class:`NoiseModel` instance. By default this implements a simple + chi-square calculation of independent noise, but it can be complexified. + + +Example +------- + +For a single observation, you might do something like: + +.. code-block:: python + + def build_obs(N): + from prospect.observation import Spectrum + N = 1000 # number of wavelength points + spec = Spectrum(wavelength=np.linspace(3000, 5000, N), flux=np.zeros(N), uncertainty=np.ones(N)) + # ensure that this is a valid observation for fitting + spec = spec.rectify() + observations = [spec] + + return observations + +Note that `build_obs` returns a *list* even if there is only one dataset. + +For photometry this might look like: .. code-block:: python - from prospect.utils.obsutils import fix_obs + def build_obs(N): + from prospect.observation import Photometry + # valid sedpy filter names + fnames = list([f"sdss_{b}0" for b in "ugriz"]) + Nf = len(fnames) + phot = [Photometry(filters=fnames, flux=np.ones(Nf), uncertainty=np.ones(Nf)/10)] + # ensure that this is a valid observation for fitting + phot = phot.rectify() + observations = [phot] + return observations + +Converting from old style obs dictionaries +------------------------------------------ + +A tool exists to convert old combined observation dictionaries to a list of +`Observation` instances: + +.. code-block:: python + + from prospect.observation import from_oldstyle # dummy observation dictionary with just a spectrum N = 1000 - obs = dict(wavelength=np.linspace(3000, 5000, N), spectrum=np.zeros(N), unc=np.ones(N)) - obs = fix_obs(obs) - assert "mask" in obs.keys() + obs = dict(wavelength=np.linspace(3000, 5000, N), spectrum=np.zeros(N), unc=np.ones(N), + filters=[f"sdss_{b}0" for b in "ugriz"], maggies=np.zeros(5), maggies_unc=np.ones(5)) + # ensure that this is a valid observation for fitting + spec, phot = from_oldstyle(obs) + print(spec.ndata, phot.filternames, phot.wavelength, phot.flux) + -It is recommended to use this method at the end of any `build_obs` function. The :py:meth:`build_obs` function --------------------------------- The :py:meth:`build_obs` function in the parameter file is written by the user. It should take a dictionary of command line arguments as keyword arguments. It -should return an ``obs`` dictionary described above. +should return a list of :py:class:`prospect.observation.Observation` instances, +described above. Other than that, the contents can be anything. Within this function you might open and read FITS files, ascii tables, HDF5 files, or query SQL databases. You @@ -90,7 +148,11 @@ The point of this function is that you don't have to *externally* convert your data format to be what |Codename| expects and keep another version of files lying around: the conversion happens *within* the code itself. Again, the only requirement is that the function can take a ``run_params`` dictionary as keyword -arguments and that it return an ``obs`` dictionary as described below. +arguments and that it return :py:class:`prospect.observation.Observation` instances, as + described above. Each observation instance should correspond to a particular + dataset (e.g. a broadband photomtric SED, the spectrum from a particular + instrument, or the spectrum from a particular night) that shares instrumental + and, more importantly, calibration parameters. .. |Codename| replace:: Prospector diff --git a/doc/faq.rst b/doc/faq.rst index 47b50daa..1e3728d0 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -40,49 +40,6 @@ sampling algorithm being used, and the kind of data that you have. Hours or even days per fit is not uncommon for more complex models. -Can I fit my spectrum too? --------------------------- -There are several extra considerations that come up when fitting spectroscopy - - 1) Wavelength range and resolution. - Prospector is based on FSPS, which uses the MILES spectral library. These - have a resolution of ~2.5A FWHM from 3750AA - 7200AA restframe, and much - lower (R~200 or so, but not actually well defined) outside this range. - Higher resolution data (after including both velocity dispersion and - instrumental resolution) or spectral points outside this range cannot yet - be fit. - - 2) Relatedly, line spread function. - Prospector includes methods for FFT based smoothing of the spectra, - assuming a gaussian LSF (in either wavelength or velocity space). There is - also the possibility of FFT based smoothing for wavelength dependent - gaussian dispersion (i.e. sigma_lambda = f(lambda) with f possibly a - polynomial of lambda). In practice the smoothed spectra will be a - combination of the library resolution plus whatever FFT smoothing is - applied. Hopefully this can be made to match your actual data resolution, - which is a combination of the physical velocity dispersion and the - instrumental resolution. The smoothing is controlled by the parameters - `sigma_smooth`, `smooth_type`, and `fftsmooth` - - 3) Nebular emission. - While prospector/FSPS include self-consistent nebular emission, the - treatment is probably not flexible enough at the moment to fit high S/N, - high resolution data including nebular emission (e.g. due to deviations of - line ratios from Cloudy predictions or to complicated gas kinematics that - are different than stellar kinematics). Thus fitting nebular lines should - take adavantage of the nebular line amplitude optimization/marginalization - capabilities. For very low resolution data this is less of an issue. - - 4) Spectrophotometric calibration. - There are various options for dealing with the spectral continuum shape - depending on how well you think your spectra are calibrated and if you - also fit photometry to tie down the continuum shape. You can optimize out - a polynomial "calibration" vector, or simultaneously fit for a polynomial - and marginalize over the polynomial coefficients (this allows you to place - priors on the accuracy of the spectrophotometric calibration). Or you can - just take the spectrum as perfectly calibrated. - - How do I fit for redshift as well as other parameters? ------------------------------------------------------ The simplest way is to just let the parameter specification for ``"zred"`` diff --git a/doc/index.rst b/doc/index.rst index 1aa3ebb1..d82c2b4e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -30,10 +30,13 @@ Prospector allows you to: installation usage dataformat + spectra models sfhs nebular + noise output + ref .. toctree:: :maxdepth: 1 @@ -55,37 +58,6 @@ Prospector allows you to: api/plotting_api api/utils_api -License and Attribution ------------------------------- - -*Copyright 2014-2022 Benjamin D. Johnson and contributors.* - -This code is available under the `MIT License -`_. - -If you use this code, please reference `this paper `_: - -.. code-block:: none - - @ARTICLE{2021ApJS..254...22J, - author = {{Johnson}, Benjamin D. and {Leja}, Joel and {Conroy}, Charlie and {Speagle}, Joshua S.}, - title = "{Stellar Population Inference with Prospector}", - journal = {\apjs}, - keywords = {Galaxy evolution, Spectral energy distribution, Astronomy data modeling, 594, 2129, 1859, Astrophysics - Astrophysics of Galaxies, Astrophysics - Instrumentation and Methods for Astrophysics}, - year = 2021, - month = jun, - volume = {254}, - number = {2}, - eid = {22}, - pages = {22}, - doi = {10.3847/1538-4365/abef67}, - archivePrefix = {arXiv}, - eprint = {2012.01426}, - primaryClass = {astro-ph.GA}, - adsurl = {https://ui.adsabs.harvard.edu/abs/2021ApJS..254...22J}, - adsnote = {Provided by the SAO/NASA Astrophysics Data System} - } - Changelog --------- diff --git a/doc/installation.rst b/doc/installation.rst index 0174c40f..a390a1cf 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -1,7 +1,8 @@ Installation ============ -|Codename| itself is is pure python. To install a released version of just |Codename|, use ``pip`` +|Codename| itself is is pure python. To install a released version of just +|Codename|, use ``pip`` .. code-block:: shell @@ -52,7 +53,8 @@ environment, use the following procedure: Requirements ------------ -|Codename| works with Python3, and requires `numpy `_ and `SciPy `_ +|Codename| works with ``python>=3.9``, and requires `numpy +`_ and `SciPy `_ You will also need: diff --git a/doc/models.rst b/doc/models.rst index 89c06f9f..4104b01e 100644 --- a/doc/models.rst +++ b/doc/models.rst @@ -190,8 +190,8 @@ inspect the free and fixed parameters in a given set, you can do something like # This dictionary can be updated or modified, to expand the model. model_params.update(TemplateLibrary["nebular"]) # Instantiate a model object - from prospect.models import SedModel - model = SedModel(model_params) + from prospect.models import SpecModel + model = SpecModel(model_params) The ``build_model()`` Method diff --git a/doc/nebular.rst b/doc/nebular.rst index cfbbfb79..0a9063cc 100644 --- a/doc/nebular.rst +++ b/doc/nebular.rst @@ -75,7 +75,7 @@ photometry). These parameters are: In all cases the line names to use in these lists are those given in the FSPS emission line line information table, ``$SPS_HOME/data/emlines_info.dat``, e.g. -``"Ly alpha 1216"`` for the Lyman-alpha 1216 Angstrom line. +``"Ly-alpha 1215"`` for the Lyman-alpha 1216 Angstrom line. Nebular Parameter Templates --------------------------- diff --git a/doc/noise.rst b/doc/noise.rst new file mode 100644 index 00000000..5e0ac575 --- /dev/null +++ b/doc/noise.rst @@ -0,0 +1,58 @@ +Noise Modeling +============ + +The noise model for each dataset is used to define the likelihood function, +given the observed data and the model prediction. As such, each dataset or +``Observation`` is assigned its own noise model. By default this is the basic +:math:`\chi^2` noise model. Complications are described below + + +Outliers +-------- + +For outlier modeling we follow `hogg10 `_ + +The key parameters of this noise model are the fraction of datapoints in a given +dataset that are outliers, and the typical variance of the outliers. Each +dataset might have a different outlier parameters, and so we need to find a way +to identify which outlier model parameter belongs with which dataset. This can +be done when the noise model is assigned to a dataset. For example, if we had a +single photometric dataset and a single spectroscopic dataset, with outlier +model parameters for each given by ``("f_outlier_phot", "nsigma_outlier_phot")`` +and ``("f_outlier_spec", "nsigma_outlier_spec")`` respectively (this is the +default parameter set available as a template) then we could associate these +parameter with each dataset as follows: + +.. code-block:: python + + from prospect.data import Photometry, Spectrum + from prospect.likelihood import NoiseModel + filternames = [f"sdss_{b}0" for b in "ugriz"] + N = len(fnames) + pdat = Photometry(filters=filternames, flux=np.zeros(N), uncertainty=np.ones(N), + noise=NoiseModel(frac_out_name="f_outlier_phot", + nsigma_out_name="nsigma_outlier_phot")) + N = 1000 + sdat = Spectrum(wavelength=np.linspace(4e3, 7e3, N), np.zeros(N), np.ones(N), + noise=NoiseModel(frac_out_name="f_outlier_spec", + nsigma_out_name="nsigma_outlier_spec")) + +This can be combined with other Noise models, as long as they have diagonal +(1-dimensional) covariance matices. + + +Jitter +------ + + + +Correlated Noise +---------------- + + +KDE Noise +--------- + + + +.. |Codename| replace:: Prospector diff --git a/doc/prospector-beta_priors.rst b/doc/prospector-beta_priors.rst index 3bb041d2..ce920b76 100644 --- a/doc/prospector-beta_priors.rst +++ b/doc/prospector-beta_priors.rst @@ -2,25 +2,37 @@ Prospector-beta Priors ============== -This model is intended for fitting galaxy photometry where the redshift is unknown. -The priors encode empirical constraints of redshifts, masses, and star formation histories in the galaxy population. +This model is intended for fitting galaxy photometry where the redshift is +unknown. The priors encode empirical constraints of redshifts, masses, and star +formation histories in the galaxy population. -A set of prospector parameters implementing the full set of priors is available as the ``"beta"`` entry -of :py:class:`prospect.models.templates.TemplateLibrary`. +N.B.: Please make sure to update to the Prospector-beta version post commit +`09a83f2 +`_, +merged on May 19, 2023. This is a major update to the SFH(M, z) prior, ensuring +the expectation values of SFRs are well-behaved over the full prior space. + +A set of prospector parameters implementing the full set of priors is available +as the ``"beta"`` entry of +:py:class:`prospect.models.templates.TemplateLibrary`. Additionally we provide different combinations of the priors for flexibility, which includes the following: -* ``PhiMet`` : mass funtion + mass-met -* ``ZredMassMet`` : number density + mass funtion + mass-met -* ``DymSFH`` : mass-met + SFH(M, z) -* ``PhiSFH`` : mass funtion + mass-met + SFH(M, z) -* ``NzSFH`` : number density + mass funtion + mass-met + SFH(M, z); this is the full set of Prospector-beta priors. +* ``PhiMet`` : mass function + mass-met +* ``ZredMassMet`` : number density + mass function + mass-met +* ``DymSFH`` : mass-met + SFH(M, z) +* ``DymSFHfixZred`` : same as above, but keeping zred fixed to a user-specified value, 'zred', during fitting +* ``PhiSFH`` : mass function + mass-met + SFH(M, z) +* ``PhiSFHfixZred`` : same as above, but keeping zred fixed to a user-specified value, 'zred', during fitting +* ``NzSFH`` : number density + mass function + mass-met + SFH(M, z); this is the full set of Prospector-beta priors. -We describe each of the priors briefly below. Please cite `wang23 `_ and the relevant papers if any of the priors are used. +We describe each of the priors briefly below. Please cite `wang23 +`_ and the +relevant papers if any of the priors are used. Stellar Mass Function ------------ +--------------------- Two options are available, the choice of which depends on the given scientific question. The relevant data files are ``prior_data/pdf_of_z_l20.txt`` & ``prior_data/pdf_of_z_l20t18.txt``. @@ -28,31 +40,57 @@ These mass functions can also be replaced by supplying new data files to ``prior 1. ``"const_phi = True"`` -The mass functions between 0.2 ≤ z ≤ 3.0 are taken from `leja20 `_. Outside this redshift range, we adopt a nearest-neighbor solution, i.e., the z = 0.2 and z = 3 mass functions. +The mass functions between 0.2 ≤ z ≤ 3.0 are taken from `leja20 +`_. Outside this +redshift range, we adopt a nearest-neighbor solution, i.e., the z = 0.2 and z = +3 mass functions. 2. ``"const_phi = False"`` -The mass functions are switched to those from `tacchella18 `_ between 4 < z < 12, with the 3 < z < 4 transition from `leja20 `_ managed with a smoothly-varying average in number density space. We use the z = 12 mass function for z > 12. +The mass functions are switched to those from `tacchella18 +`_ between 4 < z +< 12, with the 3 < z < 4 transition from `leja20 +`_ managed with +a smoothly-varying average in number density space. We use the z = 12 mass +function for z > 12. Galaxy Number Density ----------- -This prior informs the model about the survey volume being probed. It is sensitive to the mass-completeness limit of the data. We provide a default setting derived from a mock JWST catalog, which is contained in ``prior_data/mc_from_mocks.txt``. -In practice one would likely need to obtain the mass-completeness limits from using SED-modeling heuristics based on the flux-completeness limits in a given catalog. +This prior informs the model about the survey volume being probed. It is +sensitive to the mass-completeness limit of the data. We provide a default +setting derived from a mock JWST catalog, which is contained in +``prior_data/mc_from_mocks.txt``. +In practice one would likely need to obtain +the mass-completeness limits from using SED-modeling heuristics based on the +flux-completeness limits in a given catalog. Dynamic Star-formation History ----------- -The SFH is described non-parametrically as in Prospector-alpha; the number of age bins is set by ``"nbins_sfh"``. +The SFH is described non-parametrically as in Prospector-alpha; the number of +age bins is set by ``"nbins_sfh"``. -In contast to the null expectation assumed in Prospector-alpha, the expectation value in each age bin is matched to the cosmic star formation rate densities in `behroozi19 `_, while the distribution about the mean remains to be the Student’s-t distribution. The sigma of the Student’s-t distribution is set by ``"logsfr_ratio_tscale"``, and the range is clipped to be within ``"logsfr_ratio_mini"`` and ``"logsfr_ratio_maxi"``. +In contrast to the null expectation assumed in Prospector-alpha, the expectation +value in each age bin is matched to the cosmic star formation rate densities in +`behroozi19 `_, +while the distribution about the mean remains to be the Student's-t +distribution. The sigma of the Student's-t distribution is set by +``"logsfr_ratio_tscale"``, and the range is clipped to be within +``"logsfr_ratio_mini"`` and ``"logsfr_ratio_maxi"``. -A simple mass dependence on SFH is further introduced by shifting the start of the age bins as a function of mass. This SFH prior effectively encodes an expectation that high-mass galaxies form earlier, and low-mass galaxies form later, than naive expectations from the cosmic SFRD. +A simple mass dependence on SFH is further introduced by shifting the start of +the age bins as a function of mass. This SFH prior effectively encodes an +expectation that high-mass galaxies form earlier, and low-mass galaxies form +later, than naive expectations from the cosmic SFRD. -Stellar Mass–Stellar Metallicity +Stellar Mass-Stellar Metallicity ----------- -This is the stellar mass–stellar metallicity relationship measured from the SDSS in `gallazzi05 `_, introduced in `leja19 `_. +This is the stellar mass-stellar metallicity relationship measured from the SDSS +in `gallazzi05 +`_, introduced +in `leja19 `_. diff --git a/doc/quickstart.rst b/doc/quickstart.rst index 51f6606d..376ef785 100644 --- a/doc/quickstart.rst +++ b/doc/quickstart.rst @@ -19,8 +19,8 @@ ingrediants; for more realistic usage see :ref:`demo` or the :ref:`tutorial`. Build an observation -------------------- -First we'll get some data, using ``astroquery`` to get SDSS photometry of a galaxy. We'll also -get spectral data so we know the redshift. +First we'll get some data, using ``astroquery`` to get SDSS photometry of a +galaxy. We'll also get spectral data so we know the redshift. .. code:: python @@ -35,25 +35,34 @@ get spectral data so we know the redshift. shdus = SDSS.get_spectra(plate=2101, mjd=53858, fiberID=220)[0] assert int(shdus[2].data["SpecObjID"][0]) == cat[0]["specObjID"] -Now we will put this data in a dictionary with format expected by prospector. We -convert the magnitudes to maggies, convert the magnitude errors to flux -uncertainties (including a noise floor), and load the filter transmission curves -using `sedpy`. We'll store the redshift here as well for convenience. Note that -for this example we do *not* attempt to fit the spectrum at the same time. +Now we will put this data in the format expected by prospector. We convert the +magnitudes to maggies, convert the magnitude errors to flux uncertainties +(including a noise floor), and load the filter transmission curves using +`sedpy`. We'll store the redshift here as well for convenience. Note that for +this example we do *not* attempt to fit the spectrum at the same time, though we +include an empty Spectrum data set to force a prediction of the full spectrum. .. code:: python from sedpy.observate import load_filters - from prospect.utils.obsutils import fix_obs + from prospect.observation import Photometry, Spectrum filters = load_filters([f"sdss_{b}0" for b in bands]) maggies = np.array([10**(-0.4 * cat[0][f"cModelMag_{b}"]) for b in bands]) magerr = np.array([cat[0][f"cModelMagErr_{b}"] for b in bands]) - magerr = np.clip(magerr, 0.05, np.inf) + magerr = np.hypot(magerr, 0.05) - obs = dict(wavelength=None, spectrum=None, unc=None, redshift=shdus[2].data[0]["z"], - maggies=maggies, maggies_unc=magerr * maggies / 1.086, filters=filters) - obs = fix_obs(obs) + pdat = Photometry(filters=filters, flux=maggies, uncertainty=magerr*maggies/1.086, + name=f'sdss_phot_specobjID{cat[0]["specObjID"]}') + sdat = Spectrum(wavelength=None, flux=None, uncertainty=None) + observations = [sdat, pdat] + for obs in observations: + obs.redshift = shdus[2].data[0]["z"] + + +In principle we could also add noise models for the spectral and photometric +data (e.g. to fit for the photometric noise floor), but we'll make the default +assumption of iid Gaussian noise for the moment. Build a Model @@ -61,29 +70,29 @@ Build a Model Here we will get a default parameter set for a simple parametric SFH, and add a set of parameters describing nebular emission. We'll also fix the redshift to -the value given by SDSS. This model has 5 free parameters, each of which has a -an associated prior distribution. These default prior distributions can and -should be replaced or adjusted depending on your particular science question. +the value given by SDSS. This model has 5 free parameters, each of which has an +associated prior distribution. These default prior distributions can and should +be replaced or adjusted depending on your particular science question. Here +we'll just change the prior distribution for stellar mass, as an example. .. code:: python + # Get a baseline set of model parameters from prospect.models.templates import TemplateLibrary from prospect.models import SpecModel model_params = TemplateLibrary["parametric_sfh"] model_params.update(TemplateLibrary["nebular"]) model_params["zred"]["init"] = obs["redshift"] + # Adjust the prior for mass, giving a wider range + from prospect.models import priors + model_params["mass"]["prior"] = priors.LogUniform(mini=1e6, maxi=1e13) + + # Instantiate the model using this parameter dictionary model = SpecModel(model_params) assert len(model.free_params) == 5 print(model) -In principle we could also add noise models for the spectral and photometric -data, but we'll make the default assumption of independent Gaussian noise for the moment. - -.. code:: python - - noise_model = (None, None) - Get a 'Source' -------------- @@ -91,7 +100,7 @@ Get a 'Source' Now we need an object that will actually generate the galaxy spectrum using stellar population synthesis. For this we will use an object that wraps FSPS allowing access to all the parameterized SFHs. We will also just check which -spectral and isochrone librariews are being used. +spectral and isochrone libraries are being used. .. code:: python @@ -99,43 +108,58 @@ spectral and isochrone librariews are being used. sps = CSPSpecBasis(zcontinuous=1) print(sps.ssp.libraries) +For piecewise constant and other flexible SFHs use `FastStepBasis` instead of +`CSPSpecBasis`. Make a prediction ----------------- We can now predict our data for any set of parameters. This will take a little time because fsps is building and caching the SSPs. Subsequent calls to predict -will be faster. Here we'll just make the predicition for the current value of +will be faster. Here we'll just make the prediction for the current value of the free parameters. .. code:: python current_parameters = ",".join([f"{p}={v}" for p, v in zip(model.free_params, model.theta)]) print(current_parameters) - spec, phot, mfrac = model.predict(model.theta, obs=obs, sps=sps) - print(phot / obs["maggies"]) + (spec, phot), mfrac = model.predict(model.theta, observations, sps=sps) + print("filter,observed,predicted") + for i, f in enumerate(obs["filters"]): + print(f"{f.name},{obs['maggies'][i]},{phot[i]}") Run a fit --------- -Since we can make predictions and we have data and uncertainties, we should be -able to construct a likelihood function. Here we'll use the pre-defined default +Since we can make predictions and we have (photometric) data and uncertainties, +we should be able to construct a likelihood function, and then combine with the +priors to sample the posterior. Here we'll use the pre-defined default posterior probability function. We also set some sampling related keywords to -make the fit go a little faster, though it should still take of order tens of -minutes. +make the fit go a little faster (but give rougher posterior estimates), though +it should still take of order tens of minutes. .. code:: python from prospect.fitting import lnprobfn, fit_model - fitting_kwargs = dict(nlive_init=400, nested_method="rwalk", nested_target_n_effective=1000, nested_dlogz_init=0.05) - output = fit_model(obs, model, sps, optimize=False, dynesty=True, lnprobfn=lnprobfn, noise=noise_model, **fitting_kwargs) - result, duration = output["sampling"] + + # just the photometry + obs = [observations[1]] + + # posterior probability of the initial parameters given the photometry + lnp = lnprobfn(model.theta, model, observations=obs, sps=sps) + + # now do the posterior sampling + fitting_kwargs = dict(nlive_init=400, nested_method="rwalk", nested_target_n_effective=10000, nested_dlogz_init=0.05) + output = fit_model(obs, model, sps, lnprobfn=lnprobfn, + optimize=False, dynesty=True, + **fitting_kwargs) + result = output["sampling"] The ``result`` is a dictionary with keys giving the Monte Carlo samples of parameter values and the corresponding posterior probabilities. Because we are -using dynesty, we also get weights associated with each parameter sample in the -chain. +using nested sampling, we also get weights associated with each parameter sample +in the chain. Typically we'll want to save the fit information. We can save the output of the sampling along with other information about the model and the data that was fit @@ -144,12 +168,16 @@ as follows: .. code:: python from prospect.io import write_results as writer - hfile = "./quickstart_dynesty_mcmc.h5" - writer.write_hdf5(hfile, {}, model, obs, - output["sampling"][0], None, - sps=sps, - tsample=output["sampling"][1], - toptimize=0.0) + writer.write_hdf5("./quickstart_dynesty_mcmc.h5", + config=fitting_kwargs, + model=model, + obs=observations, + output["sampling"], + None, + sps=sps) + +Note that this doesn't include all the config information that would normally be stored (see :ref:`usage`) + Make plots ---------- @@ -163,14 +191,14 @@ information we can use the built-in reader. hfile = "./quickstart_dynesty_mcmc.h5" out, out_obs, out_model = reader.results_from(hfile) -This gives a dictionary of useful information (``out``), as well as the obs -dictionary that we were using and, in some cases, a reconsitituted model object. -However, that is only possible if the model generation code is saved to the -results file, in the form of the text for a `build_model()` function. Here we -will use just use the model object that we've already generated. +This gives a dictionary of useful information (``out``), as well as the obs data +that we were using and, in some cases, a reconsitituted model object. However, +that is *only* possible if the model generation code is saved to the results file, +in the form of the text for a `build_model()` function. Here we will use just +use the model object that we've already generated. -Now we will do some plotting. First, lets make a corner plot of the posterior. -We'll mark the highest probablity posterior sample as well. +First, lets make a corner plot of the posterior. We'll mark the highest +probablity posterior sample as well. .. code:: python @@ -178,7 +206,7 @@ We'll mark the highest probablity posterior sample as well. from prospect.plotting import corner nsamples, ndim = out["chain"].shape cfig, axes = pl.subplots(ndim, ndim, figsize=(10,9)) - axes = corner.allcorner(out["chain"].T, out["theta_labels"], axes, weights=out["weights"], color="royalblue", show_titles=True) + #axes = corner.allcorner(out["chain"].T, out["theta_labels"], axes, weights=out["weights"], color="royalblue", show_titles=True) from prospect.plotting.utils import best_sample pbest = best_sample(out) diff --git a/doc/ref.rst b/doc/ref.rst new file mode 100644 index 00000000..37cd1307 --- /dev/null +++ b/doc/ref.rst @@ -0,0 +1,41 @@ + +License and Attribution +======================= + +*Copyright 2014-2023 Benjamin D. Johnson and contributors.* + +This code is available under the `MIT License +`_. + +If you use this code, please reference `this paper `_: + +.. code-block:: none + + @ARTICLE{2021ApJS..254...22J, + author = {{Johnson}, Benjamin D. and {Leja}, Joel and {Conroy}, Charlie and {Speagle}, Joshua S.}, + title = "{Stellar Population Inference with Prospector}", + journal = {\apjs}, + keywords = {Galaxy evolution, Spectral energy distribution, Astronomy data modeling, 594, 2129, 1859, Astrophysics - Astrophysics of Galaxies, Astrophysics - Instrumentation and Methods for Astrophysics}, + year = 2021, + month = jun, + volume = {254}, + number = {2}, + eid = {22}, + pages = {22}, + doi = {10.3847/1538-4365/abef67}, + archivePrefix = {arXiv}, + eprint = {2012.01426}, + primaryClass = {astro-ph.GA}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2021ApJS..254...22J}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} + } + +and `Leja et al 2019 `_ + + +Please also see the reference requirements for any dependencies that are used + +* `FSPS `_ +* `python-fsps `_ +* `dynesty `_ +* `emcee `_ diff --git a/doc/requirements.txt b/doc/requirements.txt index a2d257eb..ff97cf28 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -3,7 +3,7 @@ scipy >= 1.1.0 matplotlib >= 3.0 astropy h5py -astro-sedpy +astro-sedpy >= 0.3.0 sphinx-book-theme myst-nb numpydoc \ No newline at end of file diff --git a/doc/sfhs.rst b/doc/sfhs.rst index f1abba32..aa0cd649 100644 --- a/doc/sfhs.rst +++ b/doc/sfhs.rst @@ -77,8 +77,8 @@ Continuity SFH ^^^^^^^^^^^^^^ See `leja19 `_, `johnson21 `_ -for more details. A set of propsector parameters implementing this treatment -with 3 bins is available as the ``"continuity_sfh"`` entry of +for more details. A basic set of prospector parameters implementing this +treatment with 3 bins is available as the ``"continuity_sfh"`` entry of :py:class:`prospect.models.templates.TemplateLibrary`. In this parameterization, the SFR of each bin is derived from sampling a vector @@ -86,7 +86,8 @@ of parameters describing the *ratio* of SFRs in adjacent temporal bins. By default, a Student-t prior distribution (like a Gaussian but with heavier tails) is placed on the log of these ratios. This results in a prior SFH that tends toward constant SFR, and down-weights drmamtic changes in the SFR between -adjacent bins. The overall normalization is provided by the ``logmass`` +adjacent bins. The width of the distribution can be ajusted to produce smoother +or burstier SFHs.The overall normalization is provided by the ``logmass`` parameter. In detail, the SFR in each timetime is computed as @@ -140,6 +141,20 @@ required to model post-starburst SFHs. A set of prospector parameters implementing this treatment is available as the ``"continuity_psb_sfh"`` entry of :py:class:`prospect.models.templates.TemplateLibrary` + +'Stochastic' SFH +^^^^^^^^^^^^^^^^ +This SFH (hyper-)prior uses the power spectrum of SFH fluctuations -- the +parameters of which can be sampled -- to determine the covariance matrix between +(adjacent and non-adjacent) temporal bins of SFR. See `Wan et al. 24 `_ for +details. This prior is adapted from the Extended Regulator model developed in +`Caplar & Tacchella (2019) `_ and `Tacchella, Forbes & Caplar (2020) `_ , in +conjunction with the GP implementation of `Iyer & Speagle et al. (2024) `_ taken from `this module `_ . + +Use of this SFH prior requires that +:py:class:`prospect.models.sedmodel.HyperSpecModel` be used as the model base +class (instead of :py:class:`SpecModel`). + Dirichlet SFH ^^^^^^^^^^^^^ See `leja17 `_, diff --git a/doc/spectra.rst b/doc/spectra.rst new file mode 100644 index 00000000..5d81746a --- /dev/null +++ b/doc/spectra.rst @@ -0,0 +1,68 @@ +Fitting Spectra +================ + +There are several extra considerations that come up when fitting spectroscopy. + +Wavelength range, resolution, and linespread function +----------------------------------------------------- + +Prospector is based on FSPS, which uses stellar spectral libraries with given +resolution. The empirical MILES library has a resolution of ~2.5A FWHM from +3750AA - 7200AA restframe, and much lower (R~200 or so, but not actually well +defined) outside this range. Higher resolution data (after including both +velocity dispersion and instrumental resolution) or spectral points outside this +range cannot yet be fit. + +Prospector includes methods for FFT based smoothing of the spectra, assuming a +Gaussian LSF (in either wavelength or velocity space). There is also the +possibility of FFT based smoothing for wavelength dependent Gaussian dispersion +(i.e. sigma_lambda = f(lambda) with f possibly a polynomial of lambda). In +practice the smoothed model spectra will be a combination of the library resolution +plus whatever FFT smoothing is applied. Hopefully this can be made to match your +actual data resolution, which is a combination of the physical velocity +dispersion and the instrumental resolution. The smoothing is controlled by the +parameters `sigma_smooth`, `smooth_type`, and `fftsmooth` + +For undersampled spectra, a special :py:class:`UnderSampledSpectrum` class +exists that will integrate the model (smoothed by velocity dispersion and +intrumental resolution) over the supplied pixels. + + +Instrumental Response & Spectrophotometric Calibration +--------------------- + +There are various options for dealing with the spectral continuum shape +depending on how well you think your spectra are calibrated and if you also fit +photometry to tie down the continuum shape. You can optimize out a polynomial +"calibration" vector, or simultaneously fit for a polynomial and marginalize +over the polynomial coefficients (this allows you to place priors on the +accuracy of the spectrophotometric calibration). Or you can just take the +spectrum as perfectly calibrated. + +Particular treatments can be implemented using different mix-in classes for the +:py:class:`Spectrum` observational data, e.g. for optimization of a 5th order +polynomial calibration vector at each likelihood call, use +:py:class:`PolyOptCal` as follows + +.. code-block:: python + + from prospect.observation import Spectrum, PolyOptCal + class PolySpectrum(PolyOptCal, Spectrum): # order matters + pass + + spec = PolySpectrum(wavelength=np.linspace(3000, 5000, N), + flux=np.zeros(N), + uncertainty=np.ones(N), + polynomial_order=5) + + +Nebular emission +---------------- + +While prospector/FSPS include self-consistent nebular emission, the treatment is +probably not flexible enough at the moment to fit high S/N, high resolution data +including nebular emission (e.g. due to deviations of line ratios from Cloudy +predictions or to complicated gas kinematics that are different than stellar +kinematics). Thus fitting nebular lines should take adavantage of the nebular +line amplitude optimization/marginalization capabilities. For very low +resolution data this is less of an issue. \ No newline at end of file diff --git a/doc/usage.rst b/doc/usage.rst index ed52092e..29840131 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -13,7 +13,7 @@ execution: .. code-block:: shell - python parameter_file.py --dynesty + python parameter_file.py --nested_sampler dynesty --nested_target_n_effective 512 Additional command line options can be given (see below) e.g. @@ -42,31 +42,46 @@ writes output. from prospect.io import write_results as writer from prospect import prospect_args - # Get the default argument parser + # --- Get the default argument parser --- parser = prospect_args.get_parser() # Add custom arguments that controll the build methods parser.add_argument("--custom_argument_1", ...) - # Parse the supplied arguments, convert to a dictionary, and add this file for logging purposes + + # --- Configure --- args = parser.parse_args() - run_params = vars(args) - run_params["param_file"] = __file__ + config = vars(args) + # allows parameter file text to be stored for model regeneration + config["param_file"] = __file__ - # build the fit ingredients - obs, model, sps, noise = build_all(**run_params) - run_params["sps_libraries"] = sps.ssp.libraries + # --- Get fitting ingredients --- + obs, model, sps = build_all(**config) + config["sps_libraries"] = sps.ssp.libraries + print(model) - # Set up an output file name and run the fit + if args.debug: + sys.exit() + + # --- Set up output --- ts = time.strftime("%y%b%d-%H.%M", time.localtime()) - hfile = "{0}_{1}_mcmc.h5".format(args.outfile, ts) - output = fit_model(obs, model, sps, noise, **run_params) + hfile = f"{args.outfile}_{ts}_result.h5" + + # --- Run the actual fit --- + output = fit_model(obs, model, sps, **config) - # Write results to output file - writer.write_hdf5(hfile, run_params, model, obs, - output["sampling"][0], output["optimization"][0], - tsample=output["sampling"][1], - toptimize=output["optimization"][1], - sps=sps) + print("writing to {}".format(hfile)) + writer.write_hdf5(hfile, + config=config, + model=model, + obs=obs, + output["sampling"], + output["optimization"], + sps=sps + ) + try: + hfile.close() + except(AttributeError): + pass Command Line Options and Custom Arguments @@ -100,7 +115,7 @@ The required methods in a **parameter file** for building the data and model are 1. :py:meth:`build_obs`: This function will take the command line arguments dictionary as keyword arguments - and returns on obs dictionary (see :doc:`dataformat` .) + and returns a list of `Observation` instances (see :doc:`dataformat` .) 2. :py:meth:`build_model`: This function will take the command line arguments dictionary dictionary as keyword arguments @@ -115,9 +130,11 @@ The required methods in a **parameter file** for building the data and model are building code and as such has a large memory footprint. 4. :py:meth:`build_noise`: - This function should return a :py:class:`NoiseModel` object for the spectroscopy and/or - photometry. Either or both can be ``None`` (the default) in which case the likelihood - will not include covariant noise or jitter and is equivalent to basic :math:`\chi^2`. + This function, if present, should add a :py:class:`NoiseModel` object to the + spectroscopy and/or photometry. If not present the likelihood will not + include covariant noise or jitter and is equivalent to basic :math:`\chi^2`. + + Using MPI --------- @@ -133,7 +150,8 @@ might actually increase the total CPU usage). To use MPI a "pool" of cores must be made available; each core will instantiate the fitting ingredients separately, and a single core in the pool will then conduct the fit, distributing likelihood requests to the other cores in the -pool. This requires changes to the final code block that instantiates and runs the fit: +pool. This requires changes to the final code block that instantiates and runs +the fit: .. code-block:: python @@ -175,7 +193,7 @@ pool. This requires changes to the final code block that instantiates and runs # Evaluate SPS over logzsol grid in order to get necessary data in cache/memory # for each MPI process. Otherwise, you risk creating a lag between the MPI tasks - # caching data depending which can slow down the parallelization + # caching SSPs which can slow down the parallelization if (withmpi) & ('logzsol' in model.free_params): dummy_obs = dict(filters=None, wavelength=None) @@ -211,13 +229,15 @@ pool. This requires changes to the final code block that instantiates and runs # Set up an output file and write ts = time.strftime("%y%b%d-%H.%M", time.localtime()) - hfile = "{0}_{1}_mcmc.h5".format(args.outfile, ts) - writer.write_hdf5(hfile, run_params, model, obs, - output["sampling"][0], output["optimization"][0], - tsample=output["sampling"][1], - toptimize=output["optimization"][1], - sps=sps) - + hfile = f"{args.outfile}_worker{comm.rank()}_{ts}_mcmc.h5" + writer.write_hdf5(hfile, + run_params=run_params, + model=model, + obs=obs, + output["sampling"], + output["optimization"], + sps=sps + ) try: hfile.close() except(AttributeError): @@ -229,7 +249,7 @@ Then, to run this file using mpi it can be called from the command line with som mpirun -np python parameter_file.py --emcee # or - mpirun -np python parameter_file.py --dynesty + mpirun -np python parameter_file.py --nested_sampler dynesty Note that only model evaluation is parallelizable with `dynesty`, and many operations (e.g. new point proposal) are still done in serial. This means that diff --git a/environment.yml b/environment.yml index 479fefeb..71e0be83 100644 --- a/environment.yml +++ b/environment.yml @@ -2,7 +2,7 @@ channels: - conda-forge - astropy dependencies: - - python==3.8 + - python==3.10 # infrastructure - pip - ipython diff --git a/misc/diagnostics.py b/misc/diagnostics.py deleted file mode 100644 index 2e1c70d6..00000000 --- a/misc/diagnostics.py +++ /dev/null @@ -1,418 +0,0 @@ -#Take the results from MCMC fitting of clusters -# and make diagnostic plots, or derive predictions for -# observables, etc.. - -import numpy as np -import matplotlib.pyplot as pl -import triangle -import pickle -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - - -def diagnostic_plots(sample_file, sps, model_file=None, - powell_file=None, inmod=None, - showpars=None, - nspec=5, thin=10, start=0, outname=None): - """ - Plots a number of diagnostics. These include: - spectrum - - the observed spectrum, the spectra produced from a given number of samples of the - posterior parameter space, the spectrum produced from marginalized means of each - parameter, the spectrum at the initial position from Powell minimization, and the - applied calibration model. - spectrum_blue - - same as above but for the blue region of the spectrum - sed - - as for spectrum, but f_nu at the effective wavelength of the - filters is shown instead. - stars - - just the stellar dust model for samples of the posterior. - spectrum_residuals - - plots of spectrum residuals for a given number of samples of the posterior - sed_residuals - - broadband photometry residuals, in units of f_nu - x_vs_step - - the evolution of the walkers in each parameter as a function of iteration - lnp_vs_step - - the evolution of the walkers in likelihood - triangle - - a corner plot of parameter covariances - """ - - #read results and set up model - if outname is None: - outname = sample_file#''.join(sample_file.split('.')[:-1]) - sample_results, pr, model = read_pickles(sample_file, model_file=model_file, - powell_file=powell_file, inmod=inmod) - for k, v in model.params.iteritems(): - try: - sps.params[k] = v - except KeyError: - pass - - ## Plot spectra and SEDs - ## - #rindex = model_obs(sample_results, sps, photflag=0, outname=outname, nsample=nspec, - # wlo=3400, whi =10e3, start=start) - #_ = model_obs(sample_results, sps, photflag=0, outname=outname, rindex=rindex, - # wlo=3600, whi=4450, extraname='_blue', start=start) - #_ = model_obs(sample_results, sps, photflag=1, outname=outname, nsample=15, - # wlo=2500, whi=8.5e3, start=start) - - #stellar_pop(sample_results, sps, outname=outname, nsample=nspec, - # wlo=3500, whi=9.5e3, start=start, - # alpha = 0.5, color = 'green') - - ## Plot spectral and SED residuals - ## - #residuals(sample_results, sps, photflag=0, outname=outname, nsample=nspec, - # linewidth=0.5, alpha=0.3, color='blue', marker=None, start=start, rindex=rindex) - #residuals(sample_results, sps, photflag=1, outname = outname, nsample = 15, - # linewidth=0.5, alpha=0.3, color='blue', marker='o', start=start, rindex=rindex) - - ## Plot parameters versus step - ## - param_evol(sample_results, outname=outname, showpars=showpars) - - ## Plot lnprob vs step (with a zoom-in) - ## - pl.figure() - pl.clf() - nwalk = sample_results['lnprobability'].shape[0] - for j in range(nwalk): - pl.plot(sample_results['lnprobability'][j,:]) - pl.ylabel('lnP') - pl.xlabel('step #') - pl.savefig('{0}.lnP_vs_step.png'.format(outname)) - pl.close() - #yl = sample_results['lnprobability'].max() + np.array([-3.0 * sample_results['lnprobability'][:,-1].std(), 10]) - #pl.ylim(yl[0], yl[1]) - #pl.savefig('{0}.lnP_vs_step_zoom.png'.format(outname)) - #pl.close() - - ## Triangle plot - ## - subtriangle(sample_results, outname=outname, - showpars=showpars, - start=start, thin=thin) - - return outname, sample_results, model, pr - - -def model_comp(theta, model, sps, photflag=0, inlog=True): - """ - Generate and return various components of the total model for a - given set of parameters - """ - obs, _, _ = obsdict(model.obs, photflag=photflag) - mask = obs['mask'] - mu = model.mean_model(theta, sps=sps)[photflag][mask] - spec = obs['spectrum'][mask] - wave = obs['wavelength'][mask] - - if photflag == 0: - cal = model.calibration()[mask] - try: - #model.gp.sigma = obs['unc'][mask]/mu - s = model.params['gp_jitter'] - a = model.params['gp_amplitude'] - l = model.params['gp_length'] - model.gp.factor(s, a, l, check_finite = False, force=True) - if inlog: - mu = np.log(mu) - delta = model.gp.predict(spec - mu - cal) - else: - delta = model.gp.predict(spec - mu*cal) - except: - delta = 0 - else: - mask = np.ones(len(obs['wavelength']), dtype= bool) - cal = np.ones(len(obs['wavelength'])) - delta = np.zeros(len(obs['wavelength'])) - - return mu, cal, delta, mask, wave - - -def model_obs(sample_results, sps, photflag=0, outname=None, - start=0, rindex =None, nsample=10, - wlo=3500, whi=9e3, extraname=''): - - """ - Plot the observed spectrum and overlay samples of the model - posterior, including different components of that model. - """ - - title = ['Spectrum', 'SED (photometry)'] - start = np.min([start, sample_results['chain'].shape[1]]) - flatchain = sample_results['chain'][:,start:,:] - flatchain = flatchain.reshape(flatchain.shape[0] * flatchain.shape[1], - flatchain.shape[2]) - - # draw samples - if rindex is None: - rindex = np.random.uniform(0, flatchain.shape[0], nsample).astype( int ) - else: - nsample = len(rindex) - # set up the observation dictionary for spectrum or SED - obs, outn, marker = obsdict(sample_results, photflag) - - # set up plot window and plot data - pl.figure() - pl.axhline( 0, linestyle = ':', color ='black') - pl.plot(obs['wavelength'], obs['spectrum'], - marker=marker, linewidth=0.5, - color='blue', label='observed') - - # plot the minimization result - theta = sample_results['initial_center'] - ypred, res, cal, mask, spop = model_components(theta, sample_results, obs, sps, photflag=photflag) - pl.plot(obs['wavelength'][mask], ypred + res, - marker=marker, alpha=0.5, linewidth=0.3, - color='cyan', label='minimization result') - - # loop over drawn samples and plot the model components - label = ['full model', 'calib.', 'GP'] - for i in range(nsample): - theta = flatchain[rindex[i],:] - ypred, res, cal, mask, spop = model_components(theta, sample_results, obs, sps, photflag=photflag) - - pl.plot(obs['wavelength'][mask], np.zeros(mask.sum()) + res, - linewidth=0.5, alpha=0.5, color='red', label=label[2]) - pl.plot(obs['wavelength'], cal * sample_results['model'].params.get('linescale', 1.0), - linewidth=0.5, color='magenta', label=label[1]) - pl.plot(obs['wavelength'][mask], ypred + res, - marker=marker, alpha=0.5 , color='green', label=label[0]) - label = 3 * [None] - - pl.legend(loc=0, fontsize='small') - pl.xlim(wlo, whi) - pl.xlabel(r'$\AA$') - pl.ylabel('Rate') - pl.title(title[photflag]) - if outname is not None: - pl.savefig('{0}.{1}{2}.png'.format(outname, outn, extraname), dpi=300) - pl.close() - return rindex - -def stellar_pop(sample_results, sps, outname=None, normalize_by=None, - start=0, rindex=None, nsample=10, - wlo=3500, whi=9e3, extraname='', **kwargs): - """ - Plot samples of the posterior for just the stellar population and - dust model. - """ - start = np.min([start, sample_results['chain'].shape[1]]) - flatchain = sample_results['chain'][:,start:,:] - flatchain = flatchain.reshape(flatchain.shape[0] * flatchain.shape[1], - flatchain.shape[2]) - # draw samples - if rindex is None: - rindex = np.random.uniform(0, flatchain.shape[0], nsample).astype( int ) - # set up the observation dictionary for spectrum or SED - obs, outn, marker = obsdict(sample_results, 0) - - # set up plot window - pl.figure() - pl.axhline( 0, linestyle=':', color='black') - - # loop over drawn samples and plot the model components - label = ['Stars & Dust'] - xl = '' - for i in range(nsample): - theta = flatchain[rindex[i],:] - ypred, res, cal, mask, spop = model_components(theta, sample_results, obs, sps, photflag=0) - if normalize_by is not None: - spop /= spop[normalize_by] - xl = '/C' - pl.plot(obs['wavelength'], spop, - label = label[0], **kwargs) - label = 3 * [None] - - pl.legend(loc = 0, fontsize = 'small') - pl.xlim(wlo, whi) - pl.xlabel(r'$\AA$') - pl.ylabel(r'L$_\lambda {0}$ (L$_\odot/\AA$)'.format(xl)) - if outname is not None: - pl.savefig('{0}.{1}{2}.png'.format(outname, 'stars', extraname), dpi=300) - pl.close() - - -def residuals(sample_results, sps, photflag=0, outname=None, - nsample=5, rindex=None, start=0, - wlo=3600, whi=7500, **kwargs): - """ - Plot residuals of the observations from samples of the model - posterior. This is done in terms of relative, uncertainty - normalized, and absolute residuals. Extra keywords are passed to - plot(). - """ - - start = np.min([start, sample_results['chain'].shape[1]]) - flatchain = sample_results['chain'][:,start:,:] - flatchain = flatchain.reshape(flatchain.shape[0] * flatchain.shape[1], - flatchain.shape[2]) - # draw samples - if rindex is None: - rindex = np.random.uniform(0, flatchain.shape[0], nsample).astype( int ) - nsample = len(rindex) - - # set up the observation dictionary for spectrum or SED - obs, outn, marker = obsdict(sample_results, photflag) - - # set up plot window - fig, axes = pl.subplots(3,1) - # draw guidelines - [a.axhline( int(i==0), linestyle=':', color='black') for i,a in enumerate(axes)] - axes[0].set_ylabel('obs/model') - axes[0].set_ylim(0.5,1.5) - axes[0].set_xticklabels([]) - axes[1].set_ylabel(r'(obs-model)/$\sigma$') - axes[1].set_ylim(-10,10) - axes[1].set_xticklabels([]) - axes[2].set_ylabel(r'(obs-model)') - axes[2].set_xlabel(r'$\AA$') - - # loop over the drawn samples - for i in range(nsample): - theta = flatchain[rindex[i],:] - ypred, res, cal, mask, spop = model_components(theta, sample_results, obs, sps, photflag=photflag) - wave, ospec, mod = obs['wavelength'][mask], obs['spectrum'][mask], (ypred + res) - axes[0].plot(wave, ospec / mod, **kwargs) - axes[1].plot(wave, (ospec - mod) / obs['unc'][mask], **kwargs) - axes[2].plot(wave, (ospec - mod), **kwargs) - - if photflag == 0: - [a.set_xlim(wlo,whi) for a in axes] - - fig.subplots_adjust(hspace =0) - if outname is not None: - fig.savefig('{0}.{1}_residuals.png'.format(outname, outn), dpi=300) - pl.close() - -def obsdict(inobs, photflag): - """ - Return a dictionary of observational data, generated depending on - whether you're matching photometry or spectroscopy. - """ - obs = inobs.copy() - if photflag == 0: - outn = 'spectrum' - marker = None - elif photflag == 1: - outn = 'sed' - marker = 'o' - obs['wavelength'] = np.array([f.wave_effective for f in obs['filters']]) - obs['spectrum'] = 10**(0-0.4 * obs['mags']) - obs['unc'] = obs['mags_unc'] * obs['spectrum'] - obs['mask'] = obs['mags_unc'] > 0 - - return obs, outn, marker - -def param_evol(sample_results, outname=None, showpars=None, start=0): - """ - Plot the evolution of each parameter value with iteration #, for - each chain. - """ - - chain = sample_results['chain'][:,start:,:] - nwalk = chain.shape[0] - parnames = np.array(theta_labels(sample_results['model'].theta_desc)) - - #restrict to desired parameters - if showpars is not None: - ind_show = np.array([p in showpars for p in parnames], dtype= bool) - parnames = parnames[ind_show] - chain = chain[:,:,ind_show] - - #set up plot windows - ndim = len(parnames) - nx = int(np.floor(np.sqrt(ndim))) - ny = int(np.ceil(ndim*1.0/nx)) - sz = np.array([nx,ny]) - factor = 3.0 # size of one side of one panel - lbdim = 0.2 * factor # size of left/bottom margin - trdim = 0.2 * factor # size of top/right margin - whspace = 0.05*factor # w/hspace size - plotdim = factor * sz + factor *(sz-1)* whspace - dim = lbdim + plotdim + trdim - - fig, axes = pl.subplots(nx, ny, figsize = (dim[1], dim[0])) - lb = lbdim / dim - tr = (lbdim + plotdim) / dim - fig.subplots_adjust(left=lb[1], bottom=lb[0], right=tr[1], top=tr[0], - wspace=whspace, hspace=whspace) - - #sequentially plot the chains in each parameter - for i in range(ndim): - ax = axes.flatten()[i] - for j in range(nwalk): - ax.plot(chain[j,:,i]) - ax.set_title(parnames[i]) - if outname is not None: - fig.savefig('{0}.x_vs_step.png'.format(outname)) - pl.close() - -def theta_labels(desc): - """ - Using the theta_desc parameter dictionary, return a list of the model - parameter names that has the same aorder as the sampling chain array - """ - label, index = [], [] - for p in desc.keys(): - nt = desc[p]['N'] - name = p - if p is 'amplitudes': - name = 'A' - if nt is 1: - label.append(name) - index.append(desc[p]['i0']) - else: - for i in xrange(nt): - label.append(name+'{0}'.format(i+1)) - index.append(desc[p]['i0']+i) - - return [l for (i,l) in sorted(zip(index,label))] - -def sample_photometry(sample_results, sps, filterlist, - start=0, wthin=16, tthin=10): - - chain, model = sample_results['chain'], sample_results['model'] - for k, v in model.sps_fixed_params.iteritems(): - sps.params[k] = v - model.filters = filterlist - nwalkers, nt, ndim = chain.shape - wit = range(0,nwalkers,wthin) #walkers to use - tit = range(start, nt, thin) #time steps to use - phot = np.zeros( len(wit), len(tit), len(filterlist)) #build storage - for i in wit: - for j in tit: - s, p, m = model.model(chain[i,j,:], sps=sps) - phot[i,j,:] = p - #mass[i,j] = m - return phot, wit, tit - -## All this because scipy changed -# the name of one class, which shouldn't even be a class. - -renametable = { - 'Result': 'OptimizeResult', - } - -def mapname(name): - if name in renametable: - return renametable[name] - return name - -def mapped_load_global(self): - module = mapname(self.readline()[:-1]) - name = mapname(self.readline()[:-1]) - klass = self.find_class(module, name) - self.append(klass) - -def load(file): - unpickler = pickle.Unpickler(file) - unpickler.dispatch[pickle.GLOBAL] = mapped_load_global - return unpickler.load() diff --git a/misc/fdot.py b/misc/fdot.py deleted file mode 100644 index 672c796f..00000000 --- a/misc/fdot.py +++ /dev/null @@ -1,30 +0,0 @@ -# script to calculate the fractional change in SSP flux as a function -# of time. -import matplotlib.pyplot as pl -import numpy as np -import fsps -sps = fsps.StellarPopulation(zcontinuous=0) - -# compile all metallicities -for i, z in enumerate(sps.zlegend): - w, s = sps.get_spectrum(zmet=i+1) -spec, mass, lbol = sps.all_ssp_spec(peraa=True) - - -wmin, wmax, amin, amax, zmet = 1.5e3, 2e4, 0.01, 10, 4 - -ages = 10**(sps.ssp_ages-9) -waves = sps.wavelengths - -gwave = (waves < wmax) & (waves > wmin) -gage = (ages < amax) & (ages > amin) - -fdot = np.diff(spec, axis=1) -fbar = (spec[:,:-1,:] + spec[:,1:,:])/2.0 - -pl.imshow(np.squeeze((fdot/fbar)[np.ix_(gwave, gage, [zmet])]), - interpolation='nearest', aspect='auto') - - - - diff --git a/prospect/__init__.py b/prospect/__init__.py index f1213763..338b909b 100644 --- a/prospect/__init__.py +++ b/prospect/__init__.py @@ -4,6 +4,7 @@ pass from . import models +from . import observation from . import fitting from . import io from . import sources diff --git a/prospect/fitting/__init__.py b/prospect/fitting/__init__.py index 80cbb8f7..0a6bbc6d 100644 --- a/prospect/fitting/__init__.py +++ b/prospect/fitting/__init__.py @@ -1,10 +1,10 @@ from .ensemble import run_emcee_sampler, restart_emcee_sampler from .minimizer import reinitialize -from .nested import run_dynesty_sampler +from .nested import run_nested_sampler from .fitting import fit_model, lnprobfn, run_minimize __all__ = ["fit_model", "lnprobfn", # below should all be removed/deprecated "run_emcee_sampler", "restart_emcee_sampler", - "run_dynesty_sampler", + "run_nested_sampler", "run_minimize", "reinitialize"] diff --git a/prospect/fitting/fitting.py b/prospect/fitting/fitting.py index dfc19a7e..2342afec 100755 --- a/prospect/fitting/fitting.py +++ b/prospect/fitting/fitting.py @@ -15,104 +15,82 @@ from .minimizer import minimize_wrapper, minimizer_ball from .ensemble import run_emcee_sampler -from .nested import run_dynesty_sampler -from ..likelihood import lnlike_spec, lnlike_phot, chi_spec, chi_phot, write_log -from ..utils.obsutils import fix_obs +from .nested import run_nested_sampler +from ..likelihood.likelihood import compute_chi, compute_lnlike __all__ = ["lnprobfn", "fit_model", - "run_minimize", "run_emcee", "run_dynesty" + "run_minimize", "run_ensemble", "run_nested" ] -def lnprobfn(theta, model=None, obs=None, sps=None, noise=(None, None), +def lnprobfn(theta, model=None, observations=None, sps=None, residuals=False, nested=False, negative=False, verbose=False): """Given a parameter vector and optionally a dictionary of observational ata and a model object, return the matural log of the posterior. This requires that an sps object (and if using spectra and gaussian processes, a NoiseModel) be instantiated. - :param theta: - Input parameter vector, ndarray of shape (ndim,) - - :param model: - SedModel model object, with attributes including ``params``, a - dictionary of model parameter state. It must also have - :py:func:`prior_product`, and :py:func:`predict` methods - defined. - - :param obs: - A dictionary of observational data. The keys should be - - + ``"wavelength"`` (angstroms) - + ``"spectrum"`` (maggies) - + ``"unc"`` (maggies) - + ``"maggies"`` (photometry in maggies) - + ``"maggies_unc"`` (photometry uncertainty in maggies) - + ``"filters"`` (:py:class:`sedpy.observate.FilterSet` or iterable of :py:class:`sedpy.observate.Filter`) - + and optional spectroscopic ``"mask"`` and ``"phot_mask"`` (same - length as ``spectrum`` and ``maggies`` respectively, True means use - the data points) - - :param sps: - A :py:class:`prospect.sources.SSPBasis` object or subclass thereof, or - any object with a ``get_spectrum`` method that will take a dictionary - of model parameters and return a spectrum, photometry, and ancillary - information. - - :param noise: (optional, default: (None, None)) - A 2-element tuple of :py:class:`prospect.likelihood.NoiseModel` objects. - - :param residuals: (optional, default: False) + Parameters + ---------- + theta : ndarray of shape ``(ndim,)`` + Input parameter vector + + model : instance of the :py:class:`prospect.models.SpecModel` + The model parameterization and parameter state. Must have + :py:meth:`predict()` defined + + observations : A list of :py:class:`observation.Observation` instances + The data to be fit. + + sps : instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. + The object used to construct the basic physical spectral model. + Anything with a compatible :py:func:`get_galaxy_spectrum` can + be used here. It will be passed to ``lnprobfn`` + + residuals : bool (optional, default: False) A switch to allow vectors of :math:`\chi` values to be returned instead of a scalar posterior probability. This can be useful for least-squares optimization methods. Note that prior probabilities are not included in this calculation. - :param nested: (optional, default: False) + nested : bool (optional, default: False) If ``True``, do not add the ln-prior probability to the ln-likelihood when computing the ln-posterior. For nested sampling algorithms the prior probability is incorporated in the way samples are drawn, so should not be included here. - :param negative: (optiona, default: False) + negative: bool (optional, default: False) If ``True`` return the negative on the ln-probability for minimization purposes. - :returns lnp: + Returns + ------- + lnp : float or ndarry of shape `(ndof,)` Ln-probability, unless ``residuals=True`` in which case a vector of :math:`\chi` values is returned. """ if residuals: - lnnull = np.zeros(obs["ndof"]) - 1e18 # np.infty - #lnnull = -np.infty + ndof = np.sum([obs["ndof"] for obs in observations]) + lnnull = np.zeros(ndof) - 1e18 # -np.inf else: - lnnull = -np.infty + lnnull = -np.inf # --- Calculate prior probability and exit if not within prior --- lnp_prior = model.prior_product(theta, nested=nested) if not np.isfinite(lnp_prior): return lnnull - # --- Update Noise Model --- - spec_noise, phot_noise = noise - vectors, sigma_spec = {}, None + # set parameters model.set_parameters(theta) - if spec_noise is not None: - spec_noise.update(**model.params) - vectors.update({"unc": obs.get('unc', None)}) - sigma_spec = spec_noise.construct_covariance(**vectors) - if phot_noise is not None: - phot_noise.update(**model.params) - vectors.update({'phot_unc': obs.get('maggies_unc', None), - 'phot': obs.get('maggies', None), - 'filter_names': obs.get('filter_names', None)}) + + # --- Update Noise Model Parameters --- + [obs.noise.update(**model.params) for obs in observations + if obs.noise is not None] # --- Generate mean model --- try: - t1 = time.time() - spec, phot, x = model.predict(theta, obs, sps=sps, sigma_spec=sigma_spec) - d1 = time.time() - t1 + predictions, x = model.predict(theta, observations, sps=sps) except(ValueError): return lnnull except: @@ -122,78 +100,53 @@ def lnprobfn(theta, model=None, obs=None, sps=None, noise=(None, None), # --- Optionally return chi vectors for least-squares --- # note this does not include priors! if residuals: - chispec = chi_spec(spec, obs) - chiphot = chi_phot(phot, obs) - return np.concatenate([chispec, chiphot]) - - # --- Mixture Model --- - f_outlier_spec = model.params.get('f_outlier_spec', 0.0) - if (f_outlier_spec != 0.0): - sigma_outlier_spec = model.params.get('nsigma_outlier_spec', 10) - vectors.update({'nsigma_outlier_spec': sigma_outlier_spec}) - f_outlier_phot = model.params.get('f_outlier_phot', 0.0) - if (f_outlier_phot != 0.0): - sigma_outlier_phot = model.params.get('nsigma_outlier_phot', 10) - vectors.update({'nsigma_outlier_phot': sigma_outlier_phot}) + chi = [compute_chi(pred, obs) for pred, obs in zip(predictions, observations)] + return np.concatenate(chi) # --- Emission Lines --- + lnp_eline = getattr(model, "_ln_eline_penalty", 0.0) # --- Calculate likelihoods --- - t1 = time.time() - lnp_spec = lnlike_spec(spec, obs=obs, - f_outlier_spec=f_outlier_spec, - spec_noise=spec_noise, - **vectors) - lnp_phot = lnlike_phot(phot, obs=obs, - f_outlier_phot=f_outlier_phot, - phot_noise=phot_noise, **vectors) - lnp_eline = getattr(model, '_ln_eline_penalty', 0.0) - - d2 = time.time() - t1 - if verbose: - write_log(theta, lnp_prior, lnp_spec, lnp_phot, d1, d2) - - lnp = lnp_prior + lnp_phot + lnp_spec + lnp_eline + lnp_data = [compute_lnlike(pred, obs, vectors={}) for pred, obs + in zip(predictions, observations)] + + lnp = lnp_prior + np.sum(lnp_data) + lnp_eline if negative: lnp *= -1 return lnp -def wrap_lnp(lnpfn, obs, model, sps, **lnp_kwargs): - return argfix(lnpfn, obs=obs, model=model, sps=sps, +def wrap_lnp(lnpfn, observations, model, sps, **lnp_kwargs): + return argfix(lnpfn, observations=observations, model=model, sps=sps, **lnp_kwargs) -def fit_model(obs, model, sps, noise=(None, None), lnprobfn=lnprobfn, - optimize=False, emcee=False, dynesty=True, **kwargs): +def fit_model(observations, model, sps, lnprobfn=lnprobfn, + optimize=False, emcee=False, nested_sampler="", + **kwargs): """Fit a model to observations using a number of different methods - :param obs: - The ``obs`` dictionary containing the data to fit to, which will be - passed to ``lnprobfn``. + Parameters + ---------- + observations : list of :py:class:`observate.Observation` instances + The data to be fit. - :param model: - An instance of the :py:class:`prospect.models.SedModel` class - containing the model parameterization and parameter state. It will be + model : instance of the :py:class:`prospect.models.SpecModel` + The model parameterization and parameter state. It will be passed to ``lnprobfn``. - :param sps: - An instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. - Alternatively, anything with a compatible :py:func:`get_spectrum` can + sps : instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. + The object used to construct the basic physical spectral model. + Anything with a compatible :py:func:`get_galaxy_spectrum` can be used here. It will be passed to ``lnprobfn`` - :param noise: (optional, default: (None, None)) - A tuple of NoiseModel objects for the spectroscopy and photometry - respectively. Can also be (None, None) in which case simple chi-square - will be used. - - :param lnprobfn: (optional, default: lnprobfn) - A posterior probability function that can take ``obs``, ``model``, - ``sps``, and ``noise`` as keywords. By default use the + lnprobfn : callable (optional, default: :py:meth:`lnprobfn`) + A posterior probability function that can take ``observations``, + ``model``, and ``sps`` as keywords. By default use the :py:func:`lnprobfn` defined above. - :param optimize: (optional, default: False) + optimize : bool (optional, default: False) If ``True``, conduct a round of optimization before sampling from the posterior. The model state will be set to the best value at the end of optimization before continuing on to sampling or returning. Parameters @@ -207,7 +160,7 @@ def fit_model(obs, model, sps, noise=(None, None), lnprobfn=lnprobfn, See :py:func:`run_minimize` for details. - :param emcee: (optional, default: False) + emcee : bool (optional, default: False) If ``True``, sample from the posterior using emcee. Additonal parameters controlling emcee can be passed via ``**kwargs``. These include @@ -215,104 +168,108 @@ def fit_model(obs, model, sps, noise=(None, None), lnprobfn=lnprobfn, + ``hfile``: an open h5py.File file handle for writing result incrementally Many additional emcee parameters can be provided here, see - :py:func:`run_emcee` for details. + :py:func:`run_ensemble` for details. - :param dynesty: + dynesty : bool (optional, default: True) If ``True``, sample from the posterior using dynesty. Additonal parameters controlling dynesty can be passed via ``**kwargs``. See :py:func:`run_dynesty` for details. - :returns output: + Returns + ------- + output : dictionary A dictionary with two keys, ``"optimization"`` and ``"sampling"``. The value of each of these is a 2-tuple with results in the first element and durations (in seconds) in the second element. """ # Make sure obs has required keys - obs = fix_obs(obs) + [obs.rectify() for obs in observations] - if emcee & dynesty: - msg = ("Cannot run both emcee and dynesty fits " + if (emcee) & (bool(nested_sampler)): + msg = ("Cannot run both emcee and nested fits " "in a single call to fit_model") raise(ValueError, msg) - if (not emcee) & (not dynesty) & (not optimize): + + if (not bool(emcee)) & (not bool(nested_sampler)) & (not optimize): msg = ("No sampling or optimization routine " "specified by user; returning empty results") warnings.warn(msg) - output = {"optimization": (None, 0.), - "sampling": (None, 0.)} + output = {"optimization": None, + "sampling": None} if optimize: - optres, topt, best = run_minimize(obs, model, sps, noise, + optres, topt, best = run_minimize(observations, model, sps, lnprobfn=lnprobfn, **kwargs) # set to the best model.set_parameters(optres[best].x) output["optimization"] = (optres, topt) if emcee: - run_sampler = run_emcee - elif dynesty: - run_sampler = run_dynesty + run_sampler = run_ensemble + elif nested_sampler: + run_sampler = run_nested + # put nested_sampler back into kwargs for lower level functions + kwargs["nested_sampler"] = nested_sampler else: return output - output["sampling"] = run_sampler(obs, model, sps, noise, - lnprobfn=lnprobfn, **kwargs) + output["sampling"] = run_sampler(observations, model, sps, + lnprobfn=lnprobfn, + **kwargs) return output -def run_minimize(obs=None, model=None, sps=None, noise=None, lnprobfn=lnprobfn, +def run_minimize(observations=None, model=None, sps=None, lnprobfn=lnprobfn, min_method='lm', min_opts={}, nmin=1, pool=None, **extras): """Run a minimization. This wraps the lnprobfn fixing the ``obs``, ``model``, ``noise``, and ``sps`` objects, and then runs a minimization of -lnP using scipy.optimize methods. - :param obs: - The ``obs`` dictionary containing the data to fit to, which will be - passed to ``lnprobfn``. + Parameters + ---------- + observations : list of :py:class:`observate.Observation` instances + The data to be fit. - :param model: - An instance of the :py:class:`prospect.models.SedModel` class - containing the model parameterization and parameter state. It will be + model : instance of the :py:class:`prospect.models.SpecModel` + The model parameterization and parameter state. It will be passed to ``lnprobfn``. - :param sps: - An instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. - Alternatively, anything with a compatible :py:func:`get_spectrum` can + sps : instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. + The object used to construct the basic physical spectral model. + Anything with a compatible :py:func:`get_galaxy_spectrum` can be used here. It will be passed to ``lnprobfn`` - :param noise: (optional) - If given, a tuple of :py:class:`NoiseModel` objects passed to - ``lnprobfn``. - - :param lnprobfn: (optional, default: lnprobfn) - A posterior probability function that can take ``obs``, ``model``, - ``sps``, and ``noise`` as keywords. By default use the + lnprobfn : callable (optional, default: :py:meth:`lnprobfn`) + A posterior probability function that can take ``observations``, + ``model``, and ``sps`` as keywords. By default use the :py:func:`lnprobfn` defined above. - :param min_method: (optional, default: 'lm') + min_method : string (optional, default: 'lm') Method to use for minimization * 'lm': Levenberg-Marquardt * 'powell': Powell line search method - :param nmin: (optional, default: 1) + nmin : int (optional, default: 1) Number of minimizations to do. Beyond the first, minimizations will be started from draws from the prior. - :param min_opts: (optional, default: {}) + min_opts : dict (optional, default: {}) Dictionary of minimization options passed to the scipy.optimize method. These include things like 'xtol', 'ftol', etc.. - :param pool: (optional, default: None) + pool : object (optional, default: None) A pool to use for parallel optimization from multiple initial positions. - :returns results: + Returns + ------- + results : A list of `scipy.optimize.OptimizeResult` objects. - :returns tm: + t_wall : float Wall time used for the minimization, in seconds. - :returns best: + best : int The index of the results list containing the lowest chi-square result. """ initial = model.theta.copy() @@ -330,8 +287,8 @@ def run_minimize(obs=None, model=None, sps=None, noise=None, lnprobfn=lnprobfn, residuals = False args = [] - loss = argfix(lnprobfn, obs=obs, model=model, sps=sps, - noise=noise, residuals=residuals, negative=True) + loss = argfix(lnprobfn, observations=observations, model=model, sps=sps, + residuals=residuals, negative=True) minimizer = minimize_wrapper(algorithm, loss, [], min_method, min_opts) qinit = minimizer_ball(initial, nmin, model) @@ -353,60 +310,54 @@ def run_minimize(obs=None, model=None, sps=None, noise=None, lnprobfn=lnprobfn, return results, tm, best -def run_emcee(obs, model, sps, noise, lnprobfn=lnprobfn, - hfile=None, initial_positions=None, - **kwargs): +def run_ensemble(observations, model, sps, lnprobfn=lnprobfn, + hfile=None, initial_positions=None, **kwargs): """Run emcee, optionally including burn-in and convergence checking. Thin wrapper on :py:class:`prospect.fitting.ensemble.run_emcee_sampler` - :param obs: - The ``obs`` dictionary containing the data to fit to, which will be - passed to ``lnprobfn``. + Parameters + ---------- + observations : list of :py:class:`observate.Observation` instances + The data to be fit. - :param model: - An instance of the :py:class:`prospect.models.SedModel` class - containing the model parameterization and parameter state. It will be + model : instance of the :py:class:`prospect.models.SpecModel` + The model parameterization and parameter state. It will be passed to ``lnprobfn``. - :param sps: - An instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. - Alternatively, anything with a compatible :py:func:`get_spectrum` can + sps : instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. + The object used to construct the basic physical spectral model. + Anything with a compatible :py:func:`get_galaxy_spectrum` can be used here. It will be passed to ``lnprobfn`` - :param noise: - A tuple of :py:class:`NoiseModel` objects passed to ``lnprobfn``. - - :param lnprobfn: (optional, default: lnprobfn) - A posterior probability function that can take ``obs``, ``model``, - ``sps``, and ``noise`` as keywords. By default use the + lnprobfn : callable (optional, default: :py:meth:`lnprobfn`) + A posterior probability function that can take ``observations``, + ``model``, and ``sps`` as keywords. By default use the :py:func:`lnprobfn` defined above. - :param hfile: (optional, default: None) + hfile : :py:class:`h5py.File()` instance (optional, default: None) A file handle for a :py:class:`h5py.File` object that will be written to incremantally during sampling. - :param initial_positions: (optional, default: None) - If given, a set of initial positions for the emcee walkers. Must have - shape (nwalkers, ndim). Rounds of burn-in will be skipped if this - parameter is present. + initial_positions : ndarray of shape ``(nwalkers, ndim)`` (optional, default: None) + If given, a set of initial positions for the emcee walkers. Rounds of + burn-in will be skipped if this parameter is present. Extra Parameters -------- - - :param nwalkers: + nwalkers : int The number of walkers to use. If None, use the nearest power of two to ``ndim * walker_factor``. - :param niter: + niter : int Number of iterations for the production run - :param nburn: + nburn : list of int List of the number of iterations to run in each round of burn-in (for removing stuck walkers.) E.g. `nburn=[32, 64]` will run the sampler for 32 iterations before reinitializing and then run the sampler for another 64 iterations before starting the production run. - :param storechain: (default: True) + storechain : bool (default: True) If using HDF5 output, setting this to False will keep the chain from being held in memory by the sampler object. @@ -432,107 +383,105 @@ def run_emcee(obs, model, sps, noise, lnprobfn=lnprobfn, Returns -------- - - :returns sampler: + sampler : An instance of :py:class:`emcee.EnsembleSampler`. - :returns ts: + t_wall : float Duration of sampling (including burn-in) in seconds of wall time. """ q = model.theta.copy() - postkwargs = {"obs": obs, - "model": model, - "sps": sps, - "noise": noise, - "nested": False, - } + postkwargs = {} + # Hack for MPI pools to access the global namespace + for item in ['observations', 'model', 'sps']: + val = eval(item) + if val is not None: + postkwargs[item] = val + postkwargs['nested'] = False # Could try to make signatures for these two methods the same.... if initial_positions is not None: - meth = restart_emcee_sampler - t = time.time() - out = meth(lnprobfn, initial_positions, hdf5=hfile, - postkwargs=postkwargs, **kwargs) + raise NotImplementedError + go = time.time() + out = restart_emcee_sampler(lnprobfn, initial_positions, + hdf5=hfile, + postkwargs=postkwargs, + **kwargs) sampler = out - ts = time.time() - t + ts = time.time() - go else: - meth = run_emcee_sampler - t = time.time() - out = meth(lnprobfn, q, model, hdf5=hfile, - postkwargs=postkwargs, **kwargs) - sampler, burn_p0, burn_prob0 = out - ts = time.time() - t + go = time.time() + out = run_emcee_sampler(lnprobfn, q, model, + hdf5=hfile, + postkwargs=postkwargs, + **kwargs) + sampler, burn_loc0, burn_prob0 = out + ts = time.time() - go - return sampler, ts + try: + sampler.duration = ts + except: + pass + return sampler -def run_dynesty(obs, model, sps, noise, lnprobfn=lnprobfn, - pool=None, nested_target_n_effective=10000, **kwargs): - """Thin wrapper on :py:class:`prospect.fitting.nested.run_dynesty_sampler` - :param obs: - The ``obs`` dictionary containing the data to fit to, which will be - passed to ``lnprobfn``. +def run_nested(observations, model, sps, + lnprobfn=lnprobfn, + nested_sampler="dynesty", + nested_nlive=1000, + nested_target_n_effective=1000, + verbose=False, + **kwargs): + """Thin wrapper on :py:class:`prospect.fitting.nested.run_nested_sampler` + + Parameters + ---------- + observations : list of :py:class:`observate.Observation` instances + The data to be fit. - :param model: - An instance of the :py:class:`prospect.models.SedModel` class - containing the model parameterization and parameter state. It will be + model : instance of the :py:class:`prospect.models.SpecModel` + The model parameterization and parameter state. It will be passed to ``lnprobfn``. - :param sps: - An instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. - Alternatively, anything with a compatible :py:func:`get_spectrum` can + sps : instance of a :py:class:`prospect.sources.SSPBasis` (sub-)class. + The object used to construct the basic physical spectral model. + Anything with a compatible :py:func:`get_galaxy_spectrum` can be used here. It will be passed to ``lnprobfn`` - :param noise: - A tuple of :py:class:`prospect.likelihood.NoiseModel` objects passed to - ``lnprobfn``. - - :param lnprobfn: (optional, default: :py:func:`lnprobfn`) - A posterior probability function that can take ``obs``, ``model``, - ``sps``, and ``noise`` as keywords. This function must also take a - ``nested`` keyword. - - Extra Parameters - -------- - :param nested_bound: (optional, default: 'multi') - - :param nested_sample: (optional, default: 'unif') - - :param nested_nlive_init: (optional, default: 100) - - :param nested_nlive_batch: (optional, default: 100) - - :param nested_dlogz_init: (optional, default: 0.02) + lnprobfn : callable (optional, default: :py:meth:`lnprobfn`) + A posterior probability function that can take ``observations``, + ``model``, and ``sps`` as keywords. By default use the + :py:func:`lnprobfn` defined above. - :param nested_maxcall: (optional, default: None) + nested_target_n_effective : int + Target number of effective samples - :param nested_walks: (optional, default: 25) + nested_nlive : int + Number of live points for the nested sampler. Meaning somewhat + dependent on the chosen sampler Returns -------- + result: Dictionary + Will have keys: + * points : parameter location of the samples + * log_weight : ln of the weights of each sample + * log_like : ln of the likelihoods of each sample - :returns result: - An instance of :py:class:`dynesty.results.Results`. - - :returns ts: + t_wall : float Duration of sampling in seconds of wall time. """ - from dynesty.dynamicsampler import stopping_function, weight_function - nested_stop_kwargs = {"target_n_effective": nested_target_n_effective} - - lnp = wrap_lnp(lnprobfn, obs, model, sps, noise=noise, - nested=True) - - # Need to deal with postkwargs... - - t = time.time() - dynestyout = run_dynesty_sampler(lnp, model.prior_transform, model.ndim, - stop_function=stopping_function, - wt_function=weight_function, - nested_stop_kwargs=nested_stop_kwargs, - pool=pool, **kwargs) - ts = time.time() - t - - return dynestyout, ts + # wrap the probability fiunction, making sure it's a likelihood + likelihood = wrap_lnp(lnprobfn, observations, model, sps, nested=True) + + output = run_nested_sampler(model, + likelihood, + nested_sampler=nested_sampler, + verbose=verbose, + nested_nlive=nested_nlive, + nested_neff=nested_target_n_effective, + **kwargs) + info, result_obj = output + + return info diff --git a/prospect/fitting/nested.py b/prospect/fitting/nested.py index 26ef52fe..c8aaabc6 100644 --- a/prospect/fitting/nested.py +++ b/prospect/fitting/nested.py @@ -1,200 +1,132 @@ -import sys, time +import inspect import numpy as np -from numpy.random import normal, multivariate_normal -from six.moves import range +import time +import warnings -try: - import nestle -except(ImportError): - pass +__all__ = ["run_nested_sampler"] -try: - import dynesty - from dynesty.utils import * - from dynesty.dynamicsampler import _kld_error -except(ImportError): - pass - - -__all__ = ["run_nestle_sampler", "run_dynesty_sampler"] - - -def run_nestle_sampler(lnprobfn, model, verbose=True, - callback=None, - nestle_method='multi', nestle_npoints=200, - nestle_maxcall=int(1e6), nestle_update_interval=None, +def run_nested_sampler(model, + likelihood_function, + nested_sampler="dynesty", + nested_nlive=1000, + nested_neff=1000, + verbose=False, **kwargs): - - result = nestle.sample(lnprobfn, model.prior_transform, model.ndim, - method=nestle_method, npoints=nestle_npoints, - callback=callback, maxcall=nestle_maxcall, - update_interval=nestle_update_interval) - return result - - -def run_dynesty_sampler(lnprobfn, prior_transform, ndim, - verbose=True, - # sampler kwargs - nested_bound='multi', - nested_sample='unif', - nested_walks=25, - nested_update_interval=0.6, - nested_bootstrap=0, - pool=None, - use_pool={}, - queue_size=1, - # init sampling kwargs - nested_nlive_init=100, - nested_dlogz_init=0.02, - nested_maxiter_init=None, - nested_maxcall_init=None, - nested_live_points=None, - # batch sampling kwargs - nested_maxbatch=None, - nested_nlive_batch=100, - nested_maxiter_batch=None, - nested_maxcall_batch=None, - nested_use_stop=True, - # overall kwargs - nested_maxcall=None, - nested_maxiter=None, - nested_first_update={}, - stop_function=None, - wt_function=None, - nested_weight_kwargs={'pfrac': 1.0}, - nested_stop_kwargs={}, - nested_save_bounds=False, - print_progress=True, - **extras): - - # instantiate sampler - dsampler = dynesty.DynamicNestedSampler(lnprobfn, prior_transform, ndim, - bound=nested_bound, - sample=nested_sample, - walks=nested_walks, - bootstrap=nested_bootstrap, - update_interval=nested_update_interval, - pool=pool, queue_size=queue_size, use_pool=use_pool - ) - - # generator for initial nested sampling - ncall = dsampler.ncall - niter = dsampler.it - 1 - tstart = time.time() - for results in dsampler.sample_initial(nlive=nested_nlive_init, - dlogz=nested_dlogz_init, - maxcall=nested_maxcall_init, - maxiter=nested_maxiter_init, - live_points=nested_live_points): - - try: - # dynesty >= 2.0 - (worst, ustar, vstar, loglstar, logvol, - logwt, logz, logzvar, h, nc, worst_it, - propidx, propiter, eff, delta_logz, blob) = results - except(ValueError): - # dynsety < 2.0 - (worst, ustar, vstar, loglstar, logvol, - logwt, logz, logzvar, h, nc, worst_it, - propidx, propiter, eff, delta_logz) = results - - if delta_logz > 1e6: - delta_logz = np.inf - ncall += nc - niter += 1 - - if print_progress: - with np.errstate(invalid='ignore'): - logzerr = np.sqrt(logzvar) - sys.stderr.write("\riter: {:d} | batch: {:d} | nc: {:d} | " - "ncall: {:d} | eff(%): {:6.3f} | " - "logz: {:6.3f} +/- {:6.3f} | " - "dlogz: {:6.3f} > {:6.3f} " - .format(niter, 0, nc, ncall, eff, logz, - logzerr, delta_logz, nested_dlogz_init)) - sys.stderr.flush() - - ndur = time.time() - tstart - if verbose: - print('\ndone dynesty (initial) in {0}s'.format(ndur)) - - if nested_maxcall is None: - nested_maxcall = sys.maxsize - if nested_maxbatch is None: - nested_maxbatch = sys.maxsize - if nested_maxcall_batch is None: - nested_maxcall_batch = sys.maxsize - if nested_maxiter is None: - nested_maxiter = sys.maxsize - if nested_maxiter_batch is None: - nested_maxiter_batch = sys.maxsize - - # generator for dynamic sampling - tstart = time.time() - for n in range(dsampler.batch, nested_maxbatch): - # Update stopping criteria. - dsampler.sampler.save_bounds = False - res = dsampler.results - mcall = min(nested_maxcall - ncall, nested_maxcall_batch) - miter = min(nested_maxiter - niter, nested_maxiter_batch) - if nested_use_stop: - if dsampler.use_pool_stopfn: - M = dsampler.M - else: - M = map - stop, stop_vals = stop_function(res, nested_stop_kwargs, - rstate=dsampler.rstate, M=M, - return_vals=True) - stop_val = stop_vals[2] - else: - stop = False - stop_val = np.NaN - - # If we have either likelihood calls or iterations remaining, - # run our batch. - if mcall > 0 and miter > 0 and not stop: - # Compute our sampling bounds using the provided - # weight function. - logl_bounds = wt_function(res, nested_weight_kwargs) - lnz, lnzerr = res.logz[-1], res.logzerr[-1] - for results in dsampler.sample_batch(nlive_new=nested_nlive_batch, - logl_bounds=logl_bounds, - maxiter=miter, - maxcall=mcall, - save_bounds=nested_save_bounds): - - try: - # dynesty >= 2.0 - (worst, ustar, vstar, loglstar, nc, - worst_it, propidx, propiter, eff, blob) = results - except(ValueError): - # dynesty < 2.0 - (worst, ustar, vstar, loglstar, nc, - worst_it, propidx, propiter, eff) = results - ncall += nc - niter += 1 - if print_progress: - sys.stderr.write("\riter: {:d} | batch: {:d} | " - "nc: {:d} | ncall: {:d} | " - "eff(%): {:6.3f} | " - "loglstar: {:6.3f} < {:6.3f} " - "< {:6.3f} | " - "logz: {:6.3f} +/- {:6.3f} | " - "stop: {:6.3f} " - .format(niter, n+1, nc, ncall, - eff, logl_bounds[0], loglstar, - logl_bounds[1], lnz, lnzerr, - stop_val)) - sys.stderr.flush() - dsampler.combine_runs() - else: - # We're done! - break - - ndur = time.time() - tstart + """We give a model -- parameter discription and prior transform -- and a + likelihood function. We get back samples, weights, and likelihood values. + + Parameters + ---------- + model : instance of the :py:class:`prospect.models.SpecModel` + The model parameterization and parameter state. + likelihood_function : callable + Likelihood function + nested_live : int + Number of live points. + nested_neff : float + Minimum effective sample size. + verbose : bool + Whether to output sampler progress. + + Returns + ------- + samples : 3-tuple of ndarrays (loc, logwt, loglike) + Loctions, log-weights, and log-likelihoods for the samples + + obj : Object + The sampling results object. This will depend on the nested sampler being used. + """ if verbose: - print('done dynesty (dynamic) in {0}s'.format(ndur)) - - return dsampler.results - + print(f"running {nested_sampler} for {nested_neff} effective samples") + + go = time.time() + + # Initialize the sampler. + if nested_sampler == 'nautilus': + from nautilus import Sampler + sampler_init = Sampler + init_args = (model.prior_transform, likelihood_function) + init_kwargs = dict(pass_dict=False, n_live=nested_nlive, + n_dim=model.ndim) + elif nested_sampler == 'ultranest': + from ultranest import ReactiveNestedSampler + sampler_init = ReactiveNestedSampler + init_args = (model.theta_labels(), likelihood_function, + model.prior_transform) + init_kwargs = dict() + elif nested_sampler == 'dynesty': + from dynesty import DynamicNestedSampler + sampler_init = DynamicNestedSampler + init_args = (likelihood_function, model.prior_transform, model.ndim) + init_kwargs = dict(nlive=nested_nlive) + elif nested_sampler == 'nestle': + import nestle + init_kwargs = dict() + else: + raise ValueError(f"No nested sampler called '{nested_sampler}'.") + + if nested_sampler != 'nestle': + sig = inspect.signature(sampler_init).bind_partial() + sig.apply_defaults() + for key in kwargs.keys() & init_kwargs.keys(): + warnings.warn(f"Value of key '{key}' overwritten.") + init_kwargs = { + **{key: kwargs[key] for key in sig.kwargs.keys() & kwargs.keys()}, + **init_kwargs} + sampler = sampler_init(*init_args, **init_kwargs) + + # Run the sampler. + if nested_sampler == 'nautilus': + sampler_run = sampler.run + run_args = () + run_kwargs = dict(n_eff=nested_neff, verbose=verbose) + elif nested_sampler == 'ultranest': + sampler_run = sampler.run + run_args = () + run_kwargs = dict( + min_ess=nested_neff, min_num_live_points=nested_nlive, + show_status=verbose) + elif nested_sampler == 'dynesty': + sampler_run = sampler.run_nested + run_args = () + run_kwargs = dict(n_effective=nested_neff, print_progress=verbose) + elif nested_sampler == 'nestle': + sampler_run = nestle.sample + run_args = (likelihood_function, model.prior_transform, model.ndim) + run_kwargs = dict() + + sig = inspect.signature(sampler_run).bind_partial() + sig.apply_defaults() + for key in kwargs.keys() & run_kwargs.keys(): + warnings.warn(f"Value of key '{key}' overwritten.") + run_kwargs = { + **{key: kwargs[key] for key in sig.kwargs.keys() & kwargs.keys()}, + **run_kwargs} + run_return = sampler_run(*run_args, **run_kwargs) + #for key in kwargs.keys() - (init_kwargs.keys() | run_kwargs.keys()): + # warnings.warn(f"Key '{key}' not recognized by the sampler.") + + if nested_sampler == 'nautilus': + obj = sampler + points, log_w, log_like = sampler.posterior() + elif nested_sampler == 'ultranest': + obj = run_return + points = np.array(run_return['weighted_samples']['points']) + log_w = np.log(np.array(run_return['weighted_samples']['weights'])) + log_like = np.array(run_return['weighted_samples']['logl']) + elif nested_sampler == 'dynesty': + obj = sampler + points = sampler.results["samples"] + log_w = sampler.results["logwt"] + log_like = sampler.results["logl"] + elif nested_sampler == 'nestle': + obj = run_return + points = run_return["samples"] + log_w = run_return["logwt"] + log_like = run_return["logl"] + + dur = time.time() - go + + return dict(points=points, log_weight=log_w, log_like=log_like, + duration=dur), obj diff --git a/prospect/io/read_results.py b/prospect/io/read_results.py index 119e4e1f..386a25a5 100644 --- a/prospect/io/read_results.py +++ b/prospect/io/read_results.py @@ -69,36 +69,18 @@ def results_from(filename, model_file=None, dangerous=True, **kwargs): """ # Read the basic chain, parameter, and run_params info - if filename.split('.')[-1] == 'h5': - res = read_hdf5(filename, **kwargs) - if "_mcmc.h5" in filename: - mf_default = filename.replace('_mcmc.h5', '_model') - else: - mf_default = "x" - else: - with open(filename, 'rb') as rf: - res = pickle.load(rf) - mf_default = filename.replace('_mcmc', '_model') - - # Now try to read the model object itself from a pickle - if model_file is None: - mname = mf_default - else: - mname = model_file - param_file = (res['run_params'].get('param_file', ''), - res.get("paramfile_text", '')) - model, powell_results = read_model(mname, param_file=param_file, - dangerous=dangerous, **kwargs) + res, obs = read_hdf5(filename, **kwargs) + model = None + + # Now try to instantiate the model object from the paramfile if dangerous: try: model = get_model(res) except: model = None - res['model'] = model - if powell_results is not None: - res["powell_results"] = powell_results + #res['model'] = model - return res, res["obs"], model + return res, obs, model def emcee_restarter(restart_from="", niter=32, **kwargs): @@ -153,56 +135,6 @@ def emcee_restarter(restart_from="", niter=32, **kwargs): return obs, model, sps, noise, run_params -def read_model(model_file, param_file=('', ''), dangerous=False, **extras): - """Read the model pickle. This can be difficult if there are user defined - functions that have to be loaded dynamically. In that case, import the - string version of the paramfile and *then* try to unpickle the model - object. - - :param model_file: - String, name and path to the model pickle. - - :param dangerous: (default: False) - If True, try to import the given paramfile. - - :param param_file: - 2-element tuple. The first element is the name of the paramfile, which - will be used to set the name of the imported module. The second - element is the param_file contents as a string. The code in this - string will be imported. - """ - model = powell_results = None - if os.path.exists(model_file): - try: - with open(model_file, 'rb') as mf: - mod = pickle.load(mf) - except(AttributeError): - # Here one can deal with module and class names that changed - with open(model_file, 'rb') as mf: - mod = load(mf) - except(ImportError, KeyError): - # here we load the parameter file as a module using the stored - # source string. Obviously this is dangerous as it will execute - # whatever is in the stored source string. But it can be used to - # recover functions (especially dependcy functions) that are user - # defined - path, filename = os.path.split(param_file[0]) - modname = filename.replace('.py', '') - if dangerous: - user_module = import_module_from_string(param_file[1], modname) - with open(model_file, 'rb') as mf: - mod = pickle.load(mf) - - model = mod['model'] - - for k, v in list(model.theta_index.items()): - if type(v) is tuple: - model.theta_index[k] = slice(*v) - powell_results = mod['powell'] - - return model, powell_results - - def read_hdf5(filename, **extras): """Read an HDF5 file (with a specific format) into a dictionary of results. @@ -218,8 +150,9 @@ def read_hdf5(filename, **extras): :param filename: Name of the HDF5 file. """ - groups = {"sampling": {}, "obs": {}, - "bestfit": {}, "optimization": {}} + groups = {"sampling": {}, + "bestfit": {}, + "optimization": {}} res = {} with h5py.File(filename, "r") as hf: # loop over the groups @@ -251,28 +184,26 @@ def read_hdf5(filename, **extras): res.update(groups['sampling']) res["bestfit"] = groups["bestfit"] res["optimization"] = groups["optimization"] - res['obs'] = groups['obs'] - try: - res['obs']['filters'] = load_filters([str(f) for f in res['obs']['filters']]) - except: - pass - try: - res['rstate'] = unpick(res['rstate']) - except: - pass - #try: - # mp = [names_to_functions(p.copy()) for p in res['model_params']] - # res['model_params'] = mp - #except: - # pass + # do observations + if 'observations' in hf: + try: + obs = obs_from_h5(hf['observations']) + except: + obs = None + else: + obs = None - return res + return res, obs -def read_pickles(filename, **kwargs): - """Alias for backwards compatability. Calls `results_from()`. - """ - return results_from(filename, **kwargs) +def obs_from_h5(obsgroup): + from ..observation import from_serial + observations = [] + for obsname, dset in obsgroup.items(): + arr, meta = dset[:], dict(dset.attrs) + obs = from_serial(arr, meta) + observations.append(obs) + return observations def get_sps(res): @@ -313,9 +244,9 @@ def get_sps(res): "same as the FSPS libraries that you are using now ({})".format(flib, rlib)) # If fitting and reading in are happening in different python versions, # ensure string comparison doesn't throw error: - if type(flib[0]) == 'bytes': + if isinstance(flib[0], bytes): flib = [i.decode() for i in flib] - if type(rlib[0]) == 'bytes': + if isinstance(rlib[0], bytes): rlib = [i.decode() for i in rlib] assert (flib[0] == rlib[0]) and (flib[1] == rlib[1]), liberr @@ -336,8 +267,7 @@ def get_model(res): A prospect.models.SedModel object """ import os - param_file = (res['run_params'].get('param_file', ''), - res.get("paramfile_text", '')) + param_file = ("prospar", res.get("paramfile_text", '')) path, filename = os.path.split(param_file[0]) modname = filename.replace('.py', '') user_module = import_module_from_string(param_file[1], modname) @@ -461,12 +391,6 @@ def traceplot(results, showpars=None, start=0, chains=slice(None), return fig -def param_evol(results, **kwargs): - """Backwards compatability - """ - return traceplot(results, **kwargs) - - def subcorner(results, showpars=None, truths=None, start=0, thin=1, chains=slice(None), logify=["mass", "tau"], **kwargs): @@ -553,12 +477,6 @@ def subcorner(results, showpars=None, truths=None, return fig -def subtriangle(results, **kwargs): - """Backwards compatability - """ - return subcorner(results, **kwargs) - - def compare_paramfile(res, filename): """Compare the runtime parameter file text stored in the `res` dictionary to the text of some existing file with fully qualified path `filename`. @@ -573,23 +491,3 @@ def compare_paramfile(res, filename): bbl = json.loads(b) bb = bbl.split('\n') pprint([l for l in unified_diff(aa, bb)]) - - -def names_to_functions(p): - """Replace names of functions (or pickles of objects) in a parameter - description with the actual functions (or pickles). - """ - from importlib import import_module - for k, v in list(p.items()): - try: - m = import_module(v[1]) - f = m.__dict__[v[0]] - except: - try: - f = pickle.loads(v) - except: - f = v - - p[k] = f - - return p diff --git a/prospect/io/write_results.py b/prospect/io/write_results.py index 00d0460f..ad08cfb1 100644 --- a/prospect/io/write_results.py +++ b/prospect/io/write_results.py @@ -5,45 +5,53 @@ to HDF5 files as well as to pickles. """ -import os, time, warnings -import pickle, json, base64 +import warnings +import pickle, json import numpy as np +from numpy.lib.recfunctions import structured_to_unstructured, unstructured_to_structured + try: import h5py _has_h5py_ = True except(ImportError): _has_h5py_ = False - -__all__ = ["githash", "write_pickles", "write_hdf5", +__all__ = ["githash", "write_hdf5", "chain_to_struct"] unserial = json.dumps('Unserializable') +class NumpyEncoder(json.JSONEncoder): + """ + """ + + def default(self, obj): + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, type): + return str(obj) + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + + return json.JSONEncoder.default(self, obj) + + def pick(obj): """create a serialized object that can go into hdf5 in py2 and py3, and can be read by both """ return np.void(pickle.dumps(obj, 0)) -#def run_command(cmd): -# """Open a child process, and return its exit status and stdout. -# """ -# import subprocess -# child = subprocess.Popen(cmd, shell=True, stderr=subprocess.PIPE, -# stdin=subprocess.PIPE, stdout=subprocess.PIPE) -# out = [s for s in child.stdout] -# w = child.wait() -# return os.WEXITSTATUS(w), out - - def githash(**extras): """Pull out the git hash history for Prospector here. """ try: - from .._version import __version__, __githash__ + from .._version import __version__#, __githash__ + __githash__ = None bgh = __version__, __githash__ except(ImportError): warnings.warn("Could not obtain prospector version info", RuntimeWarning) @@ -62,273 +70,196 @@ def paramfile_string(param_file=None, **extras): return pstr -def write_hdf5(hfile, run_params, model, obs, sampler=None, - optimize_result_list=None, tsample=0.0, toptimize=0.0, - sampling_initial_center=[], sps=None, **extras): +def write_hdf5(hfile, + config={}, + model=None, + obs=None, + sampling_result=None, + optimize_result_tuple=None, + write_model_params=True, + sps=None, + **extras): """Write output and information to an HDF5 file object (or group). - :param hfile: + hfile : string or `h5py.File` File to which results will be written. Can be a string name or an `h5py.File` object handle. - :param run_params: + run_params : dict-like The dictionary of arguments used to build and fit a model. - :param model: - The `prospect.models.SedModel` object. + model : Instance of :py:class:`prospect.models.SpecModel` + The object. - :param obs: - The dictionary of observations that were fit. + obs : list of Observation() instances + The observations that were fit. - :param sampler: - The `emcee` or `dynesty` sampler object used to draw posterior samples. - Can be `None` if only optimization was performed. + sampling_result : EnsembleSampler() or dict + The `emcee` sampler used to draw posterior samples or nested sampler + output. Can be `None` if only optimization was performed. - :param optimize_result_list: + optimize_result_tuple : 2-tuple of (list, float) A list of `scipy.optimize.OptimizationResult` objects generated during - the optimization stage. Can be `None` if no optimization is performed + the optimization stage, and a float giving the duration of sampling. Can + be `None` if no optimization is performed - param sps: (optional, default: None) + sps : instance of :py:class:`prospect.sources.SSPBasis` (optional, default: None) If a `prospect.sources.SSPBasis` object is supplied, it will be used to - generate and store + generate and store best fit values (not implemented) """ - - if not _has_h5py_: - warnings.warn("HDF5 file could not be opened, as h5py could not be imported.") - return - # If ``hfile`` is not a file object, assume it is a filename and open - if type(hfile) is str: - # Check for existence of file, modify name if it already exists - if os.path.exists(hfile): - import time - time_string = time.strftime("%y%b%d-%H.%M", time.localtime()) - print("Appending current time ({0}) to output file ".format(time_string) + \ - "in order to guarantee a unique name.") - name, ext = os.path.splitext(hfile) - hfile = name+'_{0}'.format(time_string)+ext - print("New output filename: {0}".format(hfile)) - - hf = h5py.File(hfile, "a") + if isinstance(hfile, str): + hf = h5py.File(hfile, "w") else: hf = hfile + assert (model is not None), "Must pass a prospector model" + run_params = config + # ---------------------- # Sampling info - try: - # emcee - a = sampler.acceptance_fraction - write_emcee_h5(hf, sampler, model, sampling_initial_center, tsample) - except(AttributeError): - # dynesty or nestle - if sampler is None: - sdat = hf.create_group('sampling') - elif 'eff' in sampler: - write_dynesty_h5(hf, sampler, model, tsample) - else: - write_nestle_h5(hf, sampler, model, tsample) + if run_params.get("emcee", False): + chain, extras = emcee_to_struct(sampling_result, model) + elif bool(run_params.get("nested_sampler", False)): + chain, extras = nested_to_struct(sampling_result, model) + else: + chain, extras = None, None + write_sampling_h5(hf, chain, extras) + hf.flush() - # ----------------- - # Optimizer info - if optimize_result_list is not None: - out = optresultlist_to_ndarray(optimize_result_list) - mgroup = hf.create_group('optimization') - mdat = mgroup.create_dataset('optimizer_results', data=out) + # ---------------------- + # Observational data + if obs is not None: + write_obs_to_h5(hf, obs) + hf.flush() # ---------------------- # High level parameter and version info - write_h5_header(hf, run_params, model) - hf.attrs['optimizer_duration'] = json.dumps(toptimize) + meta = metadata(run_params, model, write_model_params=write_model_params) + for k, v in meta.items(): + hf.attrs[k] = v hf.flush() - # ---------------------- - # Observational data - write_obs_to_h5(hf, obs) - hf.flush() + # ----------------- + # Optimizer info + if optimize_result_tuple is not None: + optimize_list, toptimize = optimize_result_tuple + optarr = optresultlist_to_ndarray(optimize_list) + opt = hf.create_group('optimization') + _ = opt.create_dataset('optimizer_results', data=optarr) + opt.attrs["optimizer_duration"] = json.dumps(toptimize) # --------------- # Best fitting model in space of data if sps is not None: if "sampling/chain" in hf: - from ..plotting.utils import best_sample - pbest = best_sample(hf["sampling"]) - spec, phot, mfrac = model.predict(pbest, obs=obs, sps=sps) - best = hf.create_group("bestfit") - best.create_dataset("spectrum", data=spec) - best.create_dataset("photometry", data=phot) - best.create_dataset("parameter", data=pbest) - best.attrs["mfrac"] = mfrac - if obs["wavelength"] is None: - best.create_dataset("restframe_wavelengths", data=sps.wavelengths) - - # Store the githash last after flushing since getting it might cause an + pass + #from ..plotting.utils import best_sample + #pbest = best_sample(hf["sampling"]) + #predictions, mfrac = model.predict(pbest, obs=obs, sps=sps) + #best = hf.create_group("bestfit") + #best.create_dataset("spectrum", data=spec) + #best.create_dataset("photometry", data=phot) + #best.create_dataset("parameter", data=pbest) + #best.attrs["mfrac"] = mfrac + #if obs["wavelength"] is None: + # best.create_dataset("restframe_wavelengths", data=sps.wavelengths) + + # Store the githash last after flushing since getting it might cause an # uncatchable crash bgh = githash(**run_params) hf.attrs['prospector_version'] = json.dumps(bgh) hf.close() -def write_emcee_h5(hf, sampler, model, sampling_initial_center, tsample): - """Write emcee information to the provided HDF5 file in the `sampling` - group. +def metadata(run_params, model, write_model_params=True): + """Generate a metadata dictionary, with serialized entries. """ - try: - sdat = hf['sampling'] - except(KeyError): - sdat = hf.create_group('sampling') - if 'chain' not in sdat: - sdat.create_dataset('chain', - data=sampler.chain) - lnp = sampler.lnprobability - if ((lnp.shape[0] != lnp.shape[1]) & - (lnp.T.shape == sampler.chain.shape[:-1])): - # hack to deal with emcee3rc lnprob transposition - lnp = lnp.T - sdat.create_dataset('lnprobability', data=lnp) - sdat.create_dataset('acceptance', - data=sampler.acceptance_fraction) - sdat.create_dataset('sampling_initial_center', - data=sampling_initial_center) - sdat.create_dataset('initial_theta', - data=model.initial_theta.copy()) - # JSON Attrs - sdat.attrs['rstate'] = pick(sampler.random_state) - sdat.attrs['sampling_duration'] = json.dumps(tsample) - sdat.attrs['theta_labels'] = json.dumps(list(model.theta_labels())) + meta = dict(run_params=run_params, + paramfile_text=paramfile_string(**run_params)) + if write_model_params: + from copy import deepcopy + meta["model_params"] = deepcopy(model.params) + for k, v in list(meta.items()): + try: + meta[k] = json.dumps(v, cls=NumpyEncoder) + except(TypeError): + meta[k] = pick(v) + except: + meta[k] = unserial - hf.flush() + return meta -def write_nestle_h5(hf, nestle_out, model, tsample): - """Write nestle results to the provided HDF5 file in the `sampling` group. - """ - try: - sdat = hf['sampling'] - except(KeyError): - sdat = hf.create_group('sampling') - sdat.create_dataset('chain', - data=nestle_out['samples']) - sdat.create_dataset('weights', - data=nestle_out['weights']) - sdat.create_dataset('lnlikelihood', - data=nestle_out['logl']) - sdat.create_dataset('lnprobability', - data=(nestle_out['logl'] + - model.prior_product(nestle_out['samples']))) - sdat.create_dataset('logvol', - data=nestle_out['logvol']) - sdat.create_dataset('logz', - data=np.atleast_1d(nestle_out['logz'])) - sdat.create_dataset('logzerr', - data=np.atleast_1d(nestle_out['logzerr'])) - sdat.create_dataset('h_information', - data=np.atleast_1d(nestle_out['h'])) - - # JSON Attrs - for p in ['niter', 'ncall']: - sdat.attrs[p] = json.dumps(nestle_out[p]) - sdat.attrs['theta_labels'] = json.dumps(list(model.theta_labels())) - sdat.attrs['sampling_duration'] = json.dumps(tsample) +def emcee_to_struct(sampler, model): + # preamble + samples = sampler.get_chain(flat=True) + lnprior = model.prior_product(samples) + lnpost = sampler.get_log_prob(flat=True) - hf.flush() + # chaincat & extras + chaincat = chain_to_struct(samples, model=model) + extras = dict(weights=None, + lnprobability=lnpost, + lnlike=lnpost - lnprior, + acceptance=sampler.acceptance_fraction, + rstate=sampler.random_state, + duration=sampler.getattr("duration", 0.0)) + return chaincat, extras -def write_dynesty_h5(hf, dynesty_out, model, tsample): - """Write nestle results to the provided HDF5 file in the `sampling` group. - """ + +def nested_to_struct(nested_out, model): + # preamble + lnprior = model.prior_product(nested_out['points']) + + # chaincat & extras + chaincat = chain_to_struct(nested_out['points'], model=model) + extras = dict(weights=np.exp(nested_out['log_weight']), + lnprobability=nested_out['log_like'] + lnprior, + lnlike=nested_out['log_like'], + duration=nested_out.get("duration", 0.0) + ) + return chaincat, extras + + +def write_sampling_h5(hf, chain, extras): try: sdat = hf['sampling'] except(KeyError): sdat = hf.create_group('sampling') - sdat.create_dataset('chain', - data=dynesty_out['samples']) - sdat.create_dataset('weights', - data=np.exp(dynesty_out['logwt']-dynesty_out['logz'][-1])) - sdat.create_dataset('logvol', - data=dynesty_out['logvol']) - sdat.create_dataset('logz', - data=np.atleast_1d(dynesty_out['logz'])) - sdat.create_dataset('logzerr', - data=np.atleast_1d(dynesty_out['logzerr'])) - sdat.create_dataset('information', - data=np.atleast_1d(dynesty_out['information'])) - sdat.create_dataset('lnlikelihood', - data=dynesty_out['logl']) - sdat.create_dataset('lnprobability', - data=(dynesty_out['logl'] + - model.prior_product(dynesty_out['samples']))) - sdat.create_dataset('efficiency', - data=np.atleast_1d(dynesty_out['eff'])) - sdat.create_dataset('niter', - data=np.atleast_1d(dynesty_out['niter'])) - sdat.create_dataset('samples_id', - data=np.atleast_1d(dynesty_out['samples_id'])) - - # JSON Attrs - sdat.attrs['ncall'] = json.dumps(dynesty_out['ncall'].tolist()) - sdat.attrs['theta_labels'] = json.dumps(list(model.theta_labels())) - sdat.attrs['sampling_duration'] = json.dumps(tsample) - - hf.flush() - - -def write_h5_header(hf, run_params, model): - """Write header information about the run. - """ - serialize = {'run_params': run_params, - 'model_params': [functions_to_names(p.copy()) - for p in model.config_list], - 'paramfile_text': paramfile_string(**run_params)} - for k, v in list(serialize.items()): + sdat.create_dataset('chain', data=chain) + try: + uchain = structured_to_unstructured(chain) + sdat.create_dataset("unstructured_chain", data=uchain) + except: + pass + for k, v in extras.items(): try: - hf.attrs[k] = json.dumps(v) #, cls=NumpyEncoder) - except(TypeError): - # Should this fall back to pickle.dumps? - hf.attrs[k] = pick(v) - warnings.warn("Could not JSON serialize {}, pickled instead".format(k), - RuntimeWarning) + sdat.create_dataset(k, data=v) except: - hf.attrs[k] = unserial - warnings.warn("Could not serialize {}".format(k), RuntimeWarning) - hf.flush() + sdat.attrs[k] = v -def write_obs_to_h5(hf, obs): +def write_obs_to_h5(hf, obslist): """Write observational data to the hdf5 file """ try: - odat = hf.create_group('obs') + odat = hf.create_group('observations') except(ValueError): # We already have an 'obs' group return - for k, v in list(obs.items()): - if k == 'filters': - try: - v = [f.name for f in v] - except: - pass - if isinstance(v, np.ndarray): - odat.create_dataset(k, data=v) - else: - try: - odat.attrs[k] = json.dumps(v) #, cls=NumpyEncoder) - except(TypeError): - # Should this fall back to pickle.dumps? - odat.attrs[k] = pick(v) - warnings.warn("Could not JSON serialize {}, pickled instead".format(k)) - except: - odat.attrs[k] = unserial - warnings.warn("Could not serialize {}".format(k)) - + for obs in obslist: + obs.to_h5_dataset(odat) hf.flush() def optresultlist_to_ndarray(results): npar, nout = len(results[0].x), len(results[0].fun) - dt = [("success", np.bool), ("message", "S50"), ("nfev", np.int), - ("x", (np.float, npar)), ("fun", (np.float, nout))] + dt = [("success", bool), ("message", "U50"), ("nfev", int), + ("x", (float, npar)), ("fun", (float, nout))] out = np.zeros(len(results), dtype=np.dtype(dt)) for i, r in enumerate(results): for f in out.dtype.names: @@ -337,22 +268,31 @@ def optresultlist_to_ndarray(results): return out -def chain_to_struct(chain, model=None, names=None): +def chain_to_struct(chain, model=None, names=None, **extras): """Given a (flat)chain (or parameter dictionary) and a model, convert the chain to a structured array - :param chain: - A chain, ndarry of shape (nsamples, ndim) or a dictionary of - parameters, values of which are numpy datatypes. + Parameters + ---------- + chain : ndarry of shape (nsamples, ndim) + A chain or a dictionary of parameters, values of which are numpy + datatypes. + + model : A ProspectorParams instance + + names : list of strings - :param model: - A ProspectorParams instance + extras : optional + Extra keyword arguments are assumed to be 1d ndarrays of type np.float64 + and shape (nsamples,) that will be added as additional fields of the + output structure - :returns struct: + Returns + ------- + struct : A structured ndarray of parameter values. """ - indict = type(chain) == dict - if indict: + if isinstance(chain, dict): return dict_to_struct(chain) else: n = np.prod(chain.shape[:-1]) @@ -366,6 +306,9 @@ def chain_to_struct(chain, model=None, names=None): else: dt = [(str(p), " 0: + # Use the noise model variance, but otherwise compute on our own + assert self.Sigma.ndim == 1, "Outlier modeling only available for uncorrelated errors" + delta = obs.flux[obs.mask] - pred[obs.mask] + var = self.Sigma + lnp = -0.5*((delta**2 / var) + np.log(2*np.pi*var)) + var_bad = var * (self.n_sigma_outlier**2) + lnp_bad = -0.5*((delta**2 / var_bad) + np.log(2*np.pi*var_bad)) + lnp_tot = np.logaddexp(lnp + np.log(1 - self.f_outlier), lnp_bad + np.log(self.f_outlier)) + return np.sum(lnp_tot) + else: + raise ValueError("f_outlier must be >= 0") + + def populate_vectors(self, obs, vectors={}): + # update vectors + vectors["mask"] = obs.mask + vectors["wavelength"] = obs.wavelength + vectors["uncertainty"] = obs.uncertainty + vectors["flux"] = obs.flux + if obs.kind == "photometry": + vectors["filternames"] = obs.filternames + vectors["phot_samples"] = obs.get("phot_samples", None) + return vectors + + def construct_covariance(self, uncertainty=[], mask=slice(None), **other_vectors): + self.Sigma = np.atleast_1d(uncertainty[mask]**2) + + def compute(self, **vectors): + """Make a boring diagonal Covariance array + """ + self.construct_covariance(**vectors) + self.log_det = np.sum(np.log(self.Sigma)) + + def lnlikelihood(self, pred, data): + """Simple ln-likihood for diagonal covariance matrix. + """ + delta = data - pred + lnp = -0.5*(np.dot(delta**2, np.log(2*np.pi) / self.Sigma) + + self.log_det) + return lnp.sum() + + +class NoiseModel1D(NoiseModel): + """This class allows for 1D (diagonal) kernels + """ + + # TODO: metric names should be the responsibility of kernels, not noise models + def __init__(self, frac_out_name="f_outlier", + nsigma_out_name="nsigma_outlier", + metric_name='', + mask_name='mask', + kernels=[]): + self.frac_out_name = frac_out_name + self.nsigma_out_name = nsigma_out_name self.kernels = kernels - self.weight_names = weight_by self.metric_name = metric_name self.mask_name = mask_name - def update(self, **params): - [k.update(**params) for k in self.kernels] + def _available_parameters(self): + new_pars = [(self.frac_out_name, "Fraction of data points that are outliers"), + (self.nsigma_out_name, "Dispersion of the outlier distribution, in units of chi")] + for kernel in self.kernels: + new_pars += getattr(kernel, "_available_parameters", []) + return new_pars def construct_covariance(self, **vectors): """Construct a covariance matrix from a metric, a list of kernel @@ -29,67 +120,91 @@ def construct_covariance(self, **vectors): mask = vectors.get(self.mask_name, slice(None)) # 1 = uncorrelated errors, 2 = covariance matrix, >2 undefined - ndmax = np.array([k.ndim for k in self.kernels]).max() - Sigma = np.zeros(ndmax * [metric[mask].shape[0]]) + ndmax = 1 + Sigma = np.zeros(metric[mask].shape[0]) - weight_vectors = self.get_weights(**vectors) - for i, (kernel, wght) in enumerate(zip(self.kernels, weight_vectors)): - Sigma += kernel(metric[mask], weights=wght, ndim=ndmax) + for kernel in self.kernels: + wght = vectors.get(kernel.weight_by, None) + Sigma += kernel(metric[mask], weights=wght[mask], ndim=ndmax) return Sigma - def get_weights(self, **vectors): - """From a dictionary of vectors that give weights, pull the vectors - that correspond to each kernel, as stored in the `weight_names` - attribute. A None vector will result in None weights + +class NoiseModelCov(NoiseModel1D): + """This object allows for 1d or 2d covariance matrices constructed from + kernels. + """ + + def __init__(self, frac_out_name="f_outlier", nsigma_out_name="nsigma_outlier", + metric_name='', mask_name='mask', kernels=[], weight_by=[]): + + super().__init__(frac_out_name=frac_out_name, + nsigma_out_name=nsigma_out_name) + assert len(kernels) == len(weight_by) + self.kernels = kernels + self.weight_names = weight_by + self.metric_name = metric_name + self.mask_name = mask_name + + def construct_covariance(self, **vectors): + """Construct a covariance matrix from a metric, a list of kernel + objects, and a list of weight vectors (of same length as the metric) """ + metric = vectors[self.metric_name] mask = vectors.get(self.mask_name, slice(None)) - wghts = [] - for w in self.weight_names: - if w is None: - wghts += [None] - elif vectors[w] is None: - wghts += [None] - else: - wghts.append(vectors[w][mask]) - return wghts + + # 1 = uncorrelated errors, 2 = covariance matrix, >2 undefined + ndmax = np.array([k.ndim for k in self.kernels]).max() + Sigma = np.zeros(ndmax * [metric[mask].shape[0]]) + + for kernel in self.kernels: + wght = vectors.get(kernel.weight_by, None) + Sigma += kernel(metric[mask], weights=wght[mask], ndim=ndmax) + return Sigma def compute(self, check_finite=False, **vectors): """Build and cache the covariance matrix, and if it is 2-d factorize it and cache that. Also cache ``log_det``. """ self.Sigma = self.construct_covariance(**vectors) - if self.Sigma.ndim > 1: - self.factorized_Sigma = cho_factor(self.Sigma, overwrite_a=True, - check_finite=check_finite) + if self.Sigma.ndim == 1: + self.log_det = np.sum(np.log(self.Sigma)) + else: + self.factorized_Sigma = cho_factor(self.Sigma, overwrite_a=True, check_finite=check_finite) self.log_det = 2 * np.sum(np.log(np.diag(self.factorized_Sigma[0]))) assert np.isfinite(self.log_det) - else: - self.log_det = np.sum(np.log(self.Sigma)) - def lnlikelihood(self, phot_mu, phot_obs, check_finite=False, **extras): - """Compute the ln of the likelihood, using the current factorized - covariance matrix. + def lnlikelihood(self, prediction, data, check_finite=False): + """Compute the ln of the likelihood, using the current cached (and + factorized if non-diagonal) covariance matrix. - :param phot_mu: - Model photometry, same units as the photometry in `phot_obs`. - :param phot_obs: - Observed photometry, in linear flux units (i.e. maggies). + Parameters + ---------- + prediction : ndarray of float + Model flux, same units as `data`. + + data : ndarray of float + Observed flux, in linear flux units (i.e. maggies). + + Returns + ------- + lnlike : float + The likelihood fo the data """ - residual = phot_obs - phot_mu + residual = data - prediction n = len(residual) assert n == self.Sigma.shape[0] - if self.Sigma.ndim > 1: - first_term = np.dot(residual, cho_solve(self.factorized_Sigma, - residual, check_finite=check_finite)) + if self.Sigma.ndim == 1: + first_term = np.dot(residual**2, 1.0 / self.Sigma) else: - first_term = np.dot(residual**2, 1.0/self.Sigma) + CinvD = cho_solve(self.factorized_Sigma, residual, check_finite=check_finite) + first_term = np.dot(residual, CinvD) lnlike = -0.5 * (first_term + self.log_det + n * np.log(2.*np.pi)) return lnlike -class NoiseModelKDE(object): +class NoiseModelKDE: def __init__(self, metric_name="phot_samples", mask_name="mask"): # , kernel=None, weight_by=None): diff --git a/prospect/models/__init__.py b/prospect/models/__init__.py index 407c21ef..728647e8 100644 --- a/prospect/models/__init__.py +++ b/prospect/models/__init__.py @@ -5,11 +5,14 @@ specifications. """ -from .sedmodel import ProspectorParams, SedModel, SpecModel -from .sedmodel import PolySpecModel, SplineSpecModel -from .sedmodel import AGNSpecModel, LineSpecModel - -__all__ = ["ProspectorParams", "SpecModel", - "PolySpecModel", "SplineSpecModel", - "LineSpecModel", "AGNSpecModel", - "SedModel"] + +from .parameters import ProspectorParams +from .sedmodel import SpecModel, HyperSpecModel, AGNSpecModel + + +__all__ = ["ProspectorParams", + "SpecModel", + "HyperSpecModel", + "AGNSpecModel" + ] + diff --git a/prospect/models/hyperparam_transforms.py b/prospect/models/hyperparam_transforms.py new file mode 100644 index 00000000..907e2366 --- /dev/null +++ b/prospect/models/hyperparam_transforms.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""hyperparam_transforms.py -- This module contains parameter transformations that are +used in the stochastic SFH prior. + +These are taken from the implementation of +https://ui.adsabs.harvard.edu/abs/2024ApJ...961...53I/abstract given in +https://github.com/kartheikiyer/GP-SFH + +They can be used as ``"depends_on"`` entries in parameter specifications. +""" + +import numpy as np +from astropy.cosmology import FlatLambdaCDM + +__all__ = ["get_sfr_covar", "sfr_covar_to_sfr_ratio_covar"] + + +# -------------------------------------- +# --- Functions/transforms for stochastic SFH prior --- +# -------------------------------------- + + +# Creating a base class that simplifies a lot of things. +# The way this is set up, you can pass a kernel as an argument +# to compute the covariance matrix and draw samples from it. +class simple_GP_sfh(): + + """ + A class that creates and holds information about a specific + kernel, and can generate samples from it. + + From https://github.com/kartheikiyer/GP-SFH + + Attributes + ---------- + tarr: fiducial time array used to draw samples + kernel: accepts an input function as an argument, + of the format: + + def kernel_function(delta_t, **kwargs): + ... function interior ... + return kernel_val[array of len(delta_t)] + + Methods + ------- + get_covariance_matrix + [although this has double for loops for maximum flexibility + with generic kernel functions, it only has to be computed once, + which makes drawing random samples super fast once it's computed.] + sample_kernel + plot_samples + plot_kernel + [to-do] condition on data + + """ + + def __init__(self, sp = 'none', cosmo = FlatLambdaCDM(H0=70, Om0=0.3), zval = 0.1): + + + self.kernel = [] + self.covariance_matrix = [] + self.zval = zval + self.sp = sp + self.cosmo = cosmo + self.get_t_univ() + self.get_tarr() + + + def get_t_univ(self): + + self.t_univ = self.cosmo.age(self.zval).value + return + + def get_tarr(self, n_tarr = 1000): + + self.get_t_univ() + if n_tarr > 1: + self.tarr = np.linspace(0,self.t_univ, n_tarr) + elif n_tarr < 1: + self.tarr = np.arange(0,self.t_univ, n_tarr) + else: + raise('Undefined n_tarr: expected int or float.') + return + + + def get_covariance_matrix(self, show_prog = True, **kwargs): + """ + Evaluate covariance matrix with a particular kernel + """ + + cov_matrix = np.zeros((len(self.tarr),len(self.tarr))) + + if show_prog == True: + iterrange = range(len(cov_matrix)) + else: + iterrange = range(len(cov_matrix)) + for i in iterrange: + for j in range(len(cov_matrix)): + cov_matrix[i,j] = self.kernel(self.tarr[i] - self.tarr[j], **kwargs) + + return cov_matrix + + + +def extended_regulator_model_kernel_paramlist(delta_t, kernel_params, base_e_to_10 = False): + """ + A basic implementation of the regulator model kernel, with five parameters: + kernel_params = [sigma, tau_eq, tau_in, sigma_gmc, tau_gmc] + sigma: \sigma, the amount of overall variance + tau_eq: equilibrium timescale + tau_x: inflow correlation timescale (includes 2pi factor) + sigma_gmc: gmc variability + tau_l: cloud lifetime + + from https://github.com/kartheikiyer/GP-SFH + """ + + sigma, tau_eq, tau_in, sigma_gmc, tau_gmc = kernel_params + + if base_e_to_10 == True: + # in TCF20, this is defined in base e, so convert to base 10 + sigma = sigma*np.log10(np.e) + sigma_gmc = sigma_gmc*np.log10(np.e) + + tau = np.abs(delta_t) + + if tau_in == tau_eq: + c_reg = sigma**2 * (1 + tau/tau_eq) * (np.exp(-tau/tau_eq)) + else: + c_reg = sigma**2 / (tau_in - tau_eq) * (tau_in*np.exp(-tau/tau_in) - tau_eq*np.exp(-tau/tau_eq)) + + c_gmc = sigma_gmc**2 * np.exp(-tau/tau_gmc) + + kernel_val = (c_reg + c_gmc) + return kernel_val + + + +def get_sfr_covar(psd_params, agebins=[], **extras): + """Caluclates SFR covariance matrix for a given set of PSD parameters and agebins + PSD parameters must be in the order: [sigma_reg, tau_eq, tau_in, sigma_dyn, tau_dyn] + + from https://github.com/kartheikiyer/GP-SFH + + Returns + ------- + covar_matrix: (Nbins, Nbins)-dim array of covariance values for SFR + """ + + bincenters = np.array([np.mean(agebins[i]) for i in range(len(agebins))]) + bincenters = (10**bincenters)/1e9 + case1 = simple_GP_sfh() + case1.tarr = bincenters + case1.kernel = extended_regulator_model_kernel_paramlist + covar_matrix = case1.get_covariance_matrix(kernel_params = psd_params, show_prog=False) + + return covar_matrix + + +def sfr_covar_to_sfr_ratio_covar(covar_matrix): + """Caluclates log SFR ratio covariance matrix from SFR covariance matrix + + from https://github.com/kartheikiyer/GP-SFH + + Returns + ------- + sfr_ratio_covar: (Nbins-1, Nbins-1)-dim array of covariance values for log SFR + """ + + dim = covar_matrix.shape[0] + + sfr_ratio_covar = [] + + for i in range(dim-1): + row = [] + for j in range(dim-1): + cov = covar_matrix[i][j] - covar_matrix[i+1][j] - covar_matrix[i][j+1] + covar_matrix[i+1][j+1] + row.append(cov) + sfr_ratio_covar.append(row) + + return np.array(sfr_ratio_covar) \ No newline at end of file diff --git a/prospect/models/hyperparameters.py b/prospect/models/hyperparameters.py new file mode 100644 index 00000000..e11a925b --- /dev/null +++ b/prospect/models/hyperparameters.py @@ -0,0 +1,115 @@ +""" +hyperparameters.py + + +This class gets all all the ProspectorParams functionality, but it overrides the +_prior_product and prior_transform methods to sample the hyperparameters & log +SFR ratios of the stochastic SFH prior. +""" + +import numpy as np +import scipy +from . import priors +from . import hyperparam_transforms as transforms +from .parameters import ProspectorParams + +__all__ = ["ProspectorHyperParams"] + + +class ProspectorHyperParams(ProspectorParams): + + """ + This class implements a SFH prior that is determined by hyper-parameters + that in turn have their own prior distributions. + """ + + def _prior_product(self, theta, **extras): + """Return a scalar which is the ln of the product of the prior + probabilities for each element of theta. Requires that the prior + functions are defined in the theta descriptor. + + :param theta: + Iterable containing the free model parameter values. ndarray of + shape ``(ndim,)`` + + :returns lnp_prior: + The natural log of the product of the prior probabilities for these + parameter values. + """ + lnp_prior = 0 + + hyper_params = ['sigma_reg', 'tau_eq', 'tau_in', 'sigma_dyn', 'tau_dyn'] + psd_params = np.zeros(len(hyper_params)) + + for i, p in enumerate(hyper_params): + if self.config_dict[p]['isfree']: + inds = self.theta_index[p] + psd_params[i] = theta[..., inds][0] + func = self.config_dict[p]['prior'] + this_prior = np.sum(func(theta[..., inds]), axis=-1) + lnp_prior += this_prior + else: + psd_params[i] = self.config_dict[p]['init'] + + sfr_covar_matrix = transforms.get_sfr_covar(psd_params, agebins=self.config_dict['agebins']['init']) + sfr_ratio_covar_matrix = transforms.sfr_covar_to_sfr_ratio_covar(sfr_covar_matrix) + nbins = len(self.config_dict['agebins']['init']) + logsfr_ratio_prior = scipy.stats.multivariate_normal(mean=[0.]*(nbins-1), cov=sfr_ratio_covar_matrix) + inds = self.theta_index['logsfr_ratios'] + this_prior = np.sum(np.log(logsfr_ratio_prior.pdf(theta[..., inds]))) + lnp_prior += this_prior + + for k, inds in list(self.theta_index.items()): + if (k in hyper_params) or (k == 'logsfr_ratios'): + continue + func = self.config_dict[k]['prior'] + this_prior = np.sum(func(theta[..., inds]), axis=-1) + lnp_prior += this_prior + + return lnp_prior + + + def prior_transform(self, unit_coords): + """Go from unit cube to parameter space, for nested sampling. + + :param unit_coords: + Coordinates in the unit hyper-cube. ndarray of shape ``(ndim,)``. + + :returns theta: + The parameter vector corresponding to the location in prior CDF + corresponding to ``unit_coords``. ndarray of shape ``(ndim,)`` + """ + + theta = np.zeros(len(unit_coords)) + + hyper_params = ['sigma_reg', 'tau_eq', 'tau_in', 'sigma_dyn', 'tau_dyn'] + psd_params = np.zeros(len(hyper_params)) + + + for i, p in enumerate(hyper_params): + if self.config_dict[p]['isfree']: + func = self.config_dict[p]['prior'].unit_transform + inds = self.theta_index[p] + psd_params[i] = func(unit_coords[inds]) + theta[inds] = psd_params[i] + else: + psd_params[i] = self.config_dict[p]['init'] + + sfr_covar_matrix = transforms.get_sfr_covar(psd_params, agebins=self.config_dict['agebins']['init']) + sfr_ratio_covar_matrix = transforms.sfr_covar_to_sfr_ratio_covar(sfr_covar_matrix) + logsfr_ratio_prior = priors.MultiVariateNormal(mean=0, Sigma=sfr_ratio_covar_matrix) + x = unit_coords[self.theta_index['logsfr_ratios']] + logsfr_ratios = logsfr_ratio_prior.unit_transform(x) + theta[self.theta_index['logsfr_ratios']] = logsfr_ratios + + for k, inds in list(self.theta_index.items()): + if (k in hyper_params) or (k == 'logsfr_ratios'): + continue + func = self.config_dict[k]['prior'].unit_transform + theta[inds] = func(unit_coords[inds]) + + return theta + + + + diff --git a/prospect/models/model_setup.py b/prospect/models/model_setup.py deleted file mode 100644 index 1a1063e3..00000000 --- a/prospect/models/model_setup.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import sys, os, getopt, json, warnings -from copy import deepcopy -import numpy as np -from . import parameters -from ..utils.obsutils import fix_obs - -"""This module has methods to take a .py file containing run parameters, model -parameters and other info and return a run_params dictionary, an obs -dictionary, and a model. It also has methods to parse command line options and -return an sps object and noise objects. - -Most of the load_ methods are just really shallow wrappers on -```import_module_from_file(param_file).load_(**kwargs)``` and could probably -be done away with at this point, as they add a mostly useless layer of -abstraction. Kept here for future flexibility. -""" - -__all__ = ["parse_args", "import_module_from_file", "get_run_params", - "load_model", "load_obs", "load_sps", "load_gp", "show_syntax"] - - -deprecation_msg = ("Use argparse based operation; usage via prospector_*.py " - "scripts will be disabled in the future.") - - -def parse_args(argv, argdict={}): - """Parse command line arguments, allowing for optional arguments. - Simple/Fragile. - """ - warnings.warn(deprecation_msg, FutureWarning) - args = [sub for arg in argv[1:] for sub in arg.split('=')] - for i, a in enumerate(args): - if (a[:2] == '--'): - abare = a[2:] - if abare == 'help': - show_syntax(argv, argdict) - sys.exit() - else: - continue - if abare in argdict.keys(): - apo = deepcopy(args[i+1]) - func = type(argdict[abare]) - try: - argdict[abare] = func(apo) - if func is bool: - argdict[abare] = apo in ['True', 'true', 'T', 't', 'yes'] - except TypeError: - argdict[abare] = apo - return argdict - - -def get_run_params(param_file=None, argv=None, **kwargs): - """Get a run_params dictionary from the param_file (if passed) otherwise - return the kwargs dictionary. - - The order of precedence of parameter specification locations is: - * 1. param_file (lowest) - * 2. kwargs passsed to this function - * 3. command line arguments - """ - warnings.warn(deprecation_msg, FutureWarning) - rp = {} - if param_file is None: - ext = "" - else: - ext = param_file.split('.')[-1] - if ext == 'py': - setup_module = import_module_from_file(param_file) - rp = deepcopy(setup_module.run_params) - elif ext == 'json': - rp, mp = parameters.read_plist(param_file) - if kwargs is not None: - kwargs.update(rp) - rp = kwargs - if argv is not None: - rp = parse_args(argv, argdict=rp) - rp['param_file'] = param_file - - return rp - - -def load_sps(param_file=None, **kwargs): - """Return an ``sps`` object which is used to hold spectral libraries, - perform interpolations, convolutions, etc. - """ - warnings.warn(deprecation_msg, FutureWarning) - ext = param_file.split('.')[-1] - assert ext == 'py' - setup_module = import_module_from_file(param_file) - - if hasattr(setup_module, 'load_sps'): - builder = setup_module.load_sps - elif hasattr(setup_module, 'build_sps'): - builder = setup_module.build_sps - else: - warnings.warn("Could not find load_sps or build_sps methods in param_file") - return None - - sps = builder(**kwargs) - - return sps - - -def load_gp(param_file=None, **kwargs): - """Return two Gaussian Processes objects, either using BSFH's internal GP - objects or George. - - :returns gp_spec: - The gaussian process object to use for the spectroscopy. - - :returns gp_phot: - The gaussian process object to use for the photometry. - """ - warnings.warn(deprecation_msg, FutureWarning) - ext = param_file.split('.')[-1] - assert ext == "py" - setup_module = import_module_from_file(param_file) - - if hasattr(setup_module, 'load_gp'): - builder = setup_module.load_gp - elif hasattr(setup_module, 'build_noise'): - builder = setup_module.build_noise - else: - warnings.warn("Could not find load_gp or build_noise methods in param_file") - return None, None - - spec_noise, phot_noise = builder(**kwargs) - - return spec_noise, phot_noise - - -def load_model(param_file=None, **kwargs): - """Load the model object from a model config list given in the config file. - - :returns model: - An instance of the parameters.ProspectorParams object which has - been configured - """ - warnings.warn(deprecation_msg, FutureWarning) - ext = param_file.split('.')[-1] - assert ext == 'py' - setup_module = import_module_from_file(param_file) - #mp = deepcopy(setup_module.model_params) - - if hasattr(setup_module, 'load_model'): - builder = setup_module.load_model - elif hasattr(setup_module, 'build_model'): - builder = setup_module.build_model - else: - warnings.warn("Could not find load_model or build_model methods in param_file") - return None - - model = builder(**kwargs) - - return model - - -def load_obs(param_file=None, **kwargs): - """Load the obs dictionary using the `obs` attribute or methods in - ``param_file``. kwargs are passed to these methods and ``fix_obs()`` - - :returns obs: - A dictionary of observational data. - """ - warnings.warn(deprecation_msg, FutureWarning) - ext = param_file.split('.')[-1] - obs = None - assert ext == 'py' - print('reading py script {}'.format(param_file)) - setup_module = import_module_from_file(param_file) - - if hasattr(setup_module, 'obs'): - return fix_obs(deepcopy(setup_module.obs)) - if hasattr(setup_module, 'load_obs'): - builder = setup_module.load_obs - elif hasattr(setup_module, 'build_obs'): - builder = setup_module.build_obs - else: - warnings.warn("Could not find load_obs or build_obs methods in param_file") - return None - - obs = builder(**kwargs) - obs = fix_obs(obs, **kwargs) - - return obs - - -def import_module_from_file(path_to_file): - """This has to break everything ever, right? - """ - from importlib import import_module - path, filename = os.path.split(path_to_file) - modname = filename.replace('.py', '') - sys.path.insert(0, path) - user_module = import_module(modname) - sys.path.remove(path) - return user_module - - -def import_module_from_string(source, name, add_to_sys_modules=True): - """Well this seems dangerous. - """ - import imp - user_module = imp.new_module(name) - exec(source, user_module.__dict__) - if add_to_sys_modules: - sys.modules[name] = user_module - - return user_module - - -def show_syntax(args, ad): - """Show command line syntax corresponding to the provided arg dictionary - `ad`. - """ - print('Usage:\n {0} '.format(args[0]) + - ' '.join(['--{0}='.format(k) for k in ad.keys()])) - - -class Bunch(object): - """ Simple storage. - """ - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - - -def custom_filter_dict(filename): - filter_dict = {} - with open(filename, 'r') as f: - for line in f: - ind, name = line.split() - filter_dict[name.lower()] = Bunch(index=int(ind)-1) - - return filter_dict diff --git a/prospect/models/parameters.py b/prospect/models/parameters.py index 888e4d03..37624bef 100644 --- a/prospect/models/parameters.py +++ b/prospect/models/parameters.py @@ -10,10 +10,10 @@ from copy import deepcopy import warnings import numpy as np -import json, pickle from . import priors from .templates import describe + __all__ = ["ProspectorParams"] @@ -63,10 +63,10 @@ def __init__(self, configuration, verbose=True, param_order=None, **kwargs): """ self.init_config = deepcopy(configuration) self.parameter_order = param_order - if type(configuration) == list: + if isinstance(configuration, list): self.config_list = configuration self.config_dict = plist_to_pdict(self.config_list) - elif type(configuration) == dict: + elif isinstance(configuration, dict): self.config_dict = configuration self.config_list = pdict_to_plist(self.config_dict, order=param_order) else: @@ -184,8 +184,8 @@ def _prior_product(self, theta, **extras): parameter values. """ lnp_prior = 0 - for k, inds in list(self.theta_index.items()): + for k, inds in list(self.theta_index.items()): func = self.config_dict[k]['prior'] this_prior = np.sum(func(theta[..., inds]), axis=-1) lnp_prior += this_prior @@ -203,9 +203,12 @@ def prior_transform(self, unit_coords): corresponding to ``unit_coords``. ndarray of shape ``(ndim,)`` """ theta = np.zeros(len(unit_coords)) + for k, inds in list(self.theta_index.items()): + func = self.config_dict[k]['prior'].unit_transform theta[inds] = func(unit_coords[inds]) + return theta def propagate_parameter_dependencies(self): @@ -409,3 +412,31 @@ def pdict_to_plist(pdict, order=None): plist += [v] return plist + +# def get_sfr_covar(psd_params, agebins=[], **extras): + +# bincenters = np.array([np.mean(agebins[i]) for i in range(len(agebins))]) +# bincenters = (10**bincenters)/1e9 +# case1 = simple_GP_sfh() +# case1.tarr = bincenters +# case1.kernel = gp_sfh_kernels.extended_regulator_model_kernel_paramlist +# covar_matrix = case1.get_covariance_matrix(kernel_params = psd_params, show_prog=False) + +# return covar_matrix + + +# def sfr_covar_to_sfr_ratio_covar(covar_matrix): + +# dim = covar_matrix.shape[0] + +# sfr_ratio_covar = [] + +# for i in range(dim-1): +# row = [] +# for j in range(dim-1): +# cov = covar_matrix[i][j] - covar_matrix[i+1][j] - covar_matrix[i][j+1] + covar_matrix[i+1][j+1] +# row.append(cov) +# sfr_ratio_covar.append(row) + +# return np.array(sfr_ratio_covar) + diff --git a/prospect/models/priors.py b/prospect/models/priors.py index 335ba90e..cd149a2a 100644 --- a/prospect/models/priors.py +++ b/prospect/models/priors.py @@ -5,11 +5,13 @@ When called these return the ln-prior-probability, and they can also be used to construct prior transforms (for nested sampling) and can be sampled from. """ + import numpy as np import scipy.stats from scipy.special import erf, erfinv -__all__ = ["Prior", "Uniform", "TopHat", "Normal", "ClippedNormal", + +__all__ = ["Prior", "Uniform", "TopHat", "Normal", "MultiVariateNormal", "ClippedNormal", "LogNormal", "LogUniform", "Beta", "StudentT", "SkewNormal", "FastUniform", "FastTruncatedNormal", @@ -38,7 +40,7 @@ class Prior(object): A list of names of the parameters, used to alias the intrinsic parameter names. This way different instances of the same Prior can have different parameter names, in case they are being fit for.... - + Attributes ---------- params : dictionary @@ -106,18 +108,22 @@ def __call__(self, x, **kwargs): """ if len(kwargs) > 0: self.update(**kwargs) + pdf = self.distribution.pdf + try: p = pdf(x, *self.args, loc=self.loc, scale=self.scale) - except(ValueError): + + except(ValueError):#, TypeError): # Deal with `x` vectors of shape (nsamples, len(prior)) # for pdfs that don't broadcast nicely. p = [pdf(_x, *self.args, loc=self.loc, scale=self.scale) - for _x in x] + for _x in x] p = np.array(p) with np.errstate(invalid='ignore'): lnp = np.log(p) + return lnp def sample(self, nsample=None, **kwargs): @@ -260,6 +266,64 @@ def bounds(self, **kwargs): return (-np.inf, np.inf) +class MultiVariateNormal(Prior): + prior_params = ["mean", 'Sigma'] + distribution = scipy.stats.norm + + @property + def scale(self): + return self.params['Sigma'] + + @property + def loc(self): + return self.params['mean'] + + @property + def range(self): + nsig = 4 + return (self.params['mean'] - nsig * self.params['Sigma'], + self.params['mean'] + nsig * self.params['Sigma']) + + def bounds(self, **kwargs): + #if len(kwargs) > 0: + # self.update(**kwargs) + return (-np.inf, np.inf) + + def sample(self, nsample=None, **kwargs): + prior = scipy.stats.multivariate_normal(mean=self.loc, cov=self.scale) + return prior.rvs(size = nsample) + + def unit_transform(self, x, **kwargs): + """Go from a value of the CDF (between 0 and 1) to the corresponding + parameter value. + + :param x: + A scalar or vector of same length as the Prior with values between + zero and one corresponding to the value of the CDF. + + :returns theta: + The parameter value corresponding to the value of the CDF given by + `x`. + """ + if len(kwargs) > 0: + self.update(**kwargs) + z = self.distribution.ppf(x, *self.args, loc=0, scale=1) + #print(z) + sqrtS = np.linalg.cholesky(self.params['Sigma']) #, lower=True) + #print(sqrtS) + #theta = np.matmul(z, sqrtS) + theta = np.matmul(sqrtS, z) + return theta + + def inverse_unit_transform(self, x, **kwargs): + """Go from the parameter value to the unit coordinate using the cdf. + """ + if len(kwargs) > 0: + self.update(**kwargs) + return scipy.stats.multivariate_normal.cdf(x, *self.args, + mean=0., cov=self.params['Sigma']) + + class ClippedNormal(Prior): """A Gaussian prior clipped to some range. @@ -523,6 +587,7 @@ def range(self): def bounds(self, **kwargs): return (-np.inf, np.inf) + # fast versions to the above priors # essentially rewriting the numpy/scipy functions @@ -771,4 +836,4 @@ def unit_transform(self, x): return np.sqrt(self.invcdf_numerator(x) / self.invcdf_denominator(x)) def sample(self): - return self.unit_transform(np.random.rand()) + return self.unit_transform(np.random.rand()) \ No newline at end of file diff --git a/prospect/models/priors_beta.py b/prospect/models/priors_beta.py index 4b1325ea..532bd04f 100644 --- a/prospect/models/priors_beta.py +++ b/prospect/models/priors_beta.py @@ -5,13 +5,15 @@ Ref: Wang, Leja, et al., 2023, ApJL. Specifically, this module includes the following priors -- -1. PhiMet : p(logM|z)p(Z*|logM), i.e., mass funtion + mass-met -2. ZredMassMet : p(z)p(logM|z)p(Z*|logM), i.e., number density + mass funtion + mass-met -3. DymSFH : p(Z*|logM) & SFH(M, z), i.e., mass-met + SFH -4. PhiSFH : p(logM|z)p(Z*|logM) & SFH(M, z), i.e., mass funtion + mass-met + SFH -5. NzSFH : p(z)p(logM|z)p(Z*|logM) & SFH(M, z), - i.e., number density + mass funtion + mass-met + SFH; - this is the full set of prospector-beta priors. +1. PhiMet : p(logM|z)p(Z*|logM), i.e., mass function + mass-met +2. ZredMassMet : p(z)p(logM|z)p(Z*|logM), i.e., number density + mass function + mass-met +3. DymSFH : p(Z*|logM) & SFH(M, z), i.e., mass-met + SFH +4. DymSFHfixZred : same as above, but keeping zred fixed to a user-specified value, 'zred', during fitting +5. PhiSFH : p(logM|z)p(Z*|logM) & SFH(M, z), i.e., mass function + mass-met + SFH +6. PhiSFHfixZred : same as above, but keeping zred fixed to a user-specified value, 'zred', during fitting +7. NzSFH : p(z)p(logM|z)p(Z*|logM) & SFH(M, z), + i.e., number density + mass function + mass-met + SFH; + this is the full set of prospector-beta priors. When called these return the ln-prior-probability, and they can also be used to construct prior transforms (for nested sampling) and can be sampled from. @@ -24,7 +26,7 @@ from scipy.stats import t from . import priors -__all__ = ["PhiMet", "ZredMassMet", "DymSFH", "PhiSFH", "NzSFH"] +__all__ = ["PhiMet", "ZredMassMet", "DymSFH", "DymSFHfixZred", "PhiSFH", "PhiSFHfixZred", "NzSFH"] prior_data_dir = os.path.join(os.path.dirname(__file__), 'prior_data') massmet = np.loadtxt( os.path.join(prior_data_dir, 'gallazzi_05_massmet.txt')) @@ -45,7 +47,7 @@ class PhiMet(priors.Prior): prior_params = ['zred_mini', 'zred_maxi', 'mass_mini', 'mass_maxi', 'z_mini', 'z_maxi', 'const_phi'] # mass is in log10 def __init__(self, parnames=[], name='', **kwargs): - """Overwrites __init__ in the base code Prior + """Overwrites __init__ in the base code priors.Prior Parameters ---------- @@ -64,7 +66,6 @@ def __init__(self, parnames=[], name='', **kwargs): self.update(**kwargs) self.zred_dist = priors.FastUniform(a=self.params['zred_mini'], b=self.params['zred_maxi']) - self.mgrid = np.linspace(self.params['mass_mini'], self.params['mass_maxi'], 101) def __len__(self): @@ -105,7 +106,7 @@ def __call__(self, x, **kwargs): # doing mcmc; x is [zred, logmass, logzsol] p = np.zeros_like(x) # p(m) - p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi']) + p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) # p(zsol) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], mu=loc_massmet(x[1]), sig=scale_massmet(x[1])) @@ -115,7 +116,7 @@ def __call__(self, x, **kwargs): # p(z) lnp[0] = self.zred_dist(x[0]) - lnp[2] = met_dist(x[2]) # FastTruncatedNormal returns ln(p) + lnp[2] = met_dist(x[2]) # priors.FastTruncatedNormal returns ln(p) return lnp else: @@ -129,7 +130,7 @@ def __call__(self, x, **kwargs): for i in range(len(_zreds)): new_x = x[i] p = np.zeros_like(new_x) - p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi']) + p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) all_p.append(p) all_p = np.array(all_p) @@ -157,7 +158,7 @@ def sample(self, nsample=None, **kwargs): zred = self.zred_dist.sample() # draw from the mass function at the above zred - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = draw_sample(xs=self.mgrid, cdf=cdf_mass) # given mass from above, draw logzsol @@ -185,7 +186,7 @@ def unit_transform(self, x, **kwargs): zred = self.zred_dist.unit_transform(x[0]) - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = ppf(x[1], self.mgrid, cdf=cdf_mass) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], @@ -203,7 +204,7 @@ class ZredMassMet(priors.Prior): prior_params = ['zred_mini', 'zred_maxi', 'mass_mini', 'mass_maxi', 'z_mini', 'z_maxi', 'const_phi'] # mass is in log10 def __init__(self, parnames=[], name='', **kwargs): - """Overwrites __init__ in the base code Prior + """Overwrites __init__ in the base code priors.Prior Parameters ---------- @@ -222,12 +223,11 @@ def __init__(self, parnames=[], name='', **kwargs): self.update(**kwargs) if self.params['const_phi']: - zreds, pdf_zred = np.loadtxt( file_pdf_of_z_l20, unpack=True) + zreds, pdf_zred = np.loadtxt(file_pdf_of_z_l20, unpack=True) else: - zreds, pdf_zred = np.loadtxt( file_pdf_of_z_l20t18, unpack=True) + zreds, pdf_zred = np.loadtxt(file_pdf_of_z_l20t18, unpack=True) self.finterp_z_pdf, self.finterp_cdf_z = norm_pz(self.params['zred_mini'], self.params['zred_maxi'], zreds, pdf_zred) - self.mgrid = np.linspace(self.params['mass_mini'], self.params['mass_maxi'], 101) def __len__(self): @@ -270,7 +270,7 @@ def __call__(self, x, **kwargs): # p(z) p[0] = self.finterp_z_pdf(x[0]) # p(m) - p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi']) + p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) # p(zsol) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], mu=loc_massmet(x[1]), sig=scale_massmet(x[1])) @@ -278,7 +278,7 @@ def __call__(self, x, **kwargs): with np.errstate(invalid='ignore', divide='ignore'): lnp = np.log(p) - lnp[2] = met_dist(x[2]) # FastTruncatedNormal returns ln(p) + lnp[2] = met_dist(x[2]) # priors.FastTruncatedNormal returns ln(p) return lnp else: @@ -293,7 +293,7 @@ def __call__(self, x, **kwargs): new_x = x[i] p = np.zeros_like(new_x) p[0] = self.finterp_z_pdf(new_x[0]) - p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi']) + p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) all_p.append(p) @@ -322,7 +322,7 @@ def sample(self, nsample=None, **kwargs): zred = self.finterp_cdf_z(u) # draw from the mass function at the above zred - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = draw_sample(xs=self.mgrid, cdf=cdf_mass) # given mass from above, draw logzsol @@ -350,7 +350,7 @@ def unit_transform(self, x, **kwargs): zred = self.finterp_cdf_z(x[0]) - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = ppf(x[1], self.mgrid, cdf=cdf_mass) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], @@ -390,9 +390,7 @@ def __init__(self, parnames=[], name='', **kwargs): self.update(**kwargs) self.zred_dist = priors.FastUniform(a=self.params['zred_mini'], b=self.params['zred_maxi']) - self.mass_dist = priors.FastUniform(a=self.params['mass_mini'], b=self.params['mass_maxi']) - self.logsfr_ratios_dist = priors.FastTruncatedEvenStudentTFreeDeg2(hw=self.params['logsfr_ratio_maxi'], sig=self.params['logsfr_ratio_tscale']) def __len__(self): @@ -551,6 +549,195 @@ def unit_transform(self, x, **kwargs): np.atleast_1d(met), np.atleast_1d(logsfr_ratios_ppf)]) +############### SFH(M, z) at FIXED ZRED ################ +# mass-met & SFH(M, z) priors +# expectation value of the nonparametric SFH ~ Behroozi+19 cosmic SFRD + +class DymSFHfixZred(priors.Prior): + + prior_params = ['zred', 'mass_mini', 'mass_maxi', 'z_mini', 'z_maxi', + 'logsfr_ratio_mini', 'logsfr_ratio_maxi', 'logsfr_ratio_tscale', 'nbins_sfh', + 'const_phi'] # mass is in log10 + + def __init__(self, parnames=[], name='', **kwargs): + """Overwrites __init__ in the base code priors.Prior + + Parameters + ---------- + parnames : sequence of strings + A list of names of the parameters, used to alias the intrinsic + parameter names. This way different instances of the same Prior + can have different parameter names, in case they are being fit for.... + """ + if len(parnames) == 0: + parnames = self.prior_params + assert len(parnames) == len(self.prior_params) + self.alias = dict(zip(self.prior_params, parnames)) + self.params = {} + + self.name = name + self.update(**kwargs) + + self.zred = self.params['zred'] + self.mass_dist = priors.FastUniform(a=self.params['mass_mini'], b=self.params['mass_maxi']) + self.logsfr_ratios_dist = priors.FastTruncatedEvenStudentTFreeDeg2(hw=self.params['logsfr_ratio_maxi'], sig=self.params['logsfr_ratio_tscale']) + + def __len__(self): + """Hack to work with Prospector 0.3 + """ + return self.params['nbins_sfh']+2 # z, mass, met, + logsfr_ratios + + @property + def range(self): + return ( + (self.params['mass_mini'], self.params['mass_maxi']),\ + (self.params['z_mini'], self.params['z_maxi']),\ + (self.params['logsfr_ratio_mini'], self.params['logsfr_ratio_maxi']) + ) + + def bounds(self, **kwargs): + if len(kwargs) > 0: + self.update(**kwargs) + return self.range + + def __call__(self, x, **kwargs): + """Compute the value of the probability density function at x and + return the ln of that. + + :params x: used to calculate the prior + x[0] = zred, x[1] = logmass, x[2] = logzsol, x[3:] = logsfr_ratios + + :param kwargs: optional + All extra keyword arguments are used to update the `prior_params`. + + :returns lnp: + The natural log of the prior probability at x, scalar or ndarray of + same length as the prior object. + """ + if len(kwargs) > 0: + self.update(**kwargs) + + if x.ndim == 1: + # doing mcmc; x is [zred, logmass, logzsol] + p = np.zeros_like(x) + + # p(zsol) + met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(x[1]), sig=scale_massmet(x[1])) + # sfh = sfrd + logsfr_ratios = expe_logsfr_ratios(this_z=x[0], this_m=x[1], nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + p[3:] = t.pdf(x[3:], df=2, loc=logsfr_ratios, scale=self.params['logsfr_ratio_tscale']) + + with np.errstate(invalid='ignore', divide='ignore'): + lnp = np.log(p) + + lnp[0] = 0 # zred is fixed + lnp[1] = self.mass_dist(x[1]) + lnp[2] = met_dist(x[2]) # priors.FastTruncatedNormal returns ln(p) + + return lnp + + else: + # write_hdf5. last step. + # in prior_product, x is of size (nsamples, npriors) + # Fast* is not vectorized? + # so just do a loop here + _zreds = x[...,0] + _logms = x[...,1] + + all_p = [] + for i in range(len(_zreds)): + new_x = x[i] + p = np.zeros_like(new_x) + + logsfr_ratios = expe_logsfr_ratios(this_z=new_x[0], this_m=new_x[1], nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + p[3:] = t.pdf(new_x[3:], df=2, loc=logsfr_ratios, scale=self.params['logsfr_ratio_tscale']) + + all_p.append(p) + + all_p = np.array(all_p) + + with np.errstate(invalid='ignore', divide='ignore'): + lnp = np.log(all_p) + + met_dists = [priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(mass_i), sig=scale_massmet(mass_i)) for mass_i in x[...,1]] + lnp[...,0] = np.zeros(len(lnp[...,0])) + lnp[...,1] = self.mass_dist(_logms) + lnp[...,2] = [met_dists[i](met_i) for (i, met_i) in enumerate(x[...,2])] + + return lnp + + def sample(self, nsample=None, **kwargs): + """Draw a sample from the prior distribution. + Needed for minimizer. + + :param nsample: (optional) + Unused. Will not work if nsample > 1 in draw_sample()! + """ + if len(kwargs) > 0: + self.update(**kwargs) + # draw a zred from pdf(z) + zred = self.zred * 1 + + mass = self.mass_dist.sample() + + # given mass from above, draw logzsol + met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(mass), sig=scale_massmet(mass)) + met = met_dist.sample() + + # sfh = sfrd + logsfr_ratios = expe_logsfr_ratios(this_z=zred, this_m=mass, nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + logsfr_ratios_rvs = t.rvs(df=2, loc=logsfr_ratios, scale=self.params['logsfr_ratio_tscale']) + logsfr_ratios_rvs = np.clip(logsfr_ratios_rvs, a_min=self.params['logsfr_ratio_mini'], a_max=self.params['logsfr_ratio_maxi']) + + return np.concatenate([np.atleast_1d(zred), np.atleast_1d(mass), + np.atleast_1d(met), np.atleast_1d(logsfr_ratios_rvs)]) + + def unit_transform(self, x, **kwargs): + """Go from a value of the CDF (between 0 and 1) to the corresponding + parameter value. + Needed for nested sampling. + + :param x: + A scalar or vector of same length as the Prior with values between + zero and one corresponding to the value of the CDF. + + :returns theta: + The parameter value corresponding to the value of the CDF given by + `x`. + """ + if len(kwargs) > 0: + self.update(**kwargs) + + zred = self.zred * 1 + + mass = self.mass_dist.unit_transform(x[1]) + + met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(mass), sig=scale_massmet(mass)) + met = met_dist.unit_transform(x[2]) + + # sfh = sfrd + logsfr_ratios = expe_logsfr_ratios(this_z=zred, this_m=mass, nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + + logsfr_ratios_ppf = np.zeros_like(logsfr_ratios) + for i in range(len(logsfr_ratios_ppf)): + logsfr_ratios_ppf[i] = self.logsfr_ratios_dist.unit_transform(x[3+i]) + logsfr_ratios[i] + logsfr_ratios_ppf = np.clip(logsfr_ratios_ppf, a_min=self.params['logsfr_ratio_mini'], a_max=self.params['logsfr_ratio_maxi']) + return np.concatenate([np.atleast_1d(zred), np.atleast_1d(mass), + np.atleast_1d(met), np.atleast_1d(logsfr_ratios_ppf)]) + + ####################### p(logM|z)p(Z*|logM) & SFH(M, z) ####################### # mass function & Gaussian metallicity & SFH(M, z) priors # expectation value of the nonparametric SFH ~ Behroozi+19 cosmic SFRD @@ -562,7 +749,7 @@ class PhiSFH(priors.Prior): 'const_phi'] # mass is in log10 def __init__(self, parnames=[], name='', **kwargs): - """Overwrites __init__ in the base code Prior + """Overwrites __init__ in the base code priors.Prior Parameters ---------- @@ -581,16 +768,14 @@ def __init__(self, parnames=[], name='', **kwargs): self.update(**kwargs) if self.params['const_phi']: - zreds, pdf_zred = np.loadtxt( file_pdf_of_z_l20, unpack=True) + zreds, pdf_zred = np.loadtxt(file_pdf_of_z_l20, unpack=True) else: - zreds, pdf_zred = np.loadtxt( file_pdf_of_z_l20t18, unpack=True) + zreds, pdf_zred = np.loadtxt(file_pdf_of_z_l20t18, unpack=True) self.finterp_z_pdf, self.finterp_cdf_z = norm_pz(self.params['zred_mini'], self.params['zred_maxi'], zreds, pdf_zred) self.mgrid = np.linspace(self.params['mass_mini'], self.params['mass_maxi'], 101) - self.zred_dist = priors.FastUniform(a=self.params['zred_mini'], b=self.params['zred_maxi']) - self.logsfr_ratios_dist = priors.FastTruncatedEvenStudentTFreeDeg2(hw=self.params['logsfr_ratio_maxi'], sig=self.params['logsfr_ratio_tscale']) def __len__(self): @@ -632,7 +817,8 @@ def __call__(self, x, **kwargs): # doing mcmc; x is [zred, logmass, logzsol] p = np.zeros_like(x) # p(m) - p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi']) + p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) + # p(zsol) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], mu=loc_massmet(x[1]), sig=scale_massmet(x[1])) @@ -647,7 +833,7 @@ def __call__(self, x, **kwargs): # p(z) lnp[0] = self.zred_dist(x[0]) - lnp[2] = met_dist(x[2]) # FastTruncatedNormal returns ln(p) + lnp[2] = met_dist(x[2]) # priors.FastTruncatedNormal returns ln(p) return lnp @@ -662,7 +848,7 @@ def __call__(self, x, **kwargs): for i in range(len(_zreds)): new_x = x[i] p = np.zeros_like(new_x) - p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi']) + p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) logsfr_ratios = expe_logsfr_ratios(this_z=new_x[0], this_m=new_x[1], nbins_sfh=self.params['nbins_sfh'], logsfr_ratio_mini=self.params['logsfr_ratio_mini'], logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) @@ -695,7 +881,7 @@ def sample(self, nsample=None, **kwargs): zred = self.zred_dist.sample() # draw from the mass function at the above zred - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = draw_sample(xs=self.mgrid, cdf=cdf_mass) # given mass from above, draw logzsol @@ -731,7 +917,197 @@ def unit_transform(self, x, **kwargs): zred = self.zred_dist.unit_transform(x[0]) - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) + mass = ppf(x[1], self.mgrid, cdf=cdf_mass) + + met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(mass), sig=scale_massmet(mass)) + met = met_dist.unit_transform(x[2]) + + # sfh = sfrd + logsfr_ratios = expe_logsfr_ratios(this_z=zred, this_m=mass, nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + + logsfr_ratios_ppf = np.zeros_like(logsfr_ratios) + for i in range(len(logsfr_ratios_ppf)): + logsfr_ratios_ppf[i] = self.logsfr_ratios_dist.unit_transform(x[3+i]) + logsfr_ratios[i] + logsfr_ratios_ppf = np.clip(logsfr_ratios_ppf, a_min=self.params['logsfr_ratio_mini'], a_max=self.params['logsfr_ratio_maxi']) + return np.concatenate([np.atleast_1d(zred), np.atleast_1d(mass), + np.atleast_1d(met), np.atleast_1d(logsfr_ratios_ppf)]) + + +################ p(logM|z)p(Z*|logM) & SFH(M, z) at FIXED ZRED ################ +# mass function & Gaussian metallicity & SFH(M, z) priors +# expectation value of the nonparametric SFH ~ Behroozi+19 cosmic SFRD + +class PhiSFHfixZred(priors.Prior): + + prior_params = ['zred', 'mass_mini', 'mass_maxi', 'z_mini', 'z_maxi', + 'logsfr_ratio_mini', 'logsfr_ratio_maxi', 'logsfr_ratio_tscale', 'nbins_sfh', + 'const_phi'] # mass is in log10 + + def __init__(self, parnames=[], name='', **kwargs): + """Overwrites __init__ in the base code priors.Prior + + Parameters + ---------- + parnames : sequence of strings + A list of names of the parameters, used to alias the intrinsic + parameter names. This way different instances of the same Prior + can have different parameter names, in case they are being fit for.... + """ + if len(parnames) == 0: + parnames = self.prior_params + assert len(parnames) == len(self.prior_params) + self.alias = dict(zip(self.prior_params, parnames)) + self.params = {} + + self.name = name + self.update(**kwargs) + + self.mgrid = np.linspace(self.params['mass_mini'], self.params['mass_maxi'], 101) + self.zred = self.params['zred'] + self.logsfr_ratios_dist = priors.FastTruncatedEvenStudentTFreeDeg2(hw=self.params['logsfr_ratio_maxi'], sig=self.params['logsfr_ratio_tscale']) + + def __len__(self): + """Hack to work with Prospector 0.3 + """ + return self.params['nbins_sfh']+2 # z, mass, met, + logsfr_ratios + + @property + def range(self): + return ( + (self.params['mass_mini'], self.params['mass_maxi']),\ + (self.params['z_mini'], self.params['z_maxi']),\ + (self.params['logsfr_ratio_mini'], self.params['logsfr_ratio_maxi']) + ) + + def bounds(self, **kwargs): + if len(kwargs) > 0: + self.update(**kwargs) + return self.range + + def __call__(self, x, **kwargs): + """Compute the value of the probability density function at x and + return the ln of that. + + :params x: used to calculate the prior + x[0] = zred, x[1] = logmass, x[2] = logzsol, x[3:] = logsfr_ratios + + :param kwargs: optional + All extra keyword arguments are used to update the `prior_params`. + + :returns lnp: + The natural log of the prior probability at x, scalar or ndarray of + same length as the prior object. + """ + if len(kwargs) > 0: + self.update(**kwargs) + + if x.ndim == 1: + # doing mcmc; x is [zred, logmass, logzsol] + p = np.zeros_like(x) + # p(m) + p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) + # p(zsol) + met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(x[1]), sig=scale_massmet(x[1])) + # sfh = sfrd + logsfr_ratios = expe_logsfr_ratios(this_z=x[0], this_m=x[1], nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + p[3:] = t.pdf(x[3:], df=2, loc=logsfr_ratios, scale=self.params['logsfr_ratio_tscale']) + + with np.errstate(invalid='ignore', divide='ignore'): + lnp = np.log(p) + + lnp[0] = 0 # zred is fixed + lnp[2] = met_dist(x[2]) # priors.FastTruncatedNormal returns ln(p) + + return lnp + + else: + # write_hdf5. last step. + # in prior_product, x is of size (nsamples, npriors) + # Fast* is not vectorized? + # so just do a loop here + _zreds = x[...,0] + + all_p = [] + for i in range(len(_zreds)): + new_x = x[i] + p = np.zeros_like(new_x) + p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) + logsfr_ratios = expe_logsfr_ratios(this_z=new_x[0], this_m=new_x[1], nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + p[3:] = t.pdf(new_x[3:], df=2, loc=logsfr_ratios, scale=self.params['logsfr_ratio_tscale']) + + all_p.append(p) + + all_p = np.array(all_p) + + with np.errstate(invalid='ignore', divide='ignore'): + lnp = np.log(all_p) + + met_dists = [priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(mass_i), sig=scale_massmet(mass_i)) for mass_i in x[...,1]] + lnp[...,0] = np.zeros(len(lnp[...,0])) + lnp[...,2] = [met_dists[i](met_i) for (i, met_i) in enumerate(x[...,2])] + + return lnp + + def sample(self, nsample=None, **kwargs): + """Draw a sample from the prior distribution. + Needed for minimizer. + + :param nsample: (optional) + Unused. Will not work if nsample > 1 in draw_sample()! + """ + if len(kwargs) > 0: + self.update(**kwargs) + # draw a zred from pdf(z) + zred = self.zred * 1 + + # draw from the mass function at the above zred + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) + mass = draw_sample(xs=self.mgrid, cdf=cdf_mass) + + # given mass from above, draw logzsol + met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], + mu=loc_massmet(mass), sig=scale_massmet(mass)) + met = met_dist.sample() + + # sfh = sfrd + logsfr_ratios = expe_logsfr_ratios(this_z=zred, this_m=mass, nbins_sfh=self.params['nbins_sfh'], + logsfr_ratio_mini=self.params['logsfr_ratio_mini'], + logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) + logsfr_ratios_rvs = t.rvs(df=2, loc=logsfr_ratios, scale=self.params['logsfr_ratio_tscale']) + logsfr_ratios_rvs = np.clip(logsfr_ratios_rvs, a_min=self.params['logsfr_ratio_mini'], a_max=self.params['logsfr_ratio_maxi']) + + return np.concatenate([np.atleast_1d(zred), np.atleast_1d(mass), + np.atleast_1d(met), np.atleast_1d(logsfr_ratios_rvs)]) + + def unit_transform(self, x, **kwargs): + """Go from a value of the CDF (between 0 and 1) to the corresponding + parameter value. + Needed for nested sampling. + + :param x: + A scalar or vector of same length as the Prior with values between + zero and one corresponding to the value of the CDF. + + :returns theta: + The parameter value corresponding to the value of the CDF given by + `x`. + """ + if len(kwargs) > 0: + self.update(**kwargs) + + zred = self.zred * 1 + + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = ppf(x[1], self.mgrid, cdf=cdf_mass) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], @@ -762,7 +1138,7 @@ class NzSFH(priors.Prior): 'const_phi'] # mass is in log10 def __init__(self, parnames=[], name='', **kwargs): - """Overwrites __init__ in the base code Prior + """Overwrites __init__ in the base code priors.Prior Parameters ---------- @@ -789,6 +1165,7 @@ def __init__(self, parnames=[], name='', **kwargs): zreds, pdf_zred = np.loadtxt(file_pdf_of_z_l20t18, unpack=True) self.finterp_z_pdf, self.finterp_cdf_z = norm_pz(self.params['zred_mini'], self.params['zred_maxi'], zreds, pdf_zred) + self.mgrid = np.linspace(self.params['mass_mini'], self.params['mass_maxi'], 101) self.zred_dist = priors.FastUniform(a=self.params['zred_mini'], b=self.params['zred_maxi']) self.logsfr_ratios_dist = priors.FastTruncatedEvenStudentTFreeDeg2(hw=self.params['logsfr_ratio_maxi'], sig=self.params['logsfr_ratio_tscale']) @@ -834,7 +1211,7 @@ def __call__(self, x, **kwargs): # p(z) p[0] = self.finterp_z_pdf(x[0]) # p(m) - p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi']) + p[1] = mass_func_at_z(x[0], x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) # p(zsol) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], mu=loc_massmet(x[1]), sig=scale_massmet(x[1])) @@ -847,7 +1224,7 @@ def __call__(self, x, **kwargs): with np.errstate(invalid='ignore', divide='ignore'): lnp = np.log(p) - lnp[2] = met_dist(x[2]) # FastTruncatedNormal returns ln(p) + lnp[2] = met_dist(x[2]) # priors.FastTruncatedNormal returns ln(p) return lnp @@ -863,7 +1240,7 @@ def __call__(self, x, **kwargs): new_x = x[i] p = np.zeros_like(new_x) p[0] = self.finterp_z_pdf(new_x[0]) - p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi']) + p[1] = mass_func_at_z(new_x[0], new_x[1], self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) logsfr_ratios = expe_logsfr_ratios(this_z=new_x[0], this_m=new_x[1], nbins_sfh=self.params['nbins_sfh'], logsfr_ratio_mini=self.params['logsfr_ratio_mini'], logsfr_ratio_maxi=self.params['logsfr_ratio_maxi']) @@ -896,7 +1273,7 @@ def sample(self, nsample=None, **kwargs): zred = self.finterp_cdf_z(u) # draw from the mass function at the above zred - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = draw_sample(xs=self.mgrid, cdf=cdf_mass) # given mass from above, draw logzsol @@ -932,7 +1309,7 @@ def unit_transform(self, x, **kwargs): zred = self.finterp_cdf_z(x[0]) - cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi']) + cdf_mass = cdf_mass_func_at_z(z=zred, logm=self.mgrid, const_phi=self.params['const_phi'], bounds=[self.params['mass_mini'], self.params['mass_maxi']]) mass = ppf(x[1], self.mgrid, cdf=cdf_mass) met_dist = priors.FastTruncatedNormal(a=self.params['z_mini'], b=self.params['z_maxi'], @@ -1101,23 +1478,27 @@ def high_z_mass_func(z0, this_m): phi = phi0*w[0] + phi1*w[1] return phi -def mass_func_at_z(z, this_logm, const_phi=False): +def mass_func_at_z(z, this_logm, const_phi=False, bounds=[6.0, 12.5]): ''' - if const_phi == True: use mass funtions in Leja+20 only; + if const_phi == True: use mass functions in Leja+20 only; no redshfit evolution outside the range 0.2 <= z <= 3.0; i.e., - z<=0.2: use mass funtion at z=0.2; + z<=0.2: use mass function at z=0.2; 0.2<=z<=3.0: defined in Leja+20; - z>=3: use mass funtion at z=3.0. + z>=3: use mass function at z=3.0. if const_phi == False: combine Leja+20 and Tacchella+18 mass functions; i.e., z<=3: mass function in Leja+20; continuous in redshift. 312: T18 mass function at z=12. ''' + if not hasattr(this_logm, "__len__"): + if this_logm < bounds[0] or this_logm > bounds[1]: + return np.zeros_like(this_logm) + if const_phi: phi = low_z_mass_func(z, this_logm) else: @@ -1133,28 +1514,32 @@ def mass_func_at_z(z, this_logm, const_phi=False): else: phi = high_z_mass_func(z0=12, this_m=10**this_logm) + if hasattr(this_logm, "__len__"): + phi[this_logm < bounds[0]] = 0 + phi[this_logm > bounds[1]] = 0 return np.squeeze(phi) ############ Empirical PDF & CDF ############ -def pdf_mass_func_at_z(z, logm, const_phi): - phi_50 = mass_func_at_z(z, logm, const_phi) +def pdf_mass_func_at_z(z, logm, const_phi, bounds): + phi_50 = mass_func_at_z(z, logm, const_phi, bounds) p_phi_int = np.trapz(phi_50, logm) pdf_at_m = phi_50/p_phi_int return pdf_at_m -def cdf_mass_func_at_z(z, logm, const_phi): +def cdf_mass_func_at_z(z, logm, const_phi, bounds): ''' logm: an array of [mass_mini, ..., mass_maxi], or a float ''' - pdf_at_m = pdf_mass_func_at_z(z, logm=logm, const_phi=const_phi) + pdf_at_m = pdf_mass_func_at_z(z, logm=logm, const_phi=const_phi, bounds=bounds) cdf_of_m = np.cumsum(pdf_at_m) cdf_of_m /= max(cdf_of_m) - # may have small numerical errors; force cdf to start at 0 + # may have small numerical errors; force cdf to be within [0, 1] clean = np.where(cdf_of_m < 0) cdf_of_m[clean] = 0 cdf_of_m[0] = 0 + cdf_of_m[-1] = 1 return cdf_of_m @@ -1162,7 +1547,7 @@ def ppf(x, xs, cdf): '''Go from a value x of the CDF (between 0 and 1) to the corresponding parameter value. ''' - func_interp = interp1d(cdf, xs, bounds_error=False, fill_value="extrapolate") + func_interp = interp1d(cdf, xs, bounds_error=False, fill_value=0) param = func_interp(x) return param @@ -1170,7 +1555,7 @@ def draw_sample(xs, cdf, nsample=None): '''Draw sample(s) from any cdf ''' u = np.random.uniform(0, 1, size=nsample) - func_interp = interp1d(cdf, xs, bounds_error=False, fill_value="extrapolate") + func_interp = interp1d(cdf, xs, bounds_error=False, fill_value=0) sample = func_interp(u) return sample @@ -1183,13 +1568,14 @@ def norm_pz(zred_mini, zred_maxi, zreds, pdf_zred): pdf_zred_inrange = pdf_zred[idx_zrange]/p_int invalid = np.where(pdf_zred_inrange<0) pdf_zred_inrange[invalid] = 0 - finterp_z_pdf = interp1d(zreds_inrange, pdf_zred_inrange, bounds_error=False, fill_value="extrapolate") + finterp_z_pdf = interp1d(zreds_inrange, pdf_zred_inrange, bounds_error=False, fill_value=0) cdf_zred = np.cumsum(pdf_zred_inrange) cdf_zred /= max(cdf_zred) invalid = np.where(cdf_zred<0) cdf_zred[invalid] = 0 - finterp_cdf_z = interp1d(cdf_zred, zreds_inrange, bounds_error=False, fill_value="extrapolate") + cdf_zred[-1] = 1 + finterp_cdf_z = interp1d(cdf_zred, zreds_inrange, bounds_error=False, fill_value=0) return (finterp_z_pdf, finterp_cdf_z) @@ -1232,6 +1618,7 @@ def z_to_agebins_rescale(zstart, nbins_sfh=7, amin=7.1295): agebins = np.array([agelims[:-1], agelims[1:]]).T return 10**agebins +### functions needed for the SFH(M,z) prior def slope(x, y): return (y[1]-y[0])/(x[1]-x[0]) @@ -1260,7 +1647,6 @@ def expe_logsfr_ratios(this_z, this_m, logsfr_ratio_mini, logsfr_ratio_maxi, age_shifted = np.log10(cosmo.age(this_z).value) + delta_t_dex(this_m) age_shifted = 10**age_shifted - # introduce these limits for stability zmin_thres = 0.15 zmax_thres = 10 if age_shifted < age[-1]: @@ -1275,6 +1661,7 @@ def expe_logsfr_ratios(this_z, this_m, logsfr_ratio_mini, logsfr_ratio_maxi, z_shifted = zmin_thres * 1 agebins_shifted = z_to_agebins_rescale(zstart=z_shifted, nbins_sfh=nbins_sfh, amin=amin) + nsfrbins = agebins_shifted.shape[0] sfr_shifted = np.zeros(nsfrbins) for i in range(nsfrbins): diff --git a/prospect/models/sedmodel.py b/prospect/models/sedmodel.py index 8f15d9c2..d684eea5 100644 --- a/prospect/models/sedmodel.py +++ b/prospect/models/sedmodel.py @@ -10,19 +10,25 @@ from numpy.polynomial.chebyshev import chebval, chebvander from scipy.interpolate import splrep, BSpline -from scipy.stats import multivariate_normal as mvn +from scipy.signal import medfilt from sedpy.observate import getSED +from sedpy.smoothing import smoothspec from .parameters import ProspectorParams +from .hyperparameters import ProspectorHyperParams from ..sources.constants import to_cgs_at_10pc as to_cgs from ..sources.constants import cosmo, lightspeed, ckms, jansky_cgs -from ..utils.smoothing import smoothspec +try: + from ..sources.fake_fsps import frac_line_err # a very rough estimate of the emission line emulator error +except: + pass -__all__ = ["SpecModel", "PolySpecModel", "SplineSpecModel", - "LineSpecModel", "AGNSpecModel", - "SedModel", "PolySedModel", "PolyFitModel"] + +__all__ = ["SpecModel", + "HyperSpecModel", + "AGNSpecModel"] class SpecModel(ProspectorParams): @@ -39,7 +45,9 @@ class SpecModel(ProspectorParams): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.init_eline_info() + self.parse_elines() def _available_parameters(self): new_pars = [("sigma_smooth", ""), @@ -50,70 +58,178 @@ def _available_parameters(self): ("eline_delta_zred", ""), ("eline_sigma", ""), ("use_eline_priors", ""), - ("eline_prior_width", "")] - relevant_pars = [("mass", ""), - ("lumdist", ""), - ("zred", ""), - ("nebemlineinspec", ""), - ("add_neb_emission")] + ("eline_prior_width", ""), + ("use_eline_nn_unc", ""), + ("dla_logNh", "log_10 HI column density for damped Lyman-alpha absorption"), + ("dla_redshift", "redshift of the DLA; if greater than zred then no absorption occurs"), + ("igm_damping", "boolean switch to turn on IGM damping wing redward of 1216 rest")] + + referenced_pars = [("mass", ""), + ("lumdist", ""), + ("zred", ""), + ("nebemlineinspec", ""), + ("add_neb_emission")] return new_pars - def predict(self, theta, obs=None, sps=None, sigma_spec=None, **extras): + def predict(self, theta, observations=None, sps=None, **extras): """Given a ``theta`` vector, generate a spectrum, photometry, and any extras (e.g. stellar mass), including any calibration effects. - :param theta: - ndarray of parameter values, of shape ``(ndim,)`` + Parameters + ---------- + theta : ndarray of shape ``(ndim,)`` + Vector of free model parameter values. - :param obs: - An observation dictionary, containing the output wavelength array, - the photometric filter lists, and the observed fluxes and - uncertainties thereon. Assumed to be the result of - :py:func:`utils.obsutils.rectify_obs` + observations : A list of `Observation` instances (e.g. instance of ) + The data to predict - :param sps: + sps : An `sps` object to be used in the model generation. It must have the :py:func:`get_galaxy_spectrum` method defined. - :param sigma_spec: (optional) - The covariance matrix for the spectral noise. It is only used for - emission line marginalization. + Returns + ------- + predictions: (list of ndarrays) + List of predictions for the given list of observations. - :returns spec: - The model spectrum for these parameters, at the wavelengths - specified by ``obs['wavelength']``, including multiplication by the - calibration vector. Units of maggies + If the observation kind is "spectrum" then this is the model spectrum for these + parameters, at the wavelengths specified by ``obs['wavelength']``, + including multiplication by the calibration vector. Units of + maggies - :returns phot: - The model photometry for these parameters, for the filters - specified in ``obs['filters']``. Units of maggies. + If the observation kind is "photometry" then this is the model + photometry for these parameters, for the filters specified in + ``obs['filters']``. Units of maggies. - :returns extras: + extras : Any extra aspects of the model that are returned. Typically this will be `mfrac` the ratio of the surviving stellar mass to the stellar mass formed. """ - # generate and cache model spectrum and info + self.predict_init(theta, sps) + + # generate predictions for likelihood + # this assumes all spectral datasets (if present) occur first + # because they can change the line strengths during marginalization. + predictions = [self.predict_obs(obs) for obs in observations] + + return predictions, self._mfrac + + def predict_init(self, theta, sps): + """Generate the physical model on the model wavelength grid, and cache + many quantities used in common for all kinds of predictions. + + Parameters + ---------- + theta : ndarray of shape ``(ndim,)`` + Vector of free model parameter values. + + sps : + An `sps` object to be used in the model generation. It must have + the :py:func:`get_galaxy_spectrum` method defined. + """ + # generate and cache intrinsic model spectrum and info self.set_parameters(theta) self._wave, self._spec, self._mfrac = sps.get_galaxy_spectrum(**self.params) self._zred = self.params.get('zred', 0) self._eline_wave, self._eline_lum = sps.get_galaxy_elines() + self._library_resolution = getattr(sps, "spectral_resolution", 0.0) # restframe # Flux normalize self._norm_spec = self._spec * self.flux_norm() - # generate spectrum and photometry for likelihood - # predict_spec should be called before predict_phot - # because in principle it can modify the emission line parameters - # and also needs some things done in 'cache_eline_parameters` - # especially _ewave_obs and _use_elines - spec = self.predict_spec(obs, sigma_spec=sigma_spec) - phot = self.predict_phot(obs.get('filters', None)) + # cache eline observed wavelengths + eline_z = self.params.get("eline_delta_zred", 0.0) + self._ewave_obs = (1 + eline_z + self._zred) * self._eline_wave - return spec, phot, self._mfrac + # cache eline mle info + self._ln_eline_penalty = 0 + self._eline_lum_mle = self._eline_lum.copy() + if self.params.get('use_eline_nn_unc', False): + self._eline_lum_covar = np.diag((self.params.get('eline_prior_width', 0.0) * + self._eline_lum)**2) + (frac_line_err * + self._eline_lum)**2 + else: + self._eline_lum_covar = np.diag((self.params.get('eline_prior_width', 0.0) * + self._eline_lum)**2) + + # physical velocity smoothing of the whole UV/NIR spectrum + self._smooth_spec = self.losvd_smoothing(self._wave, self._norm_spec) + + # Ly-alpha absorption + self._smooth_spec = self.add_dla(self._wave, self._smooth_spec) + self._smooth_spec = self.add_damping_wing(self._wave, self._smooth_spec) + + + def predict_obs(self, obs, sigma_spec=None): + if obs.kind == "spectrum": + prediction = self.predict_spec(obs) + elif obs.kind == "lines": + prediction = self.predict_lines(obs) + elif obs.kind == "photometry": + prediction = self.predict_phot(obs.filterset) + elif obs.kind == "intrinsic": + prediction = self.predict_intrinsic(obs) + else: + prediction = None + return prediction - def predict_spec(self, obs, sigma_spec=None, **extras): + def predict_intrinsic(self, obs_dummy, continuum_only=True, **extras): + """Generate a prediction for the observed spectrum. This method assumes + that the parameters have been set and predict_spec has previously been called. + + Parameters + ---------- + obs : Instance of :py:class:`observation.Spectrum` + Must contain the output wavelength array, the observed fluxes and + uncertainties thereon. Assumed to be the result of + :py:meth:`utils.obsutils.rectify_obs` + + Returns + ------- + spec : ndarray of shape ``(nwave,)`` + The prediction for the observed frame spectral flux these + parameters, at the wavelengths specified by ``obs['wavelength']``, + including multiplication by the calibration vector. in units of + maggies. + """ + obs = obs_dummy + + # redshift model wavelength + obs_wave = self.observed_wave(self._wave, do_wavecal=False) + + # get output wavelength vector + self._outwave = obs.wavelength + if self._outwave is None: + self._outwave = obs_wave + + # Set up for emission lines + self.cache_eline_parameters(obs) + + # --- smooth and put on output wavelength grid --- + # Instrumental smoothing (accounting for library resolution) + # Put onto the spec.wavelength grid. + inst_spec = obs.instrumental_smoothing(obs_wave, self._smooth_spec, + libres=self._library_resolution) + + # --- add fixed lines if necessary --- + emask = self._fix_eline_pixelmask + if emask.any() & (~continuum_only): + inds = self._fix_eline & self._valid_eline + espec = self.predict_eline_spec(line_indices=inds, + wave=self._outwave[emask]) + self._fix_eline_spec = espec + inst_spec[emask] += self._fix_eline_spec.sum(axis=1) + + # --- add (previously) fitted lines if necessary --- + emask = self._fit_eline_pixelmask + if emask.any() (~continuum_only): + inst_spec[emask] += self._fit_eline_spec.sum(axis=1) + + return inst_spec + + def predict_spec(self, obs): """Generate a prediction for the observed spectrum. This method assumes that the parameters have been set and that the following attributes are present and correct @@ -125,7 +241,7 @@ def predict_spec(self, obs, sigma_spec=None, **extras): It generates the following attributes - + ``_outwave`` - Wavelength grid (observed frame) + + ``_outwave`` - Wavelength grid (instrument frame) + ``_speccal`` - Calibration vector + ``_sed`` - Intrinsic spectrum (before cilbration vector applied but including emission lines) @@ -136,35 +252,53 @@ def predict_spec(self, obs, sigma_spec=None, **extras): spectroscopic calibration factor included. Numerous quantities related to the emission lines are also cached (see - ``cache_eline_parameters()`` and ``fit_el()`` for details.) + ``cache_eline_parameters()`` and ``fit_mle_elines()`` for details.) - :param obs: - An observation dictionary, containing the output wavelength array, - the photometric filter lists, and the observed fluxes and + Parameters + ---------- + obs : Instance of :py:class:`observation.Spectrum` + Must contain the output wavelength array, the observed fluxes and uncertainties thereon. Assumed to be the result of :py:meth:`utils.obsutils.rectify_obs` - :param sigma_spec: (optional) + sigma_spec : (optional) The covariance matrix for the spectral noise. It is only used for emission line marginalization. - :returns spec: + Returns + ------- + spec : ndarray of shape ``(nwave,)`` The prediction for the observed frame spectral flux these parameters, at the wavelengths specified by ``obs['wavelength']``, - including multiplication by the calibration vector. - ndarray of shape ``(nwave,)`` in units of maggies. + including multiplication by the calibration vector. in units of + maggies. """ - # redshift wavelength + # redshift model wavelength obs_wave = self.observed_wave(self._wave, do_wavecal=False) - self._outwave = obs.get('wavelength', obs_wave) + + # get output wavelength vector + # TODO: remove this and require all Spectrum instances to have a wavelength array + self._outwave = obs.wavelength if self._outwave is None: self._outwave = obs_wave - # --- cache eline parameters --- + # Set up for emission lines self.cache_eline_parameters(obs) # --- smooth and put on output wavelength grid --- - smooth_spec = self.smoothspec(obs_wave, self._norm_spec) + # Instrumental smoothing (accounting for library resolution) + # Put onto the spec.wavelength grid. + + # HACK to change the spectral resolution on the fly + if hasattr(obs, "resolution_jitter_parameter"): + parn = getattr(obs, "resolution_jitter_parameter") + res_jitter = self.params.get(parn) + obs.padded_resolution = np.interp(obs.padded_wavelength, + obs.wavelength, + obs.resolution * res_jitter) + + inst_spec = obs.instrumental_smoothing(obs_wave, self._smooth_spec, + libres=self._library_resolution) # --- add fixed lines if necessary --- emask = self._fix_eline_pixelmask @@ -173,24 +307,91 @@ def predict_spec(self, obs, sigma_spec=None, **extras): espec = self.predict_eline_spec(line_indices=inds, wave=self._outwave[emask]) self._fix_eline_spec = espec - smooth_spec[emask] += self._fix_eline_spec.sum(axis=1) + inst_spec[emask] += self._fix_eline_spec.sum(axis=1) - # --- calibration --- - self._speccal = self.spec_calibration(obs=obs, spec=smooth_spec, **extras) - calibrated_spec = smooth_spec * self._speccal + # --- (de-) apply calibration --- + extra_mask = self._fit_eline_pixelmask + if not extra_mask.any(): + extra_mask = True # all pixels are ok + response = obs.compute_response(spec=inst_spec, + extra_mask=extra_mask, + **self.params) + inst_spec = inst_spec * response # --- fit and add lines if necessary --- emask = self._fit_eline_pixelmask if emask.any(): - self._fit_eline_spec = self.fit_el(obs, calibrated_spec, sigma_spec) - calibrated_spec[emask] += self._fit_eline_spec.sum(axis=1) + # We need the spectroscopic covariance matrix to do emission line + # optimization and marginalization + spec_unc = None + # FIXME: do this only if the noise model is non-trivial, and make sure masking is consistent + #vectors = obs.noise.populate_vectors(obs) + #spec_unc = obs.noise.construct_covariance(**vectors) + self._fit_eline_spec = self.fit_mle_elines(obs, inst_spec, spec_unc) + inst_spec[emask] += self._fit_eline_spec.sum(axis=1) - # --- cache intrinsic spectrum --- - self._sed = calibrated_spec / self._speccal + # --- cache intrinsic spectrum for this observation --- + self._sed = inst_spec / response + self._speccal = response - return calibrated_spec + return inst_spec - def predict_phot(self, filters): + + def predict_lines(self, obs, **extras): + """Generate a prediction for the observed nebular line fluxes. This method assumes + that the model parameters have been set, that any adjustments to the + emission line fluxes based on ML fitting have been applied, and that the + following attributes are present and correct + + ``_wave`` - The SPS restframe wavelength array + + ``_zred`` - Redshift + + ``_eline_wave`` and ``_eline_lum`` - emission line parameters from the SPS model + It generates the following attributes + + ``_outwave`` - Wavelength grid (observed frame) + + ``_speccal`` - Calibration vector + + ``line_norm`` - the conversion from FSPS line luminosities to the + observed line luminosities, including scaling fudge_factor + + ``_predicted_line_inds`` - the indices of the line that are predicted + + Numerous quantities related to the emission lines are also cached (see + ``cache_eline_parameters()`` and ``fit_mle_elines()`` for details) including + ``_predicted_line_inds`` which is the indices of the line that are predicted. + ``cache_eline_parameters()`` and ``fit_elines()`` for details). + + Parameters + ---------- + obs : Instance of :py:class:``observation.Lines`` + Must have the attributes: + + ``"wavelength"`` - the observed frame wavelength of the lines. + + ``"line_ind"`` - a set of indices identifying the observed lines in + the fsps line array + + Returns + ------- + elum : ndarray of shape ``(nwave,)`` + The prediction for the observed frame nebular emission line flux + these parameters, at the wavelengths specified by + ``obs['wavelength']``, in units of erg/s/cm^2. + """ + obs_wave = self.observed_wave(self._eline_wave, do_wavecal=False) + self._outwave = obs.get('wavelength', obs_wave) + assert len(self._outwave) <= len(self.emline_info) + + # --- cache eline parameters --- + self.cache_eline_parameters(obs) + + # find the indices of the observed emission lines + #dw = np.abs(self._ewave_obs[:, None] - self._outwave[None, :]) + #self._predicted_line_inds = np.argmin(dw, axis=0) + self._predicted_line_inds = obs["line_inds"] + self._speccal = 1.0 + + self.line_norm = self.flux_norm() / (1 + self._zred) * (3631*jansky_cgs) + self.line_norm *= self.params.get("linespec_scaling", 1.0) + elums = self._eline_lum[self._predicted_line_inds] * self.line_norm + + return elums + + def predict_phot(self, filterset): """Generate a prediction for the observed photometry. This method assumes that the parameters have been set and that the following attributes are present and correct: @@ -200,29 +401,29 @@ def predict_phot(self, filters): + ``_ewave_obs`` and ``_eline_lum`` - emission line parameters from the SPS model - :param filters: - Instance of :py:class:`sedpy.observate.FilterSet` or list of + Parameters + ---------- + filters : Instance of :py:class:`sedpy.observate.FilterSet` or list of :py:class:`sedpy.observate.Filter` objects. If there is no photometry, ``None`` should be supplied. - :returns phot: - Observed frame photometry of the model SED through the given filters. - ndarray of shape ``(len(filters),)``, in units of maggies. - If ``filters`` is None, this returns 0.0 + Returns + ------- + phot : ndarray of shape ``(len(filters),)`` + Observed frame photometry of the model SED through the given filters, + in units of maggies. If ``filters`` is None, this returns 0.0 """ - if filters is None: + if filterset is None: return 0.0 # generate photometry w/o emission lines obs_wave = self.observed_wave(self._wave, do_wavecal=False) - flambda = self._norm_spec * lightspeed / obs_wave**2 * (3631*jansky_cgs) - phot = 10**(-0.4 * np.atleast_1d(getSED(obs_wave, flambda, filters))) - # TODO: below is faster for sedpy > 0.2.0 - #phot = np.atleast_1d(getSED(obs_wave, flambda, filters, linear_flux=True)) + flambda = self._smooth_spec * lightspeed / obs_wave**2 * (3631*jansky_cgs) + phot = np.atleast_1d(getSED(obs_wave, flambda, filterset, linear_flux=True)) # generate emission-line photometry if (self._want_lines & self._need_lines): - phot += self.nebline_photometry(filters) + phot += self.nebline_photometry(filterset) return phot @@ -230,7 +431,9 @@ def flux_norm(self): """Compute the scaling required to go from Lsun/Hz/Msun to maggies. Note this includes the (1+z) factor required for flux densities. - :returns norm: (float) + Returns + ------- + norm : (float) The normalization factor, scalar float. """ # distance factor @@ -250,25 +453,31 @@ def init_eline_info(self, eline_file='emlines_info.dat'): # get the emission line info try: - SPS_HOME = os.getenv('SPS_HOME') - info = np.genfromtxt(os.path.join(SPS_HOME, 'data', eline_file), - dtype=[('wave', 'f8'), ('name', ' 0): + # Incorporate gaussian "priors" on the amplitudes + # these can come from cloudy model & uncertainty, or fit from previous dataset + + # first account for calibration vector + sigma_alpha_breve = sigma_alpha_breve * linecal[:, None] * linecal[None, :] + + # combine covar matrices M = np.linalg.pinv(sigma_alpha_hat + sigma_alpha_breve) alpha_bar = (np.dot(sigma_alpha_breve, np.dot(M, alpha_hat)) + np.dot(sigma_alpha_hat, np.dot(M, alpha_breve))) @@ -495,14 +718,16 @@ def fit_el(self, obs, calibrated_spec, sigma_spec=None): sigma_alpha_bar = sigma_alpha_hat K = ln_mvn(alpha_hat, mean=alpha_hat, cov=sigma_alpha_hat) - # Cache the ln-penalty - self._ln_eline_penalty = K + # Cache the ln-penalty, accumulating (in case there are multiple spectra) + self._ln_eline_penalty += K - # Store fitted emission line luminosities in physical units + # Store fitted emission line luminosities in physical units, including prior self._eline_lum[idx] = alpha_bar / linecal - self._eline_lum_var[idx] = np.diag(sigma_alpha_bar) / linecal**2 + # store new Gaussian uncertainties in physical units + self._eline_lum_var[np.ix_(idx, idx)] = sigma_alpha_bar / linecal[:, None] / linecal[None, :] - # return the maximum-likelihood line spectrum in observed units + # return the maximum-likelihood line spectrum for this observation in observed units + self._eline_lum_mle[idx] = alpha_hat / linecal return alpha_hat * eline_gaussians def predict_eline_spec(self, line_indices=slice(None), wave=None): @@ -563,17 +788,43 @@ def get_eline_gaussians(self, lineidx=slice(None), wave=None): return eline_gaussians - def smoothspec(self, wave, spec): - """Smooth the spectrum. See :py:func:`prospect.utils.smoothing.smoothspec` - for details. + def losvd_smoothing(self, wave, spec): + """Smooth the spectrum in velocity space. + See :py:func:`prospect.utils.smoothing.smoothspec` for details. """ - sigma = self.params.get("sigma_smooth", 100) - outspec = smoothspec(wave, spec, sigma, outwave=self._outwave, **self.params) + sigma = self.params.get("sigma_smooth", 300) + sel = (wave > 0.912e3) & (wave < 2.5e4) + # TODO: make a fast version of this that is also accurate + sm = smoothspec(wave, spec, sigma, outwave=wave[sel], + smoothtype="vel", fftsmooth=True) + outspec = spec.copy() + outspec[sel] = sm return outspec + def add_dla(self, wave_rest, spec): + logN = self.params.get("dla_logNh", None) + if logN is None: + return spec + # Shift spectrum to the restframe of the DLA + dla_z = self.params.get("dla_redshift", self._zred) + if dla_z > self._zred: + return spec + wave_rest = wave_rest * (1 + dla_z) / (1 + self._zred) + tau = voigt_profile(wave_rest, 10**logN) + spec *= np.exp(-tau) + return spec + + def add_damping_wing(self, wave_rest, spec): + zmin = 5.0 + if (self._zred > zmin) & np.any(self.params.get("igm_damping", False)): + x_HI = self.params.get("igm_factor", 1.0) + tau = tau_damping(wave_rest, self._zred, x_HI, zmin=zmin, cosmo=cosmo) + spec *= np.exp(-tau) + return spec + def observed_wave(self, wave, do_wavecal=False): - """Convert the restframe wavelngth grid to the observed frame wavelength + """Convert the restframe wavelength grid to the observed frame wavelength grid, optionally including wavelength calibration adjustments. Requires that the ``_zred`` attribute is already set. @@ -594,10 +845,7 @@ def wave_to_x(self, wavelength=None, mask=slice(None), **extras): x = 2.0 * (x / (x[mask]).max()) - 1.0 return x - def spec_calibration(self, **kwargs): - return np.ones_like(self._outwave) - - def absolute_rest_maggies(self, filters): + def absolute_rest_maggies(self, filterset): """Return absolute rest-frame maggies (=10**(-0.4*M)) of the last computed spectrum. @@ -619,276 +867,24 @@ def absolute_rest_maggies(self, filters): fmaggies = self._norm_spec / (1 + self._zred) * (ld / 10)**2 # convert to erg/s/cm^2/AA for sedpy and get absolute magnitudes flambda = fmaggies * lightspeed / self._wave**2 * (3631*jansky_cgs) - abs_rest_maggies = 10**(-0.4 * np.atleast_1d(getSED(self._wave, flambda, filters))) - # TODO: below is faster for sedpy > 0.2.0 - #abs_rest_maggies = np.atleast_1d(getSED(self._wave, flambda, filters, linear_flux=True)) + abs_rest_maggies = np.atleast_1d(getSED(self._wave, flambda, filterset, linear_flux=True)) # add emission lines - if bool(self.params.get('nebemlineinspec', False)) is False: + if (self._want_lines & self._need_lines): eline_z = self.params.get("eline_delta_zred", 0.0) elams = (1 + eline_z) * self._eline_wave elums = self._eline_lum * self.flux_norm() / (1 + self._zred) * (3631*jansky_cgs) * (ld / 10)**2 - emaggies = self.nebline_photometry(filters, elams=elams, elums=elums) + emaggies = self.nebline_photometry(filterset, elams=elams, elums=elums) abs_rest_maggies += emaggies return abs_rest_maggies - def mean_model(self, theta, obs, sps=None, sigma=None, **extras): - """Legacy wrapper around predict() - """ - return self.predict(theta, obs, sps=sps, sigma_spec=sigma, **extras) - - -class PolySpecModel(SpecModel): - - """This is a subclass of *SpecModel* that generates the multiplicative - calibration vector at each model `predict` call as the maximum likelihood - chebyshev polynomial describing the ratio between the observed and the model - spectrum. - """ - - def _available_parameters(self): - pars = [("polyorder", "order of the polynomial to fit"), - ("poly_regularization", "vector of length `polyorder` providing regularization for each polynomial term") - ] - - return pars - - def spec_calibration(self, theta=None, obs=None, spec=None, **kwargs): - """Implements a Chebyshev polynomial calibration model. This uses - least-squares to find the maximum-likelihood Chebyshev polynomial of a - certain order describing the ratio of the observed spectrum to the model - spectrum, conditional on all other parameters, using least squares. If - emission lines are being marginalized out, they are excluded from the - least-squares fit. - - :returns cal: - A polynomial given by :math:`\sum_{m=0}^M a_{m} * T_m(x)`. - """ - if theta is not None: - self.set_parameters(theta) - - # norm = self.params.get('spec_norm', 1.0) - polyopt = ((self.params.get('polyorder', 0) > 0) & - (obs.get('spectrum', None) is not None)) - if polyopt: - order = self.params['polyorder'] - - # generate mask - # remove region around emission lines if doing analytical marginalization - mask = obs.get('mask', np.ones_like(obs['wavelength'], dtype=bool)).copy() - if self.params.get('marginalize_elines', False): - mask[self._fit_eline_pixelmask] = 0 - - # map unmasked wavelengths to the interval -1, 1 - # masked wavelengths may have x>1, x<-1 - x = self.wave_to_x(obs["wavelength"], mask) - y = (obs['spectrum'] / spec)[mask] - 1.0 - yerr = (obs['unc'] / spec)[mask] - yvar = yerr**2 - A = chebvander(x[mask], order) - ATA = np.dot(A.T, A / yvar[:, None]) - reg = self.params.get('poly_regularization', 0.) - if np.any(reg > 0): - ATA += reg**2 * np.eye(order) - ATAinv = np.linalg.inv(ATA) - c = np.dot(ATAinv, np.dot(A.T, y / yvar)) - Afull = chebvander(x, order) - poly = np.dot(Afull, c) - self._poly_coeffs = c - else: - poly = np.zeros_like(self._outwave) - - return (1.0 + poly) - - -class SplineSpecModel(SpecModel): - - """This is a subclass of *SpecModel* that generates the multiplicative - calibration vector at each model `predict` call as the maximum likelihood - cubic spline describing the ratio between the observed and the model - spectrum. - """ - - def _available_parameters(self): - pars = [("spline_knot_wave", "vector of wavelengths for the location ot he spline knots"), - ("spline_knot_spacing", "spacing between knots, in units of wavelength"), - ("spline_knot_n", "number of interior knots between minimum and maximum unmasked wavelength") - ] - - return pars - - def spec_calibration(self, theta=None, obs=None, spec=None, **kwargs): - """Implements a spline calibration model. This fits a cubic spline with - determined knot locations to the ratio of the observed spectrum to the - model spectrum. If emission lines are being marginalized out, they are - excluded from the least-squares fit. - - The knot locations must be specified as model parameters, either - explicitly or via a number of knots or knot spacing (in angstroms) - """ - if theta is not None: - self.set_parameters(theta) - - splineopt = True - if splineopt: - mask, (wlo, whi) = self.obs_to_mask(obs) - y = (obs['spectrum'] / spec)[mask] - 1.0 - yerr = (obs['unc'] / spec)[mask] # HACK - knots_x = self.make_knots(wlo, whi, as_x=True, **self.params) - x = 2.0 * (obs["wavelength"] - wlo) / (whi - wlo) - 1.0 - tck = splrep(x[mask], y[mask], w=1/yerr[mask], k=3, task=-1, t=knots_x) - self._spline_coeffs = tck - spl = BSpline(*tck) - spline = spl(x) - else: - spline = np.zeros_like(self._outwave) - return (1.0 + spline) - - def make_knots(self, wlo, whi, as_x=True, **params): - """ - """ - if "spline_knot_wave" in params: - knots = np.squeeze(params["spline_knot_wave"]) - elif "spline_knot_spacing" in params: - s = np.squeeze(params["spline_knot_spacing"]) - # we drop the start so knots are interior - knots = np.arange(wlo, whi, s)[1:] - elif "spline_knot_n" in params: - n = np.squeeze(params["spline_knot_n"]) - # we need to drop the endpoints so knots are interior - knots = np.linspace(wlo, whi, n)[1:-1] - else: - raise KeyError("No valid spline knot specification in self.params") - - if as_x: - knots = 2.0 * (knots - wlo) / (whi - wlo) - 1.0 - - return knots - - def obs_to_mask(self, obs): - mask = obs.get('mask', np.ones_like(obs['wavelength'], dtype=bool)).copy() - if self.params.get('marginalize_elines', False): - mask[self._fit_eline_pixelmask] = 0 - w = obs["wavelength"] - wrange = w[mask].min(), w[mask].max() - return mask, wrange - - -class LineSpecModel(SpecModel): - - """This is a sublcass of SpecModel that predicts emission line fluxes - instead of a full spectrum, useful when the continuum is not detected or is - otherwise uninformative. - """ - - def _available_parameters(self): - pars = [("linespec_scaling", "This float scales the predicted nebular " - "emission line luminosities, for example to accxount for a " - "(constant in wavelengtrh) slit loss"), - ] - - return pars - - def predict_spec(self, obs, **extras): - """Generate a prediction for the observed nebular line fluxes. This method assumes - that the model parameters have been set and that the following - attributes are present and correct - + ``_wave`` - The SPS restframe wavelength array - + ``_zred`` - Redshift - + ``_norm_spec`` - Observed frame spectral fluxes, in units of maggies - + ``_eline_wave`` and ``_eline_lum`` - emission line parameters from the SPS model - It generates the following attributes - + ``_outwave`` - Wavelength grid (observed frame) - + ``_speccal`` - Calibration vector - - Numerous quantities related to the emission lines are also cached (see - ``cache_eline_parameters()`` and ``fit_el()`` for details) including - ``_predicted_line_inds`` which is the indices of the line that are predicted. - - :param obs: - An observation dictionary, containing the keys - + ``"wavelength"`` - the observed frame wavelength of the lines. - + ``"line_ind"`` - a set of indices identifying the observed lines in - the fsps line array - Assumed to be the result of :py:meth:`utils.obsutils.rectify_obs` - - :returns spec: - The prediction for the observed frame nebular emission line flux these - parameters, at the wavelengths specified by ``obs['wavelength']``, - ndarray of shape ``(nwave,)`` in units of erg/s/cm^2. - """ - obs_wave = self.observed_wave(self._eline_wave, do_wavecal=False) - self._outwave = obs.get('wavelength', obs_wave) - assert len(self._outwave) <= len(self.emline_info) - - # --- cache eline parameters --- - self.cache_eline_parameters(obs) - - # find the indices of the observed emission lines - #dw = np.abs(self._ewave_obs[:, None] - self._outwave[None, :]) - #self._predicted_line_inds = np.argmin(dw, axis=0) - self._predicted_line_inds = obs.get("line_ind") - self._speccal = 1.0 - - self.line_norm = self.flux_norm() / (1 + self._zred) * (3631*jansky_cgs) - self.line_norm *= self.params.get("linespec_scaling", 1.0) - elums = self._eline_lum[self._predicted_line_inds] * self.line_norm - - return elums - - def nebline_photometry(self, filters, elams=None, elums=None): - """Compute the emission line contribution to photometry. This requires - several cached attributes: - + ``_ewave_obs`` - + ``_eline_lum`` - - :param filters: - Instance of :py:class:`sedpy.observate.FilterSet` or list of - :py:class:`sedpy.observate.Filter` objects - - :param elams: (optional) - The emission line wavelength in angstroms. If not supplied uses the - cached ``_ewave_obs`` attribute. - - :param elums: (optional) - The emission line flux in erg/s/cm^2. If not supplied uses the - cached ``_eline_lum`` attribute and applies appropriate distance - dimming and unit conversion. - - :returns nebflux: - The flux of the emission line through the filters, in units of - maggies. ndarray of shape ``(len(filters),)`` - """ - if (elams is None) or (elums is None): - elams = self._ewave_obs[self._use_eline] - # We have to remove the extra (1+z) since this is flux, not a flux density - # Also we convert to cgs - self.line_norm = self.flux_norm() / (1 + self._zred) * (3631*jansky_cgs) - elums = self._eline_lum[self._use_eline] * self.line_norm - - # loop over filters - flux = np.zeros(len(filters)) - try: - # TODO: Since in this case filters are on a grid, there should be a - # faster way to look up the transmission than the later loop - flist = filters.filters - except(AttributeError): - flist = filters - for i, filt in enumerate(flist): - # calculate transmission at line wavelengths - trans = np.interp(elams, filt.wavelength, filt.transmission, - left=0., right=0.) - # include all lines where transmission is non-zero - idx = (trans > 0) - if True in idx: - flux[i] = (trans[idx]*elams[idx]*elums[idx]).sum() / filt.ab_zero_counts - - return flux - class AGNSpecModel(SpecModel): + # TODO: simplify this to use SpecModel methods + # main difference is nsigma based on agn_eline_sigma + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -907,18 +903,21 @@ def _available_parameters(self): def init_aline_info(self): """AGN line spectrum. Based on data as reported in Richardson et al. - 2014 (Table 3, the 'a42' dataset) and normalized to Hbeta.index=48 is Hbeta + 2014 (Table 3, the 'a42' dataset) and normalized to Hbeta. + + index=59 is Hbeta """ - ainds = np.array([31, 33, 34, 35, 37, 42, 43, 44, 48, - 49, 50, 52, 56, 57, 58, 60, 61, 62, - 63, 64, 65, 66, 68]) + ainds = np.array([38, 40, 41, 43, 45, 50, 51, 52, 59, + 61, 62, 64, 68, 69, 70, 72, 73, 74, + 75, 76, 77, 78, 80]) afluxes = np.array([2.96, 0.06, 0.1 , 1. , 0.2 , 0.25, 0.48, 0.13, 1., 2.87, 8.53, 0.07, 0.02, 0.1 , 0.33, 0.09, 0.79, 2.86, 2.13, 0.03, 0.77, 0.65, 0.19]) - self._aline_lum = np.zeros(128) + self._aline_lum = np.zeros(len(self.emline_info)) + assert np.abs(self.emline_info["wave"][59] - 4863) < 2 self._aline_lum[ainds] = afluxes - def predict_spec(self, obs, sigma_spec=None, **extras): + def predict_spec(self, obs): """Generate a prediction for the observed spectrum. This method assumes that the parameters have been set and that the following attributes are present and correct @@ -941,7 +940,7 @@ def predict_spec(self, obs, sigma_spec=None, **extras): spectroscopic calibration factor included. Numerous quantities related to the emission lines are also cached (see - ``cache_eline_parameters()`` and ``fit_el()`` for details.) + ``cache_eline_parameters()`` and ``fit_mle_elines()`` for details.) :param obs: An observation dictionary, containing the output wavelength array, @@ -970,11 +969,14 @@ def predict_spec(self, obs, sigma_spec=None, **extras): self.cache_eline_parameters(obs, nsigma=nsigma) # --- smooth and put on output wavelength grid --- - smooth_spec = self.smoothspec(obs_wave, self._norm_spec) + smooth_spec = self.losvd_smoothing(obs_wave, self._norm_spec) + smooth_spec = obs.instrumental_smoothing(obs_wave, smooth_spec, + libres=self._library_resolution) # --- add fixed lines --- - assert self.params["nebemlineinspec"] == False, "must add agn and nebular lines within prospector" - assert self.params.get("marginalize_elines", False) == False, "Cannot fit lines when AGN lines included" + assert self._need_lines, "must add agn and nebular lines within prospector" + assert not np.any(self.params.get("marginalize_elines", False)), "Cannot fit lines when AGN lines included" + emask = self._fix_eline_pixelmask if emask.any(): # Add SF lines @@ -991,13 +993,38 @@ def predict_spec(self, obs, sigma_spec=None, **extras): smooth_spec[emask] += self._agn_eline_spec # --- calibration --- - self._speccal = self.spec_calibration(obs=obs, spec=smooth_spec, **extras) - calibrated_spec = smooth_spec * self._speccal + response = obs.compute_response(spec=smooth_spec, **self.params) + inst_spec = smooth_spec * response # --- cache intrinsic spectrum --- - self._sed = calibrated_spec / self._speccal + self._sed = inst_spec / response + self._speccal = response + + return inst_spec + + def predict_lines(self, obs, **extras): + """Generate a prediction for the observed nebular line fluxes, including + AGN. + + :param obs: + A ``data.observation.Lines()`` instance, with the attributes + + ``"wavelength"`` - the observed frame wavelength of the lines. + + ``"line_ind"`` - a set of indices identifying the observed lines in + the fsps line array + + :returns elum: + The prediction for the observed frame nebular + AGN emission line + flux these parameters, at the wavelengths specified by + ``obs['wavelength']``, ndarray of shape ``(nwave,)`` in units of + erg/s/cm^2. + """ + sflums = super().predict_lines(obs, **extras) + anorm = self.params.get('agn_elum', 1.0) * self.line_norm + alums = self._aline_lum[self._predicted_line_inds] * anorm - return calibrated_spec + elums = sflums + alums + + return elums def predict_phot(self, filters): """Generate a prediction for the observed photometry. This method assumes @@ -1057,287 +1084,197 @@ def predict_aline_spec(self, line_indices, wave): return aline_spec -class SedModel(ProspectorParams): +class HyperSpecModel(ProspectorHyperParams, SpecModel): + pass - """A subclass of :py:class:`ProspectorParams` that passes the models - through to an ``sps`` object and returns spectra and photometry, including - optional spectroscopic calibration and sky emission. - """ - def predict(self, theta, obs=None, sps=None, **extras): - """Given a ``theta`` vector, generate a spectrum, photometry, and any - extras (e.g. stellar mass), including any calibration effects. +def ln_mvn(x, mean=None, cov=None): + """Calculates the natural logarithm of the multivariate normal PDF + evaluated at `x` - :param theta: - ndarray of parameter values, of shape ``(ndim,)`` + :param x: + locations where samples are desired. - :param obs: - An observation dictionary, containing the output wavelength array, - the photometric filter lists, and the observed fluxes and - uncertainties thereon. Assumed to be the result of - :py:func:`utils.obsutils.rectify_obs` + :param mean: + Center(s) of the gaussians. - :param sps: - An `sps` object to be used in the model generation. It must have - the :py:func:`get_spectrum` method defined. + :param cov: + Covariances of the gaussians. + """ + ndim = mean.shape[-1] + dev = x - mean + log_2pi = np.log(2 * np.pi) + sign, log_det = np.linalg.slogdet(cov) + exp = np.dot(dev.T, np.dot(np.linalg.pinv(cov, rcond=1e-12), dev)) - :param sigma_spec: (optional, unused) - The covariance matrix for the spectral noise. It is only used for - emission line marginalization. + return -0.5 * (ndim * log_2pi + log_det + exp) - :returns spec: - The model spectrum for these parameters, at the wavelengths - specified by ``obs['wavelength']``, including multiplication by the - calibration vector. Units of maggies - :returns phot: - The model photometry for these parameters, for the filters - specified in ``obs['filters']``. Units of maggies. +def gauss(x, mu, A, sigma): + """Sample multiple gaussians at positions x. - :returns extras: - Any extra aspects of the model that are returned. Typically this - will be `mfrac` the ratio of the surviving stellar mass to the - stellar mass formed. - """ - s, p, x = self.sed(theta, obs, sps=sps, **extras) - self._speccal = self.spec_calibration(obs=obs, **extras) - if obs.get('logify_spectrum', False): - s = np.log(s) + np.log(self._speccal) - else: - s *= self._speccal - return s, p, x + :param x: + locations where samples are desired. - def sed(self, theta, obs=None, sps=None, **kwargs): - """Given a vector of parameters ``theta``, generate a spectrum, photometry, - and any extras (e.g. surviving mass fraction), ***not** including any - instrument calibration effects. The intrinsic spectrum thus produced is - cached in `_spec` attribute + :param mu: + Center(s) of the gaussians. - :param theta: - ndarray of parameter values. + :param A: + Amplitude(s) of the gaussians, defined in terms of total area. - :param obs: - An observation dictionary, containing the output wavelength array, - the photometric filter lists, and the observed fluxes and - uncertainties thereon. Assumed to be the result of - :py:func:`utils.obsutils.rectify_obs` + :param sigma: + Dispersion(s) of the gaussians, un units of x. - :param sps: - An `sps` object to be used in the model generation. It must have - the :py:func:`get_spectrum` method defined. + :returns val: + The values of the sum of gaussians at x. + """ + mu, A, sigma = np.atleast_2d(mu), np.atleast_2d(A), np.atleast_2d(sigma) + val = A / (sigma * np.sqrt(np.pi * 2)) * np.exp(-(x[:, None] - mu)**2 / (2 * sigma**2)) + return val.sum(axis=-1) - :returns spec: - The model spectrum for these parameters, at the wavelengths - specified by ``obs['wavelength']``. Default units are maggies, and - the calibration vector is **not** applied. - :returns phot: - The model photometry for these parameters, for the filters - specified in ``obs['filters']``. Units are maggies. +# TODO: Move the below to a separate IGM module - :returns extras: - Any extra aspects of the model that are returned. Typically this - will be `mfrac` the ratio of the surviving stellar mass to the - steallr mass formed. - """ - self.set_parameters(theta) - spec, phot, extras = sps.get_spectrum(outwave=obs['wavelength'], - filters=obs['filters'], - component=obs.get('component', -1), - lnwavegrid=obs.get('lnwavegrid', None), - **self.params) - - spec *= obs.get('normalization_guess', 1.0) - # Remove negative fluxes. - try: - tiny = 1.0 / len(spec) * spec[spec > 0].min() - spec[spec < tiny] = tiny - except: - pass - spec = (spec + self.sky(obs)) - self._spec = spec.copy() - return spec, phot, extras - - def sky(self, obs): - """Model for the *additive* sky emission/absorption""" - return 0. - - def spec_calibration(self, theta=None, obs=None, **kwargs): - """Implements an overall scaling of the spectrum, given by the - parameter ``'spec_norm'`` - - :returns cal: (float) - A scalar multiplicative factor that gives the ratio between the true - spectrum and the observed spectrum - """ - if theta is not None: - self.set_parameters(theta) - return 1.0 * self.params.get('spec_norm', 1.0) +def H(a, x): + """Voigt Profile Approximation from T. Tepper-Garcia (2006, 2007). + Valid to a fractional error of ~ 1e-7 * (N_h/10^22) for Lyman-alpha (a~1e-4)""" + P = x**2 + H0 = np.exp(-x**2) + Q = 1.5/x**2 + return H0 - a/np.sqrt(np.pi)/P * (H0*H0*(4.*P*P + 7.*P + 4. + Q) - Q - 1) - def wave_to_x(self, wavelength=None, mask=slice(None), **extras): - """Map unmasked wavelengths to the interval (-1, 1). Masked wavelengths may have x>1, x<-1 - :param wavelength: - The input wavelengths. ndarray of shape ``(nwave,)`` +def voigt_profile(wave_rest, N, bkms=40, l0=1215.6696, f=4.16e-1, gamma=6.265e8): + """ + Calculate the optical depth Voigt profile. + Default values of the atomic constants f, l0, and gamma are for Lyman-alpha. + Following Krogager 2018 - :param mask: optional - The mask. slice or boolean array with ``True`` for unmasked elements. - The interval (-1, 1) will be defined only by unmasked wavelength points + Parameters + ---------- + wave_rest : array_like, shape (N) + Restframe wavelength grid in Angstroms at which to evaluate the optical depth. - :returns x: - The wavelength vector, remapped to the interval (-1, 1). - ndarray of same shape as ``wavelength`` - """ - x = wavelength - (wavelength[mask]).min() - x = 2.0 * (x / (x[mask]).max()) - 1.0 - return x + l0 : float + Rest frame transition wavelength in Angstroms. - def mean_model(self, theta, obs, sps=None, sigma_spec=None, **extras): - """Legacy wrapper around predict() - """ - return self.predict(theta, obs, sps=sps, sigma=sigma_spec, **extras) + f : float + Oscillator strength. + + N : float + Column density in units of cm^-2. + bkms : float + Velocity width of the Voigt profile in km/s. -class PolySedModel(SedModel): + gamma : float + Radiation damping constant, or Einstein constant (A_ul) - """This is a subclass of SedModel that replaces the calibration vector with - the maximum likelihood chebyshev polynomial describing the difference - between the observed and the model spectrum. + Returns + ------- + tau : array_like, shape (N) + Optical depth array evaluated at the input grid wavelengths `l`. """ + # Units & constants + c = 2.99792e10 # cm/s + const = 0.0149736082 # sqrt(pi) * e**2/(c * m_e) (cgs) + l0_cm = (l0*1.e-8) + b = bkms * 1e5 - def spec_calibration(self, theta=None, obs=None, **kwargs): - """Implements a Chebyshev polynomial calibration model. This uses - least-squares to find the maximum-likelihood Chebyshev polynomial of a - certain order describing the ratio of the observed spectrum to the - model spectrum, conditional on all other parameters, using least - squares. The first coefficient is always set to 1, as the overall - normalization is controlled by ``spec_norm``. + # Calculate Profile + C_a = const * f * l0_cm / b + a = l0_cm * gamma / (4.*np.pi*b) - :returns cal: - A polynomial given by 'spec_norm' * (1 + \sum_{m=1}^M a_{m} * T_m(x)). - """ - if theta is not None: - self.set_parameters(theta) - - norm = self.params.get('spec_norm', 1.0) - polyopt = ((self.params.get('polyorder', 0) > 0) & - (obs.get('spectrum', None) is not None)) - if polyopt: - order = self.params['polyorder'] - mask = obs.get('mask', slice(None)) - # map unmasked wavelengths to the interval -1, 1 - # masked wavelengths may have x>1, x<-1 - x = self.wave_to_x(obs["wavelength"], mask) - y = (obs['spectrum'] / self._spec)[mask] / norm - 1.0 - yerr = (obs['unc'] / self._spec)[mask] / norm - yvar = yerr**2 - A = chebvander(x[mask], order)[:, 1:] - ATA = np.dot(A.T, A / yvar[:, None]) - reg = self.params.get('poly_regularization', 0.) - if np.any(reg > 0): - ATA += reg**2 * np.eye(order) - ATAinv = np.linalg.inv(ATA) - c = np.dot(ATAinv, np.dot(A.T, y / yvar)) - Afull = chebvander(x, order)[:, 1:] - poly = np.dot(Afull, c) - self._poly_coeffs = c - else: - poly = 0.0 + x = (c / b) * (1. - l0 / wave_rest) + tau = np.float64(C_a) * N * H(a, x) - return (1.0 + poly) * norm + return tau -class PolyFitModel(SedModel): +def Voigt(x, alpha, gamma): + """ + Return the Voigt line shape at x with Lorentzian component HWHM gamma + and Gaussian component HWHM alpha. - """This is a subclass of *SedModel* that generates the multiplicative - calibration vector as a Chebyshev polynomial described by the - ``'poly_coeffs'`` parameter of the model, which may be free (fittable) """ + from scipy.special import wofz + sigma = alpha / np.sqrt(2 * np.log(2)) - def spec_calibration(self, theta=None, obs=None, **kwargs): - """Implements a Chebyshev polynomial calibration model. This only - occurs if ``"poly_coeffs"`` is present in the :py:attr:`params` - dictionary, otherwise the value of ``params["spec_norm"]`` is returned. + return np.real(wofz((x + 1j*gamma)/sigma/np.sqrt(2))) / sigma\ + /np.sqrt(2*np.pi) - :param theta: (optional) - If given, set :py:attr:`params` using this vector before - calculating the calibration polynomial. ndarray of shape - ``(ndim,)`` - :param obs: - A dictionary of observational data, must contain the key - ``"wavelength"`` +def tau_damping(wave_rest, zred, x_HI, zmin=5, cosmo=cosmo, Y=0.25, l0=1215.6696): + """Compute the optical depth redward of restframe Ly-alpha due to the IGM + damping wing. Fiollows Mirald-Escude 1998 and Totani 2006 in assuming a + uniform IGM below the object redshift. - :returns cal: - If ``params["cal_type"]`` is ``"poly"``, a polynomial given by - ``'spec_norm'`` :math:`\times (1 + \Sum_{m=1}^M```'poly_coeffs'[m-1]``:math:` \times T_n(x))`. - Otherwise, the exponential of a Chebyshev polynomial. - """ - if theta is not None: - self.set_parameters(theta) - - if ('poly_coeffs' in self.params): - mask = obs.get('mask', slice(None)) - # map unmasked wavelengths to the interval -1, 1 - # masked wavelengths may have x>1, x<-1 - x = self.wave_to_x(obs["wavelength"], mask) - # get coefficients. Here we are setting the first term to 0 so we - # can deal with it separately for the exponential and regular - # multiplicative cases - c = np.insert(self.params['poly_coeffs'], 0, 0) - poly = chebval(x, c) - # switch to have spec_norm be multiplicative or additive depending - # on whether the calibration model is multiplicative in exp^poly or - # just poly - if self.params.get('cal_type', 'exp_poly') == 'poly': - return (1.0 + poly) * self.params.get('spec_norm', 1.0) - else: - return np.exp(self.params.get('spec_norm', 0) + poly) - else: - return 1.0 * self.params.get('spec_norm', 1.0) + Parameters + ---------- + wave_rest : array_like, shape (N) + Restframe wavelength grid in Angstroms at which to evaluate the optical depth. + zred : float + The object redshift. We also assume this is the maximum redshift of the + IGM integral -def ln_mvn(x, mean=None, cov=None): - """Calculates the natural logarithm of the multivariate normal PDF - evaluated at `x` + x_HI : float + The neutral fraction of the IGM. Can be greater than 1 to approximate + local overdensity. - :param x: - locations where samples are desired. + zmin : float + The minimum redshift for the uniform IGM integral - :param mean: - Center(s) of the gaussians. + cosmo : astropy.cosmology.Cosmology() instance + The cosmology to use for the calculation + + Y : float + The helium fraction of the universe + + l0 : float + Rest frame transition wavelength in Angstroms. + + Returns + ------- + tau_damping : array_like, shape (N) + The optical depth due to the damping wing. Will be zero blueward of l0. - :param cov: - Covariances of the gaussians. """ - ndim = mean.shape[-1] - dev = x - mean - log_2pi = np.log(2 * np.pi) - sign, log_det = np.linalg.slogdet(cov) - exp = np.dot(dev.T, np.dot(np.linalg.pinv(cov, rcond=1e-12), dev)) + R_alpha = 2.02e-8 - return -0.5 * (ndim * log_2pi + log_det + exp) + wave_obs = wave_rest * (1 + zred) + zobs = wave_obs / l0 - 1 + red = (zobs - zred) / (1+zobs) > (100 * R_alpha) + tau_damp = np.zeros_like(wave_rest) + xx = Ix((1 + zred) / (1 + zobs[red])) - Ix((1 + zmin) / (1 + zobs[red])) -def gauss(x, mu, A, sigma): - """Sample multiple gaussians at positions x. + tau = R_alpha/np.pi * x_HI * tau_gp(cosmo, zred, Y=Y) + tau = tau * ((1 + zobs[red]) / (1 + zred))**(3/2) * xx + tau_damp[red] = tau + return tau_damp - :param x: - locations where samples are desired. - :param mu: - Center(s) of the gaussians. +def tau_gp(cosmo, zred, Y=0.25): + # totani 2006 + # with scaling by omega_baryon * (1-Y) * h / sqrt(Omega_m) + #factor = 3.88e5 + #scale = ((1-Y)/0.75) * (cosmo.Ob0/0.044) * (cosmo.Om0/0.27)**(-1/2) * (cosmo.h/0.71) - :param A: - Amplitude(s) of the gaussians, defined in terms of total area. + # Also Miralda-Escude substituting 1/sqrt(Omega_m) = Ho/Hz * (1+z)^(3/2)) + factor = 2.6e5 + scale = (1-Y) * cosmo.h * cosmo.Om0**(-0.5) * (cosmo.Ob0/0.03) - :param sigma: - Dispersion(s) of the gaussians, un units of x. + # Hertz 2024 relies on Becker 01/wikipedia and is not the same (missing 1-Y ?) + # factor = 1.8 + # scale = cosmo.h * cosmo.Om0**(-0.5) * (cosmo.Ob0/0.02) - :returns val: - The values of the sum of gaussians at x. - """ - mu, A, sigma = np.atleast_2d(mu), np.atleast_2d(A), np.atleast_2d(sigma) - val = A / (sigma * np.sqrt(np.pi * 2)) * np.exp(-(x[:, None] - mu)**2 / (2 * sigma**2)) - return val.sum(axis=-1) + tau_gp = factor * scale * ((1 + zred) / 7)**(3/2) + return tau_gp + + +def Ix(x): + v = x**(9/2)/(1-x) + 9/7 * x**(3.5) + 9/5*x**(2.5) + 3*x**(1.5) + 9 * x**(0.5) + v -= 9/2 * np.log((1+x**0.5) / (1-x**0.5)) + return v \ No newline at end of file diff --git a/prospect/models/templates.py b/prospect/models/templates.py index e8f164da..54afad9d 100755 --- a/prospect/models/templates.py +++ b/prospect/models/templates.py @@ -10,12 +10,13 @@ import os from . import priors from . import priors_beta -from . import transforms +from . import transforms, hyperparam_transforms __all__ = ["TemplateLibrary", "describe", "adjust_dirichlet_agebins", "adjust_continuity_agebins", + "adjust_stochastic_params" ] @@ -131,6 +132,27 @@ def adjust_continuity_agebins(parset, tuniv=13.7, nbins=7): return parset +def adjust_stochastic_params(parset, tuniv=13.7): + + agebins = parset['agebins']['init'] + + ncomp = len(parset['agebins']['init']) + mean = np.zeros(ncomp - 1) + psd_params = [parset['sigma_reg']['init'], parset['tau_eq']['init'], parset['tau_in']['init'], + parset['sigma_dyn']['init'], parset['tau_dyn']['init']] + sfr_covar = hyperparam_transforms.get_sfr_covar(psd_params, agebins=agebins) + sfr_ratio_covar = hyperparam_transforms.sfr_covar_to_sfr_ratio_covar(sfr_covar) + rprior = priors.MultiVariateNormal(mean=mean, Sigma=sfr_ratio_covar) + + parset['mass']['N'] = ncomp + parset['agebins']['N'] = ncomp + parset["logsfr_ratios"]["N"] = ncomp - 1 + parset["logsfr_ratios"]["init"] = mean + parset["logsfr_ratios"]["prior"] = rprior + + return parset + + TemplateLibrary = Directory() # A template for what parameter configuration element should look like @@ -268,6 +290,99 @@ def adjust_continuity_agebins(parset, tuniv=13.7, nbins=7): ("The set of nebular emission parameters, " "with gas_logz tied to stellar logzsol.")) +# new nebular parameters from cue +use_eline_nn_unc = {'N': 1, "isfree": False, "init": True} +use_stellar_ionizing = {'N': 1, "isfree": False, "init": False} +gas_logz = {'N': 1, 'isfree': True, + "init": 0.0, 'units': r"log Z/Z_\odot", + "prior": priors.TopHat(mini=-2.2, maxi=0.5)} + +gas_logu = {"N": 1, 'isfree': True, + "init": -2.0, 'units': r"Q_H/N_H", + "prior": priors.TopHat(mini=-4.0, maxi=-1.0)} + +gas_lognH = {"N": 1, 'isfree': True, + "init": 2.0, 'units': r"n_H", + "prior": priors.TopHat(mini=1.0, maxi=4.0)} + +gas_logno = {"N": 1, 'isfree': True, + "init": 0.0, 'units': r"[N/O]", + "prior": priors.TopHat(mini=-1.0, maxi=np.log10(5.4))} + +gas_logco = {"N": 1, 'isfree': True, + "init": 0.0, 'units': r"[C/O]", + "prior": priors.TopHat(mini=-1.0, maxi=np.log10(5.4))} + +ionspec_index1 = {"N": 1, 'isfree': True, + "init": 3.3, 'units': r"1st power-law index of ionizing spectrum", + "prior": priors.TopHat(mini=1.0, maxi=42.0)} + +ionspec_index2 = {"N": 1, 'isfree': True, + "init": 15.0, 'units': r"2nd power-law index of ionizing spectrum", + "prior": priors.TopHat(mini=-0.3, maxi=30.0)} + +ionspec_index3 = {"N": 1, 'isfree': True, + "init": 8.0, 'units': r"3rd power-law index of ionizing spectrum", + "prior": priors.TopHat(mini=-1.0, maxi=14.0)} + +ionspec_index4 = {"N": 1, 'isfree': True, + "init": 3.0, 'units': r"4th power-law index of ionizing spectrum", + "prior": priors.TopHat(mini=-1.7, maxi=8.0)} + +ionspec_logLratio1 = {"N": 1, 'isfree': True, + "init": 2.0, 'units': r"ratio of 2nd and 1st ionizing spectrum segments", + "prior": priors.TopHat(mini=-1.0, maxi=10.1)} + +ionspec_logLratio2 = {"N": 1, 'isfree': True, + "init": 1.0, 'units': r"ratio of 3rd and 2nd ionizing spectrum segments", + "prior": priors.TopHat(mini=-0.5, maxi=1.9)} + +ionspec_logLratio3 = {"N": 1, 'isfree': True, + "init": 1.0, 'units': r"ratio of 4th and 3rd ionizing spectrum segments", + "prior": priors.TopHat(mini=-0.4, maxi=2.2)} + +log_qion = {"N": 1, 'isfree': True, + "init": 52.0, 'units': r"log Q_H", + "prior": priors.TopHat(mini=35.0, maxi=65.0)} + +_cue_nebular_ = {"add_neb_emission": add_neb, + "add_neb_continuum": neb_cont, + "nebemlineinspec": neb_spec, + "use_eline_nn_unc": use_eline_nn_unc, + "use_stellar_ionizing": use_stellar_ionizing, + "gas_logz": gas_logz, + "gas_logu": gas_logu, + "gas_lognH": gas_lognH, + "gas_logno": gas_logno, + "gas_logco": gas_logco, + "ionspec_index1": ionspec_index1, + "ionspec_index2": ionspec_index2, + "ionspec_index3": ionspec_index3, + "ionspec_index4": ionspec_index4, + "ionspec_logLratio1": ionspec_logLratio1, + "ionspec_logLratio2": ionspec_logLratio2, + "ionspec_logLratio3": ionspec_logLratio3, + "gas_logqion": log_qion, + } + +TemplateLibrary["cue_nebular"] = (_cue_nebular_, + ("The set of nebular emission parameters for cue, where ionizing spectrum is free.")) + +use_stellar_ionizing = {'N': 1, "isfree": False, "init": True} +_cue_stellar_nebular_ = {"add_neb_emission": add_neb, + "add_neb_continuum": neb_cont, + "nebemlineinspec": neb_spec, + "use_eline_nn_unc": use_eline_nn_unc, + "use_stellar_ionizing": use_stellar_ionizing, + "gas_logz": gas_logz, + "gas_logu": gas_logu, + "gas_lognH": gas_lognH, + "gas_logno": gas_logno, + "gas_logco": gas_logco + } +TemplateLibrary["cue_stellar_nebular"] = (_cue_stellar_nebular_, + ("The set of nebular emission parameters for cue, where ionizing spectrum is fixed to young stellar populations from FSPS.")) + # ----------------------------------------- # --- Nebular Emission Marginalization ---- # ----------------------------------------- @@ -659,6 +774,63 @@ def adjust_continuity_agebins(parset, tuniv=13.7, nbins=7): TemplateLibrary["dirichlet_sfh"] = (_dirichlet_, "Non-parameteric SFH with Dirichlet prior (fractional SFR)") + +# ---------------------------- +# --- Stochastic SFH ---- +# ---------------------------- +# A non-parametric SFH model which correlates the SFRs between time bins based on Extended Regulator model in TFC2020 + +_stochastic_ = TemplateLibrary["ssp"] +_ = _stochastic_.pop("tage") + +_stochastic_["sfh"] = {"N": 1, "isfree": False, "init": 3, "units": "FSPS index"} +# This is the *total* mass formed, as a variable +_stochastic_["logmass"] = {"N": 1, "isfree": True, "init": 10, 'units': 'Msun', + 'prior': priors.TopHat(mini=7, maxi=12)} +# This will be the mass in each bin. It depends on other free and fixed +# parameters. Its length needs to be modified based on the number of bins +_stochastic_["mass"] = {'N': 8, 'isfree': False, 'init': 1e6, 'units': r'M$_\odot$', + 'depends_on': transforms.logsfr_ratios_to_masses} + +# This gives the start and stop of each age bin. It can be adjusted and its +# length must match the lenth of "mass" +agebins = [[0.0, 6.0], [6.0, 6.5], [6.5, 7.0], [7.0, 7.5], [7.5, 8.0], [8.0, 8.5], [8.5, 9.0], [9.5, 10.0]] +_stochastic_["agebins"] = {'N': 8, 'isfree': False, 'init': agebins, 'units': 'log(yr)'} + +# Sets the PSD parameters & priors +# sigma_reg: Overall stochasticity coming from gas inflow +_stochastic_["sigma_reg"] = {'name': 'sigma_reg', 'N': 1, 'isfree': True, 'init': 0.3, + 'prior': priors.LogUniform(mini=0.01, maxi=5.0), 'units': 'dex^2'} +# tau_eq: Timescale associated with equilibrium gas cycling in gas reservoir (related to depletion timescale) +_stochastic_["tau_eq"] = {'name': 'tau_eq', 'N': 1, 'isfree': True, 'init': 2.5, + 'prior': priors.TopHat(mini=0.01, maxi=7.0), 'units': 'Gyr'} +# tau_in: Characteristic timescale associated with gas inflow into gas reservoir +_stochastic_["tau_in"] = {'name': 'tau_in', 'N': 1, 'isfree': False, 'init': 7.0, 'units': 'Gyr'} +# sigma_dyn: Overall stochasticity coming from short-term, dynamical processes (e.g., creation/destruction of GMCs) +_stochastic_["sigma_dyn"] = {'name': 'sigma_dyn', 'N': 1, 'isfree': True, 'init': 0.01, + 'prior': priors.LogUniform(mini=0.001, maxi=0.1), 'units': 'dex^2'} +# tau_dyn: Characteristic timescale associated with short-term, dynamical processes +_stochastic_["tau_dyn"] = {'name': 'tau_dyn', 'N': 1, 'isfree': True, 'init': 0.025, + 'prior': priors.ClippedNormal(mini=0.005, maxi=0.2, mean=0.01, sigma=0.02), 'units': 'Gyr'} + +_stochastic_["logsfr_ratios"] = {'N': 7, 'isfree': True, 'init': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + 'prior': None} + +_stochastic_ = adjust_stochastic_params(_stochastic_) +# calculates covariance matrix from the initial PSD parameter values to be used in log SFR ratios prior +# psd_params = [0.3, 2.5, 1.0, 0.01, 0.025] +# sfr_covar = hyperparam_transforms.get_sfr_covar(psd_params, agebins=agebins) +# sfr_ratio_covar = hyperparam_transforms.sfr_covar_to_sfr_ratio_covar(sfr_covar) + +# This controls the distribution of SFR(t) / SFR(t+dt). It has NBINS-1 components. +# _stochastic_["logsfr_ratios"] = {'N': 7, 'isfree': True, 'init': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], +# 'prior': priors.MultiVariateNormal(mean=[0.]*7, Sigma=sfr_ratio_covar)} + +TemplateLibrary["stochastic_sfh"] = (_stochastic_, + ("Stochastic SFH which correlates the SFRs between time bins based on model in TFC2020." + " Requires `HyperSpecModel` as the base model class.")) + + # ---------------------------- # --- Prospector-alpha --- # ---------------------------- @@ -736,4 +908,4 @@ def adjust_continuity_agebins(parset, tuniv=13.7, nbins=7): 'depends_on': transforms.zred_to_agebins_pbeta} TemplateLibrary["beta"] = (_beta_nzsfh_, - "The prospector-beta model; Wang, Leja, et al. 2023") + "The prospector-beta model; Wang, Leja, et al. 2023") \ No newline at end of file diff --git a/prospect/models/transforms.py b/prospect/models/transforms.py index b8d2697c..9b78f136 100755 --- a/prospect/models/transforms.py +++ b/prospect/models/transforms.py @@ -10,6 +10,9 @@ import numpy as np from ..sources.constants import cosmo +#from gp_sfh import * +#import gp_sfh_kernels + __all__ = ["stellar_logzsol", "delogify_mass", "tburst_from_fage", "tage_from_tuniv", "zred_to_agebins", @@ -17,7 +20,8 @@ "logsfr_ratios_to_masses", "logsfr_ratios_to_sfrs", "logsfr_ratios_to_masses_flex", "logsfr_ratios_to_agebins", "zfrac_to_masses", "zfrac_to_sfrac", "zfrac_to_sfr", "masses_to_zfrac", - "sfratio_to_sfr", "sfratio_to_mass", + "sfratio_to_sfr", "sfratio_to_mass", + #"get_sfr_covar", "sfr_covar_to_sfr_ratio_covar", "zred_to_agebins_pbeta", "zredmassmet_to_zred", "zredmassmet_to_logmass", "zredmassmet_to_mass", "zredmassmet_to_logzsol", "nzsfh_to_zred", "nzsfh_to_logmass", "nzsfh_to_mass", "nzsfh_to_logzsol", "nzsfh_to_logsfr_ratios"] @@ -184,7 +188,8 @@ def logsfr_ratios_to_masses(logmass=None, logsfr_ratios=None, agebins=None, time. """ nbins = agebins.shape[0] - sratios = 10**np.clip(logsfr_ratios, -100, 100) # numerical issues... + sratios = 10**np.clip(logsfr_ratios, -10, 10) # numerical issues... + #sratios = 10**np.clip(logsfr_ratios, -100, 100) # numerical issues... dt = (10**agebins[:, 1] - 10**agebins[:, 0]) coeffs = np.array([ (1. / np.prod(sratios[:i])) * (np.prod(dt[1: i+1]) / np.prod(dt[: i])) for i in range(nbins)]) @@ -209,8 +214,10 @@ def logsfr_ratios_to_sfrs(logmass=None, logsfr_ratios=None, agebins=None, **extr def logsfr_ratios_to_masses_flex(logmass=None, logsfr_ratios=None, logsfr_ratio_young=None, logsfr_ratio_old=None, **extras): - logsfr_ratio_young = np.clip(logsfr_ratio_young, -100, 100) - logsfr_ratio_old = np.clip(logsfr_ratio_old, -100, 100) + logsfr_ratio_young = np.clip(logsfr_ratio_young, -10, 10) + logsfr_ratio_old = np.clip(logsfr_ratio_old, -10, 10) + #logsfr_ratio_young = np.clip(logsfr_ratio_young, -100, 100) + #logsfr_ratio_old = np.clip(logsfr_ratio_old, -100, 100) abins = logsfr_ratios_to_agebins(logsfr_ratios=logsfr_ratios, **extras) @@ -236,7 +243,8 @@ def logsfr_ratios_to_agebins(logsfr_ratios=None, agebins=None, **extras): """ # numerical stability - logsfr_ratios = np.clip(logsfr_ratios, -100, 100) + logsfr_ratios = np.clip(logsfr_ratios, -10, 10) + #logsfr_ratios = np.clip(logsfr_ratios, -100, 100) # calculate delta(t) for oldest, youngest bins (fixed) lower_time = (10**agebins[0, 1] - 10**agebins[0, 0]) @@ -498,15 +506,14 @@ def sfratio_to_mass(sfr_ratio=None, sfr0=None, agebins=None, **extras): def zred_to_agebins_pbeta(zred=None, agebins=[], **extras): """New agebin scheme, refined so that none of the bins is overly wide when the universe is young. - + Parameters ---------- zred : float Cosmological redshift. This sets the age of the universe. - agebins : ndarray of shape ``(nbin, 2)`` The SFH bin edges in log10(years). - + Returns ------- agebins : ndarray of shape ``(nbin, 2)`` @@ -521,7 +528,7 @@ def zred_to_agebins_pbeta(zred=None, agebins=[], **extras): else: agelims = np.linspace(amin,np.log10(tbinmax),nbins_sfh).tolist() + [np.log10(tuniv)] agelims[0] = 0 - + agebins = np.array([agelims[:-1], agelims[1:]]) return agebins.T @@ -555,3 +562,52 @@ def nzsfh_to_logzsol(nzsfh=None, **extras): def nzsfh_to_logsfr_ratios(nzsfh=None, **extras): return nzsfh[3:] + + +# -------------------------------------- +# --- Transforms for stochastic SFH prior --- +# -------------------------------------- + +# def get_sfr_covar(psd_params, agebins=[], **extras): + +# """ +# Caluclates SFR covariance matrix for a given set of PSD parameters and agebins +# PSD parameters must be in the order: [sigma_reg, tau_eq, tau_in, sigma_dyn, tau_dyn] + +# Returns +# ------- +# covar_matrix: (Nbins, Nbins)-dim array of covariance values for SFR +# """ + +# bincenters = np.array([np.mean(agebins[i]) for i in range(len(agebins))]) +# bincenters = (10**bincenters)/1e9 +# case1 = simple_GP_sfh() +# case1.tarr = bincenters +# case1.kernel = gp_sfh_kernels.extended_regulator_model_kernel_paramlist +# covar_matrix = case1.get_covariance_matrix(kernel_params = psd_params, show_prog=False) + +# return covar_matrix + + +# def sfr_covar_to_sfr_ratio_covar(covar_matrix): + +# """ +# Caluclates log SFR ratio covariance matrix from SFR covariance matrix + +# Returns +# ------- +# sfr_ratio_covar: (Nbins-1, Nbins-1)-dim array of covariance values for log SFR +# """ + +# dim = covar_matrix.shape[0] + +# sfr_ratio_covar = [] + +# for i in range(dim-1): +# row = [] +# for j in range(dim-1): +# cov = covar_matrix[i][j] - covar_matrix[i+1][j] - covar_matrix[i][j+1] + covar_matrix[i+1][j+1] +# row.append(cov) +# sfr_ratio_covar.append(row) + +# return np.array(sfr_ratio_covar) diff --git a/prospect/observation/__init__.py b/prospect/observation/__init__.py new file mode 100644 index 00000000..46bf1ef4 --- /dev/null +++ b/prospect/observation/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +from .observation import Observation +from .observation import Photometry, Spectrum, Lines +from .observation import UndersampledSpectrum, IntrinsicSpectrum +from .observation import PolyOptCal, SplineOptCal +from .observation import from_oldstyle, from_serial + +__all__ = ["Observation", + "Photometry", "Spectrum", "Lines", + "UndersampledSpectrum", "InstrinsicSpectrum", + "PolyOptCal", "SplineOptCal", + "from_oldstyle", "from_serial"] diff --git a/prospect/observation/observation.py b/prospect/observation/observation.py new file mode 100644 index 00000000..0062db13 --- /dev/null +++ b/prospect/observation/observation.py @@ -0,0 +1,794 @@ +# -*- coding: utf-8 -*- + +import json +import numpy as np + +from numpy.polynomial.chebyshev import chebval, chebvander +from scipy.interpolate import splrep, BSpline +from scipy.signal import medfilt + +from sedpy.observate import FilterSet +from sedpy.smoothing import smooth_fft +from sedpy.observate import rebin + +from ..likelihood.noise_model import NoiseModel + + +__all__ = ["Observation", + "Spectrum", "Photometry", "Lines", + "UndersampledSpectrum", "IntrinsicSpectrum", + "SplineOptCal", "PolyOptCal", + "from_oldstyle", "from_serial", "obstypes"] + + +CKMS = 2.998e5 + + +class NumpyEncoder(json.JSONEncoder): + + def default(self, obj): + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, type): + return str(obj) + return json.JSONEncoder.default(self, obj) + + +class Observation: + + """Data to be predicted (and fit) + + Attributes + ---------- + flux : + uncertainty : + mask : + noise : + """ + + _kind = "observation" + logify_spectrum = False + alias = {} + _meta = ("kind", "name") + _data = ("wavelength", "flux", "uncertainty", "mask") + + def __init__(self, + flux=None, + uncertainty=None, + mask=slice(None), + noise=NoiseModel(), + name=None, + **kwargs + ): + + self.flux = np.array(flux) + self.uncertainty = np.array(uncertainty) + self.mask = mask + self.noise = noise + self.from_oldstyle(**kwargs) + if name is None: + addr = f"{id(self):04x}" + self.name = f"{self.kind[:5]}-{addr[:6]}" + else: + self.name = name + + def __str__(self): + return f"{self.kind} ({self.name})" + + def __getitem__(self, item): + """Dict-like interface for backwards compatibility + """ + k = self.alias.get(item, item) + return getattr(self, k) + + def get(self, item, default): + try: + return self[item] + except(AttributeError): + return default + + def from_oldstyle(self, **kwargs): + """Take an old-style obs dict and use it to populate the relevant + attributes. + """ + for k, v in self.alias.items(): + if k in kwargs: + setattr(self, v, kwargs[k]) + + def rectify(self): + """Make sure required attributes for fitting are present and have the + appropriate sizes. Also auto-masks non-finite data or negative + uncertainties. + """ + n = self.__repr__ + if self.flux is None: + print(f"{n} has no data") + return + + assert self.flux.ndim == 1, f"{n}: flux is not a 1d array" + assert self.uncertainty.ndim == 1, f"{n}: uncertainty is not a 1d array" + assert self.ndata > 0, f"{n} no data supplied!" + assert self.uncertainty is not None, f"{n} No uncertainties." + assert len(self.flux) == len(self.uncertainty), f"{n}: flux and uncertainty of different length" + if self.wavelength is not None: + assert self.wavelength.ndim == 1, f"{n}: `wavelength` is not 1-d array" + assert len(self.wavelength) == len(self.flux), f"{n}: Wavelength array not same shape as flux array" + + self._automask() + + assert self.ndof > 0, f"{self.__repr__()} has no valid data to fit: check the sign of the masks." + assert hasattr(self, "noise") + + def _automask(self): + # make mask array with automatic filters + marr = np.zeros(self.ndata, dtype=bool) + marr[self.mask] = True + if self.flux is not None: + self.mask = (marr & + (np.isfinite(self.flux)) & + (np.isfinite(self.uncertainty)) & + (self.uncertainty > 0)) + else: + self.mask = marr + + def render(self, wavelength, spectrum): + raise(NotImplementedError) + + @property + def kind(self): + # make 'kind' private + return self._kind + + @property + def ndof(self): + # TODO: cache this? + return int(np.sum(np.ones(self.ndata)[self.mask])) + + @property + def ndata(self): + # TODO: cache this? + if self.flux is None: + return 0 + else: + return len(self.flux) + + @property + def wave_min(self): + return np.min(self.wavelength) + + @property + def wave_max(self): + return np.max(self.wavelength) + + @property + def metadata(self): + meta = {m: getattr(self, m) for m in self._meta} + if "filternames" in meta: + meta["filters"] = ",".join(meta["filternames"]) + return meta + + def to_struct(self, data_dtype=np.float32): + """Convert data to a structured array + """ + self._automask() + cols = [] + for c in self._data: + dat = getattr(self, c) + if (dat is None): + continue + if (len(dat) != self.ndata): + continue + #raise ValueError(f"The {c} attribute of the {self.name} observation has the wrong length ({len(dat)} instead of {self.ndata})") + cols += [(c, dat.dtype)] + dtype = np.dtype(cols) + struct = np.zeros(self.ndata, dtype=dtype) + for c in dtype.names: + data = getattr(self, c) + if c is not None: + struct[c] = data + #except(ValueError): + # pass + return struct + + def to_fits(self, filename=""): + from astropy.io import fits + hdus = fits.HDUList([fits.PrimaryHDU(), + fits.BinTableHDU(self.to_struct())]) + for hdu in hdus: + hdu.header.update(self.metadata) + if filename: + hdus.writeto(filename, overwrite=True) + hdus.close() + + def to_h5_dataset(self, handle): + dset = handle.create_dataset(self.name, data=self.to_struct()) + dset.attrs.update(self.metadata) + + def to_json(self): + obs = {m: getattr(self, m) for m in self._meta + self._data} + serial = json.dumps(obs, cls=NumpyEncoder) + return serial + + def to_oldstyle(self): + obs = {} + obs.update(vars(self)) + for k, v in self.alias.items(): + obs[k] = self[v] + _ = obs.pop(v) + return obs + + @property + def maggies_to_nJy(self): + return 1e9 * 3631 + + +class Photometry(Observation): + + _kind = "photometry" + alias = dict(maggies="flux", + maggies_unc="uncertainty", + filters="filters", + phot_mask="mask") + _meta = ("kind", "name", "filternames") + + def __init__(self, filters=[], + name=None, + **kwargs): + """On Observation object that holds photometric data + + Parameters + ---------- + filters : list of strings or list of `sedpy.observate.Filter` instances + The names or instances of Filters to use + + flux : iterable of floats + The flux through the filters, in units of maggies. + + uncertainty : iterable of floats + The uncertainty on the flux + + name : string, optional + The name for this set of data + """ + self.set_filters(filters) + super(Photometry, self).__init__(name=name, **kwargs) + + def set_filters(self, filters): + + # TODO: Make this less convoluted + if (len(filters) == 0) or (filters is None): + self.filters = filters + self.filternames = [] + self.filterset = None + return + + try: + self.filternames = [f.name for f in filters] + except(AttributeError): + self.filternames = filters + #if type(filters[0]) is str: + # self.filternames = filters + #else: + # self.filternames = [f.name for f in filters] + + self.filterset = FilterSet(self.filternames) + # filters on the gridded resolution + self.filters = [f for f in self.filterset.filters] + + @property + def wavelength(self): + return np.array([f.wave_effective for f in self.filters]) + + def to_oldstyle(self): + obs = super(Photometry, self).to_oldstyle() + obs["phot_wave"] = self.wavelength + return obs + + +class Spectrum(Observation): + + _kind = "spectrum" + alias = dict(spectrum="flux", + unc="uncertainty", + wavelength="wavelength", + mask="mask") + + _meta = ("kind", "name", "lambda_pad") + _data = ("wavelength", "flux", "uncertainty", "mask", + "resolution", "response") + + def __init__(self, + wavelength=None, + resolution=None, + response=None, + name=None, + lambda_pad=100, + **kwargs): + + """ + Parameters + ---------- + wavelength : iterable of floats + The wavelength of each flux measurement, in vacuum AA + + flux : iterable of floats + The flux at each wavelength, in units of maggies, same length as + ``wavelength`` + + uncertainty : iterable of floats + The uncertainty on the flux + + resolution : (optional, default: None) + Instrumental resolution at each wavelength point in units of km/s + dispersion (:math:`= c \, \sigma_\lambda / \lambda = c \, \FWHM_\lambda / 2.355 / \lambda = c / (2.355 \, R_\lambda)` + where :math:`c=2.998e5 {\rm km}/{\rm s}` + + :param calibration: + not sure yet .... + """ + super(Spectrum, self).__init__(name=name, **kwargs) + self.lambda_pad = lambda_pad + self.resolution = resolution + self.response = response + self.instrument_smoothing_parameters = dict(smoothtype="vel", fftsmooth=True) + self.wavelength = np.atleast_1d(wavelength) + + @property + def wavelength(self): + return self._wavelength + + @wavelength.setter + def wavelength(self, wave): + self._wavelength = wave + if self._wavelength is not None: + assert np.all(np.diff(self._wavelength) > 0) + self.pad_wavelength_array() + + def pad_wavelength_array(self): + """Pad the wavelength and, if present, resolution arrays so that FFTs + can be used on the models without edge effects. + """ + if self.wavelength is None: + return + + low_pad = np.arange(self.lambda_pad, 1, (self.wavelength[0]-self.wavelength[1])) + hi_pad = np.arange(1, self.lambda_pad, (self.wavelength[-1]-self.wavelength[-2])) + wave_min = self.wave_min - low_pad + wave_max = self.wave_max + hi_pad + self.padded_wavelength = np.concatenate([wave_min, self.wavelength, wave_max]) + self._unpadded_inds = slice(len(low_pad), -len(hi_pad)) + if self.resolution is not None: + self.padded_resolution = np.interp(self.padded_wavelength, self.wavelength, self.resolution) + + def _smooth_lsf_fft(self, inwave, influx, outwave, sigma): + + # construct cdf of 'resolution elements' as a fn of wavelength + dw = np.gradient(outwave) + sigma_per_pixel = (dw / sigma) + cdf = np.cumsum(sigma_per_pixel) + cdf /= cdf.max() + + # Get number of resolution elements: is this the best way to do this? + # Can't we just use unnormalized cdf above + x_per_pixel = np.gradient(cdf) + x_per_sigma = np.nanmedian(x_per_pixel / sigma_per_pixel) + pix_per_sigma = 1 + N = pix_per_sigma / x_per_sigma + nx = int(2**np.ceil(np.log2(N))) + + # now evenly sample in the x coordinate + x = np.linspace(cdf[0], 1, nx) + dx = (1.0 - cdf[0]) / nx + # convert x back to wavelength, and get model at these wavelengths + lam = np.interp(x, cdf, outwave) + newflux = np.interp(lam, inwave, influx) + + # smooth flux sampled at constant resolution + # could replace this with np.conv() + flux_conv = smooth_fft(dx, newflux, x_per_sigma) + + # sample at wavelengths of pixels + outflux = self._pixelize(outwave, lam, flux_conv) + return outflux + + def _pixelize(self, outwave, inwave, influx): + # could do this with an FFT in pixel space using a sinc + return np.interp(outwave, inwave, influx) + + def instrumental_smoothing(self, model_wave_obsframe, model_flux, zred=0, libres=0): + """Smooth a spectrum by the instrumental resolution, optionally + accounting (in quadrature) the intrinsic library resolution. + + Parameters + ---------- + model_wave_obsframe : ndarray of shape (N_pix_model,) + Observed frame wavelengths, in units of AA for the *model* + + model_flux : ndarray of shape (N_pix_model,) + Flux array corresponding to the observed frame wavelengths + + libres : float or ndarray of shape (N_pix_model,) + Library resolution in units of km/s (dispersion) to be subtracted + from the smoothing kernel. This should be in the observed frame and + on the same wavelength grid as obswave + + Returns + ------- + outflux : ndarray of shape (ndata,) + If instrument resolution is not None, this is the smoothed flux on + the observed ``wavelength`` grid. If wavelength is None, this just + passes ``influx`` right back again. If ``resolution`` is None then + ``influx`` is simply interpolated onto the wavelength grid + """ + if self.wavelength is None: + return model_flux + if self.resolution is None: + return np.interp(self.wavelength, model_wave_obsframe, model_flux) + + # interpolate library resolution onto the instrumental wavelength grid + Klib = np.interp(self.padded_wavelength, model_wave_obsframe, libres) + assert np.all(self.padded_resolution >= Klib), "data higher resolution than library" + + # quadrature difference of instrumental and library resolution + Kdelta = np.sqrt(self.padded_resolution**2 - Klib**2) + Kdelta_lambda = Kdelta / CKMS * self.padded_wavelength + + # Smooth by the difference kernel + outspec_padded = self._smooth_lsf_fft(model_wave_obsframe, + model_flux, + self.padded_wavelength, + Kdelta_lambda) + + return outspec_padded[self._unpadded_inds] + + def compute_response(self, **extras): + if self.response is not None: + return self.response + else: + return 1.0 + + +class Lines(Observation): + + _kind = "lines" + alias = dict(spectrum="flux", + unc="uncertainty", + wavelength="wavelength", + mask="mask", + line_inds="line_ind") + + _meta = ("name", "kind") + _data = ("wavelength", "flux", "uncertainty", "mask", + "line_ind") + + def __init__(self, + line_ind=None, + line_names=None, + wavelength=None, + name=None, + **kwargs): + + """ + Parameters + ---------- + line_ind : iterable of int + The index of the lines in the FSPS spectral line array. + + wavelength : iterable of floats + The wavelength of each flux measurement, in vacuum AA + + flux : iterable of floats + The flux at each wavelength, in units of erg/s/cm^2, same length as + ``wavelength`` + + uncertainty : iterable of floats + The uncertainty on the flux + + resolution : (optional, default: None) + Instrumental resolution at each wavelength point in units of km/s + dispersion (:math:`= c \, \sigma_\lambda / \lambda = c \, \FWHM_\lambda / 2.355 / \lambda = c / (2.355 \, R_\lambda)` + where :math:`c=2.998e5 {\rm km}/{\rm s}` + + line_ind : iterable of string, optional + Names of the lines. + + :param calibration: + not sure yet .... + """ + super(Lines, self).__init__(name=name, **kwargs) + assert (line_ind is not None), "You must identify the lines by their index in the FSPS emission line array" + self.line_ind = np.array(line_ind).astype(int) + self.line_names = line_names + + if wavelength is not None: + self._wavelength = np.atleast_1d(wavelength) + else: + self._wavelength = None + + @property + def wavelength(self): + return self._wavelength + + +class UndersampledSpectrum(Spectrum): + """ + As for Spectrum, but account for pixelization effects when pixels + undersample the instrumental LSF. + """ + #TODO: Implement as a convolution with a square kernel (or sinc in frequency space) + + def _pixelize(self, outwave, inwave, influx): + return rebin(outwave, inwave, influx) + + +class IntrinsicSpectrum(Spectrum): + + """ + As for Spectrum, but predictions for this object type will not include + polynomial fitting or fitting of the emission line strengths (previously fit + and cached emission luminosities will be used.) + """ + + _kind = "intrinsic" + + +class PolyOptCal: + + """A mixin class that allows for optimization of a Chebyshev response + function given a model spectrum. + """ + + def __init__(self, *args, + polynomial_order=0, + polynomial_regularization=0, + median_polynomial=0, + **kwargs): + super(PolyOptCal, self).__init__(*args, **kwargs) + self.polynomial_order = polynomial_order + self.polynomial_regularization = polynomial_regularization + self.median_polynomial = median_polynomial + + def _available_parameters(self): + # These should both be attached to the Observation instance as attributes + pars = [("polynomial_order", "order of the polynomial to fit"), + ("polynomial_regularization", "vector of length `polyorder` providing regularization for each polynomial term"), + ("median_polynomial", "if > 0, median smooth with a kernel of width order/range/median_polynomial before fitting") + ] + + return pars + + def compute_response(self, spec=None, extra_mask=True, **kwargs): + """Implements a Chebyshev polynomial response function model. This uses + least-squares to find the maximum-likelihood Chebyshev polynomial of a + certain order describing the ratio of the observed spectrum to the model + spectrum. If emission lines are being marginalized out, they should be + excluded from the least-squares fit using the ``extra_mask`` keyword + + Parameters + ---------- + spec : ndarray of shape (Spectrum().ndata,) + The model spectrum on the observed wavelength grid + + extra_mask : ndarray of Booleans of shape (Spectrum().ndata,) + The extra mask to be applied. True=use data, False=mask data + + Returns + ------- + response : ndarray of shape (nwave,) + A polynomial given by :math:`\sum_{m=0}^M a_{m} * T_m(x)`. + """ + + order = self.polynomial_order + reg = self.polynomial_regularization + mask = self.mask & extra_mask + assert (self.mask.sum() > order), f"Not enough points to constrain polynomial of order {order}" + + polyopt = (order > 0) + if (not polyopt): + print("no polynomial") + self.response = np.ones_like(self.wavelength) + return self.response + + x = wave_to_x(self.wavelength, mask) + y = (self.flux / spec - 1.0)[mask] + yerr = (self.uncertainty / spec)[mask] + yvar = yerr**2 + + if self.median_polynomial > 0: + kernel_factor = self.median_polynomial + knl = int((x.max() - x.min()) / order / kernel_factor) + knl += int((knl % 2) == 0) + y = medfilt(y, knl) + + Afull = chebvander(x, order) + A = Afull[mask, :] + ATA = np.dot(A.T, A / yvar[:, None]) + if np.any(reg > 0): + ATA += reg**2 * np.eye(order+1) + c = np.linalg.solve(ATA, np.dot(A.T, y / yvar)) + + poly = np.dot(Afull, c) + + self._chebyshev_coefficients = c + self.response = poly + 1.0 + return self.response + + +class SplineOptCal: + + """A mixin class that allows for optimization of a Chebyshev response + function given a model spectrum. + """ + + + def __init__(self, *args, + spline_knot_wave=None, + spline_knot_spacing=None, + spline_knot_n=None, + **kwargs): + super(SplineOptCal, self).__init__(*args, **kwargs) + + self.params = {} + if spline_knot_wave is not None: + self.params["spline_knot_wave"] = spline_knot_wave + elif spline_knot_spacing is not None: + self.params["spline_knot_spacing"] = spline_knot_spacing + elif spline_knot_n is not None: + self.params["spline_knot_n"] = spline_knot_n + + # build and cache the knots + w = self.wavelength[self.mask] + (wlo, whi) = w.min(), w.min() + self.wave_x = 2.0 * (self.wavelength - wlo) / (whi - wlo) - 1.0 + self.knots_x = self.make_knots(wlo, whi, as_x=True, **self.params) + + + def _available_parameters(self): + pars = [("spline_knot_wave", "vector of wavelengths for the location of the spline knots"), + ("spline_knot_spacing", "spacing between knots, in units of wavelength"), + ("spline_knot_n", "number of interior knots between minimum and maximum unmasked wavelength") + ] + + return pars + + def compute_response(self, spec=None, extra_mask=True, **extras): + """Implements a spline response function model. This fits a cubic + spline with determined knot locations to the ratio of the observed + spectrum to the model spectrum. If emission lines are being + marginalized out, they are excluded from the least-squares fit. + + The knot locations must be specified as model parameters, either + explicitly or via a number of knots or knot spacing (in angstroms) + """ + mask = self.mask & extra_mask + + + splineopt = True + if ~splineopt: + self.response = np.ones_like(self.wavelength) + return self.response + + y = (self.flux / spec - 1.0)[mask] + yerr = (self.uncertainty / spec)[mask] # HACK + tck = splrep(self.wave_x[mask], y[mask], w=1/yerr[mask], k=3, task=-1, t=self.knots_x) + self._spline_coeffs = tck + spl = BSpline(*tck) + spline = spl(self.wave_x) + + self.response = (1.0 + spline) + return self.response + + def make_knots(self, wlo, whi, as_x=True, **params): + """Can we move this to instantiation? + """ + if "spline_knot_wave" in params: + knots = np.squeeze(params["spline_knot_wave"]) + elif "spline_knot_spacing" in params: + s = np.squeeze(params["spline_knot_spacing"]) + # we drop the start so knots are interior + knots = np.arange(wlo, whi, s)[1:] + elif "spline_knot_n" in params: + n = np.squeeze(params["spline_knot_n"]) + # we need to drop the endpoints so knots are interior + knots = np.linspace(wlo, whi, n)[1:-1] + else: + raise KeyError("No valid spline knot specification in self.params") + + if as_x: + knots = 2.0 * (knots - wlo) / (whi - wlo) - 1.0 + + return knots + + +class PolyFitCal: + + """This is a mixin class that generates the + multiplicative response vector as a Chebyshev polynomial described by the + ``poly_param_name`` parameter of the model, which may be free (fittable) + """ + + def __init__(self, *args, poly_param_name=None, **kwargs): + super(SplineOptCal, self).__init(*args, **kwargs) + self.poly_param_name = poly_param_name + + def _available_parameters(self): + pars = [(self.poly_param_name, "vector of polynomial chabyshev coefficients")] + + return pars + + def compute_response(self, **kwargs): + """Implements a Chebyshev polynomial calibration model. This only + occurs if ``"poly_coeffs"`` is present in the :py:attr:`params` + dictionary, otherwise the value of ``params["spec_norm"]`` is returned. + + :param theta: (optional) + If given, set :py:attr:`params` using this vector before + calculating the calibration polynomial. ndarray of shape + ``(ndim,)`` + + :param obs: + A dictionary of observational data, must contain the key + ``"wavelength"`` + + :returns cal: + If ``params["cal_type"]`` is ``"poly"``, a polynomial given by + :math:`\times (\Sum_{m=0}^M```'poly_coeffs'[m]``:math:` \times T_n(x))`. + """ + + if self.poly_param_name in kwargs: + mask = self.get('mask', slice(None)) + # map unmasked wavelengths to the interval -1, 1 + # masked wavelengths may have x>1, x<-1 + x = wave_to_x(self.wavelength, mask) + # get coefficients. + c = kwargs[self.poly_param_name] + poly = chebval(x, c) + else: + poly = 1.0 + + self.response = poly + return self.response + + +obstypes = dict(photometry=Photometry, + spectrum=Spectrum, + lines=Lines) + + +def from_oldstyle(obs, **kwargs): + """Convert from an oldstyle dictionary to a list of observations + """ + spec, phot = Spectrum(**obs), Photometry(**obs) + #phot.set_filters(phot.filters) + #[o.rectify() for o in obslist] + + return [spec, phot] + + +def from_serial(arr, meta): + # TODO: This does not account for composite or special classes, or include + # noise models + kind = obstypes[meta.pop("kind")] + + adict = {a:arr[a] for a in arr.dtype.names} + adict["name"] = meta.pop("name", "") + if 'filters' in meta: + adict["filters"] = meta.pop("filters").split(",") + + obs = kind(**adict) + + # set other metadata as attributes? No, needs to be during instantiation + #for k, v in meta.items(): + # if k in kind._meta: + # setattr(obs, k, v) + + return obs + + +def wave_to_x(wavelength=None, mask=slice(None), **extras): + """Map unmasked wavelengths to the interval -1, 1 + masked wavelengths may have x>1, x<-1 + """ + x = wavelength - (wavelength[mask]).min() + x = 2.0 * (x / (x[mask]).max()) - 1.0 + return x \ No newline at end of file diff --git a/prospect/utils/obsutils.py b/prospect/observation/obsutils.py similarity index 100% rename from prospect/utils/obsutils.py rename to prospect/observation/obsutils.py diff --git a/prospect/plotting/corner.py b/prospect/plotting/corner.py index 5c1cf794..93c9144b 100644 --- a/prospect/plotting/corner.py +++ b/prospect/plotting/corner.py @@ -305,7 +305,7 @@ def corner(samples, paxes, weights=None, span=None, smooth=0.02, :returns paxes: """ assert samples.ndim > 1 - assert np.product(samples.shape[1:]) > samples.shape[0] + assert np.prod(samples.shape[1:]) > samples.shape[0] ndim = len(samples) # Determine plotting bounds. diff --git a/prospect/plotting/sed.py b/prospect/plotting/sed.py index c0625bab..6a3c12de 100644 --- a/prospect/plotting/sed.py +++ b/prospect/plotting/sed.py @@ -3,7 +3,7 @@ import numpy as np -from ..utils.smoothing import smoothspec +from sedpy.smoothing import smoothspec __all__ = ["convolve_spec", "to_nufnu"] diff --git a/prospect/plotting/sfh.py b/prospect/plotting/sfh.py index 0c136fe2..cfde42d5 100644 --- a/prospect/plotting/sfh.py +++ b/prospect/plotting/sfh.py @@ -151,7 +151,7 @@ def parametric_cmf(times=None, tage=1., **sfh): if times is None: times = np.array(sfh["tage"]) - pset = parametric_pset(tage=tage**sfh) + pset = parametric_pset(tage=tage, **sfh) _, mass = compute_mass_formed(tage - times, pset) return mass diff --git a/prospect/plotting/utils.py b/prospect/plotting/utils.py index f5c13ad2..df08c489 100644 --- a/prospect/plotting/utils.py +++ b/prospect/plotting/utils.py @@ -4,9 +4,10 @@ import numpy as np from .corner import _quantile from ..models.priors import TopHat as Uniform +from ..utils.stats import get_best, best_sample - -__all__ = ["get_best", "best_sample", "get_simple_prior", "sample_prior", "sample_posterior", +__all__ = ["get_best", "best_sample", + "get_simple_prior", "sample_prior", "sample_posterior", "boxplot", "violinplot", "step"] @@ -92,42 +93,6 @@ def sample_posterior(chain, weights=None, nsample=int(1e4), return flatchain[inds, :], extra[inds, ...] -def get_best(res, **kwargs): - """Get the maximum a posteriori parameters and their names - - :param res: - A ``results`` dictionary with the keys 'lnprobability', 'chain', and - 'theta_labels' - - :returns theta_names: - List of strings giving the names of the parameters, of length ``ndim`` - - :returns best: - ndarray with shape ``(ndim,)`` of parameter values corresponding to the - sample with the highest posterior probaility - """ - theta_best = best_sample(res) - - try: - theta_names = res["theta_labels"] - except(KeyError): - theta_names = res["model"].theta_labels() - return theta_names, theta_best - - -def best_sample(res): - """Get the posterior sample with the highest posterior probability. - """ - imax = np.argmax(res['lnprobability']) - # there must be a more elegant way to deal with differnt shapes - try: - i, j = np.unravel_index(imax, res['lnprobability'].shape) - theta_best = res['chain'][i, j, :].copy() - except(ValueError): - theta_best = res['chain'][imax, :].copy() - return theta_best - - def violinplot(data, pos, widths, ax=None, violin_kwargs={"showextrema": False}, color="slateblue", alpha=0.5, span=None, **extras): diff --git a/prospect/sources/__init__.py b/prospect/sources/__init__.py index 7f7bd353..e4bccd8c 100644 --- a/prospect/sources/__init__.py +++ b/prospect/sources/__init__.py @@ -1,12 +1,4 @@ from .galaxy_basis import * -from .ssp_basis import * -from .star_basis import * -from .dust_basis import * -from .boneyard import StepSFHBasis -__all__ = ["to_cgs", - "CSPSpecBasis", "MultiComponentCSPBasis", - "FastSSPBasis", "SSPBasis", - "FastStepBasis", "StepSFHBasis", - "StarBasis", "BigStarBasis", - "BlackBodyDustBasis"] +__all__ = ["CSPSpecBasis", "SSPBasis", + "FastStepBasis"] diff --git a/prospect/sources/boneyard.py b/prospect/sources/boneyard.py deleted file mode 100644 index e090771a..00000000 --- a/prospect/sources/boneyard.py +++ /dev/null @@ -1,487 +0,0 @@ -import numpy as np -from scipy.special import expi, gammainc - -from .ssp_basis import SSPBasis - - -__all__ = ["CSPBasis", "StepSFHBasis", "CompositeSFH", "LinearSFHBasis"] - -# change base -from .constants import loge - - -class CSPBasis(object): - """ - A class for composite stellar populations, which can be composed from - multiple versions of parameterized SFHs. Deprecated, Use CSPSpecBasis instead. - """ - def __init__(self, compute_vega_mags=False, zcontinuous=1, vactoair_flag=False, **kwargs): - - # This is a StellarPopulation object from fsps - self.csp = fsps.StellarPopulation(compute_vega_mags=compute_vega_mags, - zcontinuous=zcontinuous, - vactoair_flag=vactoair_flag) - self.params = {} - - def get_spectrum(self, outwave=None, filters=None, peraa=False, **params): - """Given a theta vector, generate spectroscopy, photometry and any - extras (e.g. stellar mass). - - :param theta: - ndarray of parameter values. - - :param sps: - A python-fsps StellarPopulation object to be used for - generating the SED. - - :returns spec: - The restframe spectrum in units of maggies. - - :returns phot: - The apparent (redshifted) observed frame maggies in each of the - filters. - - :returns extras: - A list of the ratio of existing stellar mass to total mass formed - for each component, length ncomp. - """ - self.params.update(**params) - # Pass the model parameters through to the sps object - ncomp = len(self.params['mass']) - for ic in range(ncomp): - s, p, x = self.one_sed(component_index=ic, filterlist=filters) - try: - spec += s - maggies += p - extra += [x] - except(NameError): - spec, maggies, extra = s, p, [x] - # `spec` is now in Lsun/Hz, with the wavelength array being the - # observed frame wavelengths. Flux array (and maggies) have not been - # increased by (1+z) due to cosmological redshift - - w = self.ssp.wavelengths - if outwave is not None: - spec = np.interp(outwave, w, spec) - else: - outwave = w - # Distance dimming and unit conversion - zred = self.params.get('zred', 0.0) - if (zred == 0) or ('lumdist' in self.params): - # Use 10pc for the luminosity distance (or a number provided in the - # lumdist key in units of Mpc). Do not apply cosmological (1+z) - # factor to the flux. - dfactor = (self.params.get('lumdist', 1e-5) * 1e5)**2 - a = 1.0 - else: - # Use the comsological luminosity distance implied by this - # redshift. Cosmological (1+z) factor on the flux was already done in one_sed - lumdist = cosmo.luminosity_distance(zred).value - dfactor = (lumdist * 1e5)**2 - if peraa: - # spectrum will be in erg/s/cm^2/AA - spec *= to_cgs / dfactor * lightspeed / outwave**2 - else: - # Spectrum will be in maggies - spec *= to_cgs / dfactor / (3631*jansky_cgs) - - # Convert from absolute maggies to apparent maggies - maggies /= dfactor - - return spec, maggies, extra - - def one_sed(self, component_index=0, filterlist=[]): - """Get the SED of one component for a multicomponent composite SFH. - Should set this up to work as an iterator. - - :param component_index: - Integer index of the component to calculate the SED for. - - :param filterlist: - A list of strings giving the (FSPS) names of the filters onto which - the spectrum will be projected. - - :returns spec: - The restframe spectrum in units of Lsun/Hz. - - :returns maggies: - Broadband fluxes through the filters named in ``filterlist``, - ndarray. Units are observed frame absolute maggies: M = -2.5 * - log_{10}(maggies). - - :returns extra: - The extra information corresponding to this component. - """ - # Pass the model parameters through to the sps object, and keep track - # of the mass of this component - mass = 1.0 - for k, vs in list(self.params.items()): - try: - v = vs[component_index] - except(IndexError, TypeError): - v = vs - if k in self.csp.params.all_params: - self.csp.params[k] = deepcopy(v) - if k == 'mass': - mass = v - # Now get the spectrum. The spectrum is in units of - # Lsun/Hz/per solar mass *formed*, and is restframe - w, spec = self.csp.get_spectrum(tage=self.csp.params['tage'], peraa=False) - # redshift and get photometry. Note we are boosting fnu by (1+z) *here* - a, b = (1 + self.csp.params['zred']), 0.0 - wa, sa = w * (a + b), spec * a # Observed Frame - if filterlist is not None: - mags = getSED(wa, lightspeed/wa**2 * sa * to_cgs, filterlist) - phot = np.atleast_1d(10**(-0.4 * mags)) - else: - phot = 0.0 - - # now some mass normalization magic - mfrac = self.csp.stellar_mass - if np.all(self.params.get('mass_units', 'mstar') == 'mstar'): - # Convert input normalization units from per stellar masss to per mass formed - mass /= mfrac - # Output correct units - return mass * sa, mass * phot, mfrac - - -class StepSFHBasis(SSPBasis): - """Subclass of SSPBasis that computes SSP weights for piecewise constant - SFHs (i.e. a binned SFH). The parameters for this SFH are: - - * `agebins` - array of shape (nbin, 2) giving the younger and older (in - lookback time) edges of each bin. If `interp_type` is `"linear"', - these are assumed to be in years. Otherwise they are in log10(years) - - * `mass` - array of shape (nbin,) giving the total surviving stellar mass - (in solar masses) in each bin, unless the `mass_units` parameter is set - to something different `"mstar"`, in which case the units are assumed - to be total stellar mass *formed* in each bin. - - The `agebins` parameter *must not be changed* without also setting - `self._ages=None`. - """ - - @property - def all_ssp_weights(self): - # Cache age bins and relative weights. This means params['agebins'] - # *must not change* without also setting _ages = None - if getattr(self, '_ages', None) is None: - self._ages = self.params['agebins'] - nbin, nssp = len(self._ages), len(self.logage) + 1 - self._bin_weights = np.zeros([nbin, nssp]) - for i, (t1, t2) in enumerate(self._ages): - # These *should* sum to one (or zero) for each bin - self._bin_weights[i, :] = self.bin_weights(t1, t2) - - # Now normalize the weights in each bin by the mass parameter, and sum - # over bins. - bin_masses = self.params['mass'] - if np.all(self.params.get('mass_units', 'mformed') == 'mstar'): - # Convert from mstar to mformed for each bin. We have to do this - # here as well as in get_spectrum because the *relative* - # normalization in each bin depends on the units, as well as the - # overall normalization. - bin_masses /= self.bin_mass_fraction - w = (bin_masses[:, None] * self._bin_weights).sum(axis=0) - - return w - - @property - def bin_mass_fraction(self): - """Return the ratio m_star(surviving) / m_formed for each bin. - """ - try: - mstar = self.ssp_stellar_masses - w = self._bin_weights - bin_mfrac = (mstar[None, :] * w).sum(axis=-1) / w.sum(axis=-1) - return bin_mfrac - except(AttributeError): - print('agebin info or ssp masses not chached?') - return 1.0 - - def bin_weights(self, amin, amax): - """Compute normalizations required to get a piecewise constant SFH - within an age bin. This is super complicated and obscured. The output - weights are such that one solar mass will have formed during the bin - (i.e. SFR = 1/(amax-amin)) - - This computes weights using \int_tmin^tmax dt (\log t_i - \log t) / - (\log t_{i+1} - \log t_i) but see sfh.tex for the detailed calculation - and the linear time interpolation case. - """ - if self.interp_type == 'linear': - sspages = np.insert(10**self.logage, 0, 0) - func = constant_linear - mass = amax - amin - elif self.interp_type == 'logarithmic': - sspages = np.insert(self.logage, 0, self.mint_log) - func = constant_logarithmic - mass = 10**amax - 10**amin - - assert amin >= sspages[0] - assert amax <= sspages.max() - - # below could be done by using two separate dt vectors instead of two - # age vectors - ages = np.array([sspages[:-1], sspages[1:]]) - dt = np.diff(ages, axis=0) - tmin, tmax = np.clip(ages, amin, amax) - - # get contributions from SSP sub-bin to the left and from SSP sub-bin - # to the right - left, right = (func(ages, tmax) - func(ages, tmin)) / dt - # put into full array - ww = np.zeros(len(sspages)) - ww[:-1] += right # last element has no sub-bin to the right - ww[1:] += -left # need to flip sign - - # normalize to 1 solar mass formed and return - return ww / mass - - -class CompositeSFH(SSPBasis): - """Subclass of SSPBasis that computes SSP weights for a parameterized SF. - The parameters for this SFH are: - - * `sfh_type` - String of "delaytau", "tau", "simha" - - * `tage`, `sf_trunc`, `sf_slope`, `const`, `fburst`, `tau` - - * `mass` - - - """ - - def configure(self): - """This reproduces FSPS-like combinations of SFHs. Note that the - *same* parameter set is passed to each component in the combination - """ - sfhs = [self.sfh_type] - limits = len(sfhs) * ['regular'] - if 'simha' in self.sfh_type: - sfhs = ['delaytau', 'linear'] - limits = ['regular', 'simha'] - - fnames = ['{0}_{1}'.format(f, self.interp_type) for f in sfhs] - lnames = ['{}_limits'.format(f) for f in limits] - self.funcs = [globals()[f] for f in fnames] - self.limits = [globals()[f] for f in lnames] - - if self.interp_type == 'linear': - sspages = np.insert(10**self.logage, 0, 0) - elif self.interp_type == 'logarithmic': - sspages = np.insert(self.logage, 0, self.mint_log) - self.ages = np.array([sspages[:-1], sspages[1:]]) - self.dt = np.diff(self.ages, axis=0) - - @property - def _limits(self): - pass - - @property - def _funcs(self): - pass - - @property - def all_ssp_weights(self): - - # Full output weight array. We keep separate vectors for each - # component so we can renormalize after the loop, but for many - # components it would be better to renormalize and sum within the loop - ww = np.zeros([len(self.funcs), self.ages.shape[-1] + 1]) - - # Loop over components. Note we are sending the same params to every component - for i, (limit, func) in enumerate(zip(self.limits, self.funcs)): - ww[i, :] = self.ssp_weights(func, limit, self.params) - - # renormalize each component to 1 Msun - assert np.all(ww >= 0) - wsum = ww.sum(axis=1) - # unless truly no SF in the component - if 0 in wsum: - wsum[wsum == 0] = 1.0 - ww /= wsum[:, None] - # apply relative normalizations - ww *= self.normalizations(**self.params)[:, None] - # And finally add all components together and renormalize again to - # 1Msun and return - return ww.sum(axis=0) / ww.sum() - - def ssp_weights(self, integral, limit_function, params, **extras): - # build full output weight vector - ww = np.zeros(self.ages.shape[-1] + 1) - tmin, tmax = limit_function(self.ages, mint_log=self.mint_log, - interp_type=self.interp_type, **params) - left, right = (integral(self.ages, tmax, **params) - - integral(self.ages, tmin, **params)) / self.dt - # Put into full array, shifting the `right` terms by 1 element - ww[:-1] += right # last SSP has no sub-bin to the right - ww[1:] += -left # need to flip sign - - # Note that now ww[i,1] = right[1] - left[0], where - # left[0] is the integral from tmin,0 to tmax,0 of - # SFR(t) * (sspages[0] - t)/(sspages[1] - sspages[0]) and - # right[1] is the integral from tmin,1 to tmax,1 of - # SFR(t) * (sspages[2] - t)/(sspages[2] - sspages[1]) - return ww - - def normalizations(self, tage=0., sf_trunc=0, sf_slope=0, const=0, - fburst=0, tau=0., **extras): - if (sf_trunc <= 0) or (sf_trunc > tage): - Tmax = tage - else: - Tmax = sf_trunc - # Tau models. SFH=1 -> power=1; SFH=4,5 -> power=2 - if ('delay' in self.sfh_type) or ('simha' in self.sfh_type): - power = 2. - else: - power = 1. - mass_tau = tau * gammainc(power, Tmax/tau) - - if 'simha' not in self.sfh_type: - return np.array([mass_tau]) - # SFR at Tmax - sfr_q = (Tmax/tau)**(power-1) * np.exp(-Tmax/tau) - - # linear. integral of (1 - m * (T - Tmax)) from Tmax to Tzero - if sf_slope == 0.: - Tz = tage - else: - Tz = Tmax + 1/np.float64(sf_slope) - if (Tz < Tmax) or (Tz > tage) or (not np.isfinite(Tz)): - Tz = tage - m = sf_slope - mass_linear = (Tz - Tmax) - m/2.*(Tz**2 + Tmax**2) + m*Tz*Tmax - - # normalize the linear portion relative to the tau portion - norms = np.array([1, mass_linear * sfr_q / mass_tau]) - norms /= norms.sum() - # now add in constant and burst - if (const > 0) or (fburst > 0): - norms = (1-fburst-const) * norms - norms.tolist().extend([const, fburst]) - return np.array(norms) - - -class LinearSFHBasis(SSPBasis): - """Subclass of SSPBasis that computes SSP weights for piecewise linear SFHs - (i.e. a linearly interpolated tabular SFH). The parameters for this SFH - are: - * `ages` - array of shape (ntab,) giving the lookback time of each - tabulated SFR. If `interp_type` is `"linear"', these are assumed to be - in years. Otherwise they are in log10(years) - * `sfr` - array of shape (ntab,) giving the SFR (in Msun/yr) - * `logzsol` - * `dust2` - """ - def get_galaxy_spectrum(self): - raise(NotImplementedError) - - -def regular_limits(ages, tage=0., sf_trunc=0., mint_log=-3, - interp_type='logarithmic', **extras): - # get the truncation time in units of lookback time - if (sf_trunc <= 0) or (sf_trunc > tage): - tq = 0 - else: - tq = tage - sf_trunc - if interp_type == 'logarithmic': - tq = np.log10(np.max([tq, 10**mint_log])) - tage = np.log10(np.max([tage, 10**mint_log])) - return np.clip(ages, tq, tage) - - -def simha_limits(ages, tage=0., sf_trunc=0, sf_slope=0., mint_log=-3, - interp_type='logarithmic', **extras): - # get the truncation time in units of lookback time - if (sf_trunc <= 0) or (sf_trunc > tage): - tq = 0 - else: - tq = tage - sf_trunc - t0 = tq - 1. / np.float64(sf_slope) - if (t0 > tq) or (t0 <= 0) or (not np.isfinite(t0)): - t0 = 0. - if interp_type == 'logarithmic': - tq = np.log10(np.max([tq, 10**mint_log])) - t0 = np.log10(np.max([t0, 10**mint_log])) - return np.clip(ages, t0, tq) - - -def constant_linear(ages, t, **extras): - """Indefinite integral for SFR = 1 - - :param ages: - Linear age(s) of the SSPs. - - :param t: - Linear time at which to evaluate the indefinite integral - """ - return ages * t - t**2 / 2 - - -def constant_logarithmic(logages, logt, **extras): - """SFR = 1 - """ - t = 10**logt - return t * (logages - logt + loge) - - -def tau_linear(ages, t, tau=None, **extras): - """SFR = e^{(tage-t)/\tau} - """ - return (ages - t + tau) * np.exp(t / tau) - - -def tau_logarithmic(logages, logt, tau=None, **extras): - """SFR = e^{(tage-t)/\tau} - """ - tprime = 10**logt / tau - return (logages - logt) * np.exp(tprime) + loge * expi(tprime) - - -def delaytau_linear(ages, t, tau=None, tage=None, **extras): - """SFR = (tage-t) * e^{(tage-t)/\tau} - """ - bracket = tage * ages - (tage + ages)*(t - tau) + t**2 - 2*t*tau + 2*tau**2 - return bracket * np.exp(t / tau) - - -def delaytau_logarithmic(logages, logt, tau=None, tage=None, **extras): - """SFR = (tage-t) * e^{(tage-t)/\tau} - """ - t = 10**logt - tprime = t / tau - a = (t - tage - tau) * (logt - logages) - tau * loge - b = (tage + tau) * loge - return a * np.exp(tprime) + b * expi(tprime) - - -def linear_linear(ages, t, tage=None, sf_trunc=0, sf_slope=0., **extras): - """SFR = [1 - sf_slope * (tage-t)] - """ - tq = np.max([0, tage-sf_trunc]) - k = 1 - sf_slope * tq - return k * ages * t + (sf_slope*ages - k) * t**2 / 2 - sf_slope * t**3 / 3 - - -def linear_logarithmic(logages, logt, tage=None, sf_trunc=0, sf_slope=0., **extras): - """SFR = [1 - sf_slope * (tage-t)] - """ - tq = np.max([0, tage-sf_trunc]) - t = 10**logt - k = 1 - sf_slope * tq - term1 = k * t * (logages - logt + loge) - term2 = sf_slope * t**2 / 2 * (logages - logt + loge / 2) - return term1 + term2 - - -def burst_linear(ages, t, tburst=None, **extras): - """Burst. SFR = \delta(t-t_burst) - """ - return ages - tburst - - -def burst_logarithmic(logages, logt, tburst=None, **extras): - """Burst. SFR = \delta(t-t_burst) - """ - return logages - np.log10(tburst) diff --git a/prospect/sources/dust_basis.py b/prospect/sources/dust_basis.py deleted file mode 100644 index 8e478bf7..00000000 --- a/prospect/sources/dust_basis.py +++ /dev/null @@ -1,104 +0,0 @@ -import numpy as np - -try: - from sedpy.observate import getSED -except(ImportError): - pass - -__all__ = ["BlackBodyDustBasis"] - -# cgs constants -from .constants import lsun, pc, kboltz, hplanck -lightspeed = 29979245800.0 - - -class BlackBodyDustBasis(object): - """ - """ - def __init__(self, **kwargs): - self.dust_parlist = ['mass', 'T', 'beta', 'kappa0', 'lambda0'] - self.params = {} - self.params.update(**kwargs) - self.default_wave = np.arange(1000) # in microns - - def get_spectrum(self, outwave=None, filters=None, **params): - """Given a params dictionary, generate spectroscopy, photometry and any - extras (e.g. stellar mass). - - :param outwave: - The output wavelength vector. - - :param filters: - A list of sedpy filter objects. - - :param params: - Keywords forming the parameter set. - - :returns spec: - The restframe spectrum in units of erg/s/cm^2/AA - - :returns phot: - The apparent (redshifted) maggies in each of the filters. - - :returns extras: - A list of None type objects, only included for consistency with the - SedModel class. - """ - self.params.update(**params) - if outwave is None: - outwave = self.default_wave - # Loop over number of MBBs - ncomp = len(self.params['mass']) - seds = [self.one_sed(icomp=ic, wave=outwave, filters=filters) - for ic in range(ncomp)] - # sum the components - spec = np.sum([s[0] for s in seds], axis=0) - maggies = np.sum([s[1] for s in seds], axis=0) - extra = [s[2] for s in seds] - - norm = self.normalization() - spec, maggies = norm * spec, norm * maggies - return spec, maggies, extra - - def one_sed(self, icomp=0, wave=None, filters=None, **extras): - """Pull out individual component parameters from the param dictionary - and generate spectra for those components - """ - cpars = {} - for k in self.dust_parlist: - try: - cpars[k] = np.squeeze(self.params[k][icomp]) - except(IndexError, TypeError): - cpars[k] = np.squeeze(self.params[k]) - - spec = cpars['mass'] * modified_BB(wave, **cpars) - phot = 10**(-0.4 * getSED(wave*1e4, spec, filters)) - return spec, phot, None - - def normalization(self): - """This method computes the normalization (due do distance dimming, - unit conversions, etc.) based on the content of the params dictionary. - """ - return 1 - - -def modified_BB(wave, T=20, beta=2.0, kappa0=1.92, lambda0=350, **extras): - """Return a modified blackbody. - - the normalization of the emissivity curve can be given as kappa0 and - lambda0 in units of cm^2/g and microns, default = (1.92, 350). Ouput units - are erg/s/micron/g. - """ - term = (lambda0 / wave)**beta - return planck(wave, T=T, **extras) * term * kappa0 - - -def planck(wave, T=20.0, **extras): - """Return planck function B_lambda (erg/s/micron) for a given T (in Kelvin) and - wave (in microns) - """ - # Return B_lambda in erg/s/micron - w = wave * 1e4 #convert from microns to cm - conv = 2 * hplank * lightspeed**2 / w**5 / 1e4 - denom = (np.exp(hplanck * lightspeed / (kboltz * T)) - 1) - return conv / denom diff --git a/prospect/sources/fake_fsps.py b/prospect/sources/fake_fsps.py index 0a31b457..1b9339cc 100644 --- a/prospect/sources/fake_fsps.py +++ b/prospect/sources/fake_fsps.py @@ -1,9 +1,29 @@ import numpy as np +import os +import dill as pickle +from pkg_resources import resource_filename -__all__ = ["add_dust", "add_igm"] +import jax.numpy as jnp +import jax +from pathlib import Path -def add_dust(wave,specs,line_waves,lines,dust_type=0,dust_index=0.0,dust2=0.0,dust1_index=0.0,dust1=0.0,**kwargs): + +# index for the 128 emulated emission lines in fsps new line list +idx = np.array([0,1,2,3,4,5,6,9,13,14,15,16,17,18,19,20,21,22,23,24,24,25,26,28,29,30,31, + 32,34,35,37,38,39,40,41,43,44,45,46,47,48,49,50,51,52,53,54,57,59,61,62, + 63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,84,85,86,87,88,89, + 90,91,92,93,94,95,96,97,100,101,101,102,103,104,105,106,107,108,111,112, + 114,116,118,119,122,123,125,127,129,130,134,137,139,140,143,145,146,148, + 151,152,153,154,155,156,157,158,159,160,161,162,163,164,165]) + +out = pickle.load(open(resource_filename("cuejax", "data/nn_stats_v0.pkl"), "rb")) +frac_line_err = 1./out['SN_quantile'][1][np.argsort(out['wav'])] # 1 / upper 2 sigma quantike of SN of the cue test set + +__all__ = ["add_dust", "add_igm", "DustEmission"] + + +def add_dust(wave,specs,line_waves,lines,dust_type=0,dust_index=-0.7,dust2=0.0,dust1_index=-1.0,dust1=0.0,**kwargs): """ wave: wavelength vector in Angstroms specs: spectral flux density, in (young, old) pairs @@ -11,21 +31,37 @@ def add_dust(wave,specs,line_waves,lines,dust_type=0,dust_index=0.0,dust2=0.0,du lines: emission line flux density, in (young, old) pairs """ + attenuated_specs = np.zeros_like(specs) + attenuated_lines = np.zeros_like(lines) + # Loop over the (young,old) pairs for both lines and continuum for i, (spec, line) in enumerate(zip(specs,lines)): - if (i == 0): + if (i == 0): d1 = dust1 else: d1 = 0.0 - spec = attenuate(spec,wave,dust_type=dust_type,dust_index=dust_index,dust2=dust2,dust1=d1) - line = attenuate(line,line_waves,dust_type=dust_type,dust_index=dust_index,dust2=dust2,dust1=d1) - - return specs, lines - - -def attenuate(spec,lam,dust_type=0,dust_index=0.0,dust2=0.0,dust1_index=0.0,dust1=0.0): - """returns F(obs) / F(emitted) for a given attenuation curve + dust1 + dust2 + attenuated_lines[i], diff_dust = attenuate(line,line_waves,dust_type=dust_type,dust_index=dust_index,dust2=dust2,dust1_index=dust1_index,dust1=d1) + attenuated_specs[i], diff_dust = attenuate(spec,wave,dust_type=dust_type,dust_index=dust_index,dust2=dust2,dust1_index=dust1_index,dust1=d1) + + attenuated_specs = attenuated_specs[0] + attenuated_specs[1] + attenuated_lines = attenuated_lines[0] + attenuated_lines[1] + +# if kwargs.get("add_dust_emission", None): +# dust_specs = DustEmission(dust_file = os.getenv('SPS_HOME'), +# spec_lambda = wave, **kwargs).compute_dust_emission( +# attenuated_specs, specs[0]+specs[1], wave, +# diff_dust, attenuated_lines, lines[0]+lines[1])[0] +# return dust_specs, attenuated_lines + +# else: +# return attenuated_specs, attenuated_lines + return attenuated_specs, attenuated_lines + + + +def attenuate(spec,lam,dust_type=0,dust_index=-0.7,dust2=0.0,dust1_index=0.0,dust1=0.0): + """returns F(obs) for a given attenuation curve + dust1 + dust2 """ ### constants from FSPS @@ -116,15 +152,16 @@ def attenuate(spec,lam,dust_type=0,dust_index=0.0,dust2=0.0,dust1_index=0.0,dust reddy = reddy/2.505 attn_curve = dust2*reddy - + dust1_ext = np.exp(-dust1*(lam/5500.)**dust1_index) dust2_ext = np.exp(-attn_curve) ext_tot = dust2_ext*dust1_ext - - return ext_tot*spec -def add_igm(wave, spec, zred=None, igm_factor=None, add_igm_absorption=None): + return ext_tot*spec, dust2_ext + + +def add_igm(wave, spec, zred=0., igm_factor=1.0, add_igm_absorption=None, **kwargs): """IGM absorption based on Madau+1995 wave: rest-frame wavelength spec: spectral flux density @@ -135,22 +172,22 @@ def add_igm(wave, spec, zred=None, igm_factor=None, add_igm_absorption=None): if add_igm_absorption == False: return spec - wave = np.asarray(wave) * (1+zred) + redshifted_wave = np.asarray(wave) * (1+zred) lylim = 911.75 lyw = np.array([1215.67, 1025.72, 972.537, 949.743, 937.803, 930.748, 926.226, 923.150, 920.963, 919.352,918.129, 917.181, 916.429, 915.824, 915.329, 914.919, 914.576]) lycoeff = np.array([0.0036,0.0017,0.0011846,0.0009410,0.0007960,0.0006967,0.0006236,0.0005665,0.0005200,0.0004817,0.0004487, 0.0004200,0.0003947,0.000372,0.000352,0.0003334,0.00031644]) nly = len(lyw) - tau_line = np.zeros_like(wave) + tau_line = np.zeros_like(redshifted_wave) for i in range(nly): lmin = lyw[i] lmax = lyw[i]*(1.0+zred) - idx0 = np.where((wave>=lmin) & (wave<=lmax))[0] - tau_line[idx0] += lycoeff[i]*np.exp(3.46*np.log(wave[idx0]/lyw[i])) + idx0 = np.where((redshifted_wave>=lmin) & (redshifted_wave<=lmax))[0] + tau_line[idx0] += lycoeff[i]*np.exp(3.46*np.log(redshifted_wave[idx0]/lyw[i])) - xc = wave/lylim + xc = redshifted_wave/lylim xem = 1.0+zred idx = np.where(xc<1.0)[0] @@ -167,4 +204,272 @@ def add_igm(wave, spec, zred=None, igm_factor=None, add_igm_absorption=None): # attenuate the input spectrum by the IGM # include a fudge factor to dial up/down the strength - return spec*np.exp(-tau*factor) + res = spec*np.exp(-tau*igm_factor) + tiny_number = 10**(-70.0) + return np.clip(res, a_min=tiny_number, a_max=None) + + +class DustEmission: + + def __init__(self, duste_model="DL07", + dust_file=None, spec_lambda=None, **kwargs): + """ + Initialize the DustEmission object with parameters for dust emission modeling. + + Parameters + ---------- + duste_model : str + Dust emission model to use: 'DL07' or 'THEMIS'. + dust_file : str + Path to the dust emission file (required). + spec_lambda : ndarray + Wavelength grid over which dust emission will be evaluated (required). + kwargs : dict + Optional keyword arguments to override default dust parameters. + Supported: duste_qpah, duste_umin, duste_gamma + """ + + # Store model choice (e.g., 'DL07' or 'THEMIS') + self.duste_model = duste_model + + # Dust parameter values + self.duste_qpah = None + self.duste_umin = None + self.duste_gamma = None + + # Model grid arrays for allowed values of qPAH and Umin + self.qpaharr = None + self.uminarr = None + + # Placeholder for loaded dust emission spectra + self.dustem2_dustem = None + + # File path and wavelength grid + self.dust_file = None + self.spec_lambda = None + + # Store any additional keyword arguments for later use + self.dwargs = kwargs + + # Read in key dust parameters, allowing overrides via kwargs + self.duste_qpah = kwargs.pop("duste_qpah", 1.1) + self.duste_umin = kwargs.pop("duste_umin", 0.72) + self.duste_gamma = kwargs.pop("duste_gamma", 0.5) + + # Set parameter grids based on selected dust model + if self.duste_model == "DL07": + self.qpaharr = jnp.array([0.47, 1.12, 1.77, 2.50, 3.19, 3.90, 4.58]) + self.uminarr = jnp.array([ + 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.7, 0.8, 1.0, 1.2, 1.5, 2.0, + 2.5, 3.0, 4.0, 5.0, 7.0, 8.0, 12.0, 15.0, 20.0, 25.0 + ]) + elif self.duste_model == "THEMIS": + # THEMIS model uses smaller qPAH values rescaled to percent + self.qpaharr = jnp.array([0.02, 0.06, 0.10, 0.14, 0.17, 0.20, 0.24, + 0.28, 0.32, 0.36, 0.40]) / 2.2 * 100 + self.uminarr = jnp.array([ + 0.1, 0.12, 0.15, 0.17, 0.2, 0.25, 0.3, 0.35, 0.4, 0.5, 0.6, + 0.7, 0.8, 1.0, 1.2, 1.5, 1.7, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, + 6.0, 7.0, 8.0, 10.0, 12.0, 15.0, 17.0, 20.0, 25.0, 30.0, + 35.0, 40.0, 50.0, 80.0 + ]) + else: + raise ValueError("Invalid duste_model. Choose 'DL07' or 'THEMIS'.") + + # Ensure that necessary data is provided + if dust_file is None or spec_lambda is None: + raise ValueError("If `duste=True`, both `dust_file` and `spec_lambda` must be provided.") + + self.dust_file = dust_file + self.spec_lambda = spec_lambda + + # Load emission templates or model data from file + self.load_dust_emission(dust_file, spec_lambda) + + def __repr__(self): + """ + Custom string representation of the DustEmission object. + Provides a readable summary of model settings and parameters. + """ + + def format_array(arr): + """Helper to format short arrays inline; longer arrays multiline.""" + if arr is None: + return "None" + if arr.ndim == 1 and len(arr) <= 5: + return f"[{', '.join(map(str, arr))}]" + return f"\n " + "\n ".join(map(str, arr)) + + attributes = { + "Duste model": self.duste_model, + "DUST qPAH": self.duste_qpah, + "DUST Umin": self.duste_umin, + "DUST Gamma": self.duste_gamma, + f"qpaharr ({self.duste_model})": format_array(self.qpaharr), + f"uminarr ({self.duste_model})": format_array(self.uminarr), + "dust_file": self.dust_file, + "spec_lambda": self.spec_lambda.shape if self.spec_lambda is not None else None, + } + + if self.dwargs: + attributes["Extra parameters (dwargs)"] = self.dwargs + + attr_str = "\n".join(f" {k:<30}: {v}" for k, v in attributes.items() if v is not None) + return f"\nDustEmission Model:\n{'='*50}\n{attr_str}\n{'='*50}" + + def load_dust_emission(self, dust_file=None, spec_lambda=None): + + # Use default paths if not provided + if dust_file is None: + dust_file = self.dust_file + if spec_lambda is None: + spec_lambda = self.spec_lambda + + # Select dust model parameters + dust_model_params = { + "DL07": (7, 1001, 22), + "THEMIS": (11, 576, 37), + } + + if self.duste_model not in dust_model_params: + raise ValueError("Invalid duste_model. Choose 'DL07' or 'THEMIS'.") + + nqpah_dustem, ndim_dustem, numin_dustem = dust_model_params[self.duste_model] + + # Initialize storage for interpolated spectra (JAX-compatible) + dustem2_dustem = np.zeros((len(spec_lambda), nqpah_dustem, numin_dustem * 2)) + + # Read and interpolate dust emission spectra + for k in range(nqpah_dustem): + filename = Path(dust_file) / "dust" / "dustem" / f"{self.duste_model}_MW3.1_{'100' if k == 10 else f'{k}0'}.dat" + + if not filename.exists(): + raise FileNotFoundError(f"Error opening dust emission file: {filename}. File does not exist.") + + with filename.open('r') as f: + next(f) # Skip first header line + next(f) # Skip second header line + + lambda_dustem = np.zeros(ndim_dustem) + dustem_dustem = np.zeros((ndim_dustem, numin_dustem * 2)) + + for i in range(ndim_dustem): + try: + values = list(map(float, f.readline().strip().split())) + lambda_dustem[i], dustem_dustem[i, :] = values[0], values[1:] + except Exception: + raise RuntimeError(f"Error reading dust emission file: {filename}") + + # Convert wavelength from microns to Angstroms + lambda_dustem *= 1E4 + + # Interpolate dust spectra onto the master wavelength array + jj = jnp.searchsorted(spec_lambda / 1E4, 1, side='left') + for j in range(numin_dustem * 2): + dustem2_dustem[jj:, k, j] = jnp.interp(spec_lambda[jj:], lambda_dustem, dustem_dustem[:, j]) + + self.dustem2_dustem = jnp.array(dustem2_dustem) + + def compute_dust_emission(self, specdust, csp_spectra, spec_lambda, diff_dust, + linedust, line, + duste_qpah=None, duste_umin=None, duste_gamma=None): + """ + Compute dust emission using JAX-optimized vectorization for GPU acceleration. + + Parameters: + specdust (jnp.ndarray): Attenuated spectrum after dust absorption. + csp_spectra (jnp.ndarray): Stellar spectrum before attenuation. + spec_lambda (jnp.ndarray): Wavelength array in Angstroms. + duste_qpah (float, optional): PAH fraction. Defaults to self.duste_qpah if None. + duste_umin (float, optional): Minimum U radiation field. Defaults to self.duste_umin if None. + duste_gamma (float, optional): Fraction of high U component. Defaults to self.duste_gamma if None. + + Returns: + tuple: (Updated spectrum with dust emission added, Estimated dust mass) + """ + + # Use provided parameters if given, otherwise fallback to self attributes + duste_qpah = duste_qpah if duste_qpah is not None else self.duste_qpah + duste_umin = duste_umin if duste_umin is not None else self.duste_umin + duste_gamma = duste_gamma if duste_gamma is not None else self.duste_gamma + + + # Compute total luminosity before and after attenuation + nu = 2.9979E18 / spec_lambda # Frequency in Hz (c / λ) + lbold = jax.scipy.integrate.trapezoid(nu * specdust, -nu) + jnp.sum(linedust) # L_bol after attenuation + lboln = jax.scipy.integrate.trapezoid(nu * csp_spectra, -nu) + jnp.sum(line) # L_bol before attenuation + + # Interpolation indices for PAH fraction and Umin + qlo = jnp.clip(jnp.searchsorted(self.qpaharr, duste_qpah) - 1, 0, len(self.qpaharr) - 2) + dq = jnp.clip((duste_qpah - self.qpaharr[qlo]) / (self.qpaharr[qlo + 1] - self.qpaharr[qlo]), 0.0, 1.0) + ulo = jnp.clip(jnp.searchsorted(self.uminarr, duste_umin) - 1, 0, len(self.uminarr) - 2) + du = jnp.clip((duste_umin - self.uminarr[ulo]) / (self.uminarr[ulo + 1] - self.uminarr[ulo]), 0.0, 1.0) + + + # Ensure gamma fraction is within [0,1] + gamma = jnp.clip(duste_gamma, 0.0, 1.0) + + # Perform bilinear interpolation over qpah and Umin using `vmap` + def interpolate_dustem(i): + return ( + (1 - dq) * (1 - du) * self.dustem2_dustem[i, qlo, 2 * ulo - 1] + + dq * (1 - du) * self.dustem2_dustem[i, qlo + 1, 2 * ulo - 1] + + dq * du * self.dustem2_dustem[i, qlo + 1, 2 * (ulo + 1) - 1] + + (1 - dq) * du * self.dustem2_dustem[i, qlo, 2 * (ulo + 1) - 1] + ), ( + (1 - dq) * (1 - du) * self.dustem2_dustem[i, qlo, 2 * ulo] + + dq * (1 - du) * self.dustem2_dustem[i, qlo + 1, 2 * ulo] + + dq * du * self.dustem2_dustem[i, qlo + 1, 2 * (ulo + 1)] + + (1 - dq) * du * self.dustem2_dustem[i, qlo, 2 * (ulo + 1)] + ) + + dumin, dumax = jax.vmap(interpolate_dustem)(jnp.arange(len(spec_lambda))) + + # Compute dust emission spectrum + mduste = (1 - gamma) * dumin + gamma * dumax + mduste = jnp.maximum(mduste, 1e-70) + + # Normalize to absorbed luminosity + labs = lboln - lbold # Energy absorbed by dust + norm = jax.scipy.integrate.trapezoid(nu * mduste, -nu) # Normalization factor + duste = mduste / norm * labs # Normalize dust emission + duste = jnp.maximum(duste, 1e-70) + + # Iterative correction for dust self-absorption using `jax.lax.while_loop` + # sometimes this was stalled; use python while function instead +# def cond_fn(state): +# lbold, lboln, _ = state +# return jnp.abs(lboln - lbold) > 1e-2 + +# def body_fn(state): +# lbold, lboln, tduste = state +# oduste = duste +# duste_att = duste * diff_dust # Apply diffuse attenuation, duste *jnp.exp(-self.diffuse_tau) +# tduste = tduste + duste_att + +# lbold = jax.scipy.integrate.trapezoid(nu * duste_att, -nu) # Update L_bol after self-absorption +# lboln = jax.scipy.integrate.trapezoid(nu * oduste, -nu) # Before self-absorption + +# duste = jnp.maximum(mduste / norm * (lboln - lbold), 1e-70) +# return lbold, lboln, tduste + +# _, _, tduste = jax.lax.while_loop(cond_fn, body_fn, (lbold, lboln, jnp.zeros_like(duste))) + + tduste = jnp.zeros_like(duste) + while jnp.abs(lboln - lbold) > 1e-2: + oduste = duste + duste_att = duste * diff_dust # Apply diffuse attenuation, duste *jnp.exp(-self.diffuse_tau) + tduste = tduste + duste_att + + lbold = jax.scipy.integrate.trapezoid(nu * duste_att, -nu) # Update L_bol after self-absorption + lboln = jax.scipy.integrate.trapezoid(nu * oduste, -nu) # Before self-absorption + + duste = jnp.maximum(mduste / norm * (lboln - lbold), 1e-70) + + # Compute estimated dust mass + mdust = 3.21E-3 / (4 * jnp.pi) * labs / norm + + # Add dust emission to the stellar spectrum + specdust = specdust + tduste + + return specdust, mdust \ No newline at end of file diff --git a/prospect/sources/galaxy_basis.py b/prospect/sources/galaxy_basis.py index 6333f2ca..fb967446 100644 --- a/prospect/sources/galaxy_basis.py +++ b/prospect/sources/galaxy_basis.py @@ -2,21 +2,247 @@ import numpy as np from copy import deepcopy -from .ssp_basis import SSPBasis -from ..utils.smoothing import smoothspec -from .constants import cosmo, lightspeed, jansky_cgs, to_cgs_at_10pc - try: import fsps - from sedpy.observate import getSED, vac2air, air2vac except(ImportError, RuntimeError): pass -__all__ = ["CSPSpecBasis", "MultiComponentCSPBasis", - "to_cgs"] +__all__ = ["SSPBasis", "FastStepBasis", + "CSPSpecBasis"] + + +class SSPBasis(object): + + """This is a class that wraps the fsps.StellarPopulation object, which is + used for producing SSPs. The ``fsps.StellarPopulation`` object is accessed + as ``SSPBasis().ssp``. + + This class allows for the custom calculation of relative SSP weights (by + overriding ``all_ssp_weights``) to produce spectra from arbitrary composite + SFHs. Alternatively, the entire ``get_galaxy_spectrum`` method can be + overridden to produce a galaxy spectrum in some other way, for example + taking advantage of weight calculations within FSPS for tabular SFHs or for + parameteric SFHs. + + The base implementation here produces an SSP interpolated to the age given + by ``tage``, with initial mass given by ``mass``. However, this is much + slower than letting FSPS calculate the weights, as implemented in + :py:class:`FastSSPBasis`. + + Furthermore, smoothing, redshifting, and filter projections are handled + outside of FSPS, allowing for fast and more flexible algorithms. + + :param reserved_params: + These are parameters which have names like the FSPS parameters but will + not be passed to the StellarPopulation object because we are overriding + their functionality using (hopefully more efficient) custom algorithms. + """ + + def __init__(self, zcontinuous=1, reserved_params=['tage', 'sigma_smooth'], + interp_type='logarithmic', flux_interp='linear', + mint_log=-3, compute_vega_mags=False, + **kwargs): + """ + :param interp_type: (default: "logarithmic") + Specify whether to linearly interpolate the SSPs in log(t) or t. + For the latter, set this to "linear". + + :param flux_interp': (default: "linear") + Whether to compute the final spectrum as \sum_i w_i f_i or + e^{\sum_i w_i ln(f_i)}. Basically you should always do the former, + which is the default. + + :param mint_log: (default: -3) + The log of the age (in years) of the youngest SSP. Note that the + SSP at this age is assumed to have the same spectrum as the minimum + age SSP avalibale from fsps. Typically anything less than 4 or so + is fine for this parameter, since the integral converges as log(t) + -> -inf + + :param reserved_params: + These are parameters which have names like the FSPS parameters but + will not be passed to the StellarPopulation object because we are + overriding their functionality using (hopefully more efficient) + custom algorithms. + """ + + self.interp_type = interp_type + self.mint_log = mint_log + self.flux_interp = flux_interp + self.ssp = fsps.StellarPopulation(compute_vega_mags=compute_vega_mags, + zcontinuous=zcontinuous) + self.ssp.params['sfh'] = 0 + self.reserved_params = reserved_params + self.params = {} + self.update(**kwargs) + + def update(self, **params): + """Update the parameters, passing the *unreserved* FSPS parameters + through to the ``fsps.StellarPopulation`` object. + + :param params: + A parameter dictionary. + """ + for k, v in params.items(): + # try to make parameters scalar + try: + if (len(v) == 1) and callable(v[0]): + self.params[k] = v[0] + else: + self.params[k] = np.squeeze(v) + except: + self.params[k] = v + # Parameters named like FSPS params but that we reserve for use + # here. Do not pass them to FSPS. + if k in self.reserved_params: + continue + # Otherwise if a parameter exists in the FSPS parameter set, pass a + # copy of it in. + if k in self.ssp.params.all_params: + self.ssp.params[k] = deepcopy(v) + + # We use FSPS for SSPs !!ONLY!! + # except for FastStepBasis. And CSPSpecBasis. and... + # assert self.ssp.params['sfh'] == 0 + + def get_galaxy_spectrum(self, **params): + """Update parameters, then get the SSP spectrum + + Returns + ------- + wave : ndarray + Restframe avelength in angstroms. + + spectrum : ndarray + Spectrum in units of Lsun/Hz per solar mass formed. + + mass_fraction : float + Fraction of the formed stellar mass that still exists. + """ + self.update(**params) + wave, spec = self.ssp.get_spectrum(tage=float(self.params['tage']), peraa=False) + return wave, spec, self.ssp.stellar_mass + + def get_galaxy_elines(self): + """Get the wavelengths and specific emission line luminosity of the nebular emission lines + predicted by FSPS. These lines are in units of Lsun/solar mass formed. + This assumes that `get_galaxy_spectrum` has already been called. + + :returns ewave: + The *restframe* wavelengths of the emission lines, AA + + :returns elum: + Specific luminosities of the nebular emission lines, + Lsun/stellar mass formed + """ + ewave = self.ssp.emline_wavelengths + # This allows subclasses to set their own specific emission line + # luminosities within other methods, e.g., get_galaxy_spectrum, by + # populating the `_specific_line_luminosity` attribute. + elum = getattr(self, "_line_specific_luminosity", None) + + if elum is None: + elum = self.ssp.emline_luminosity.copy() + if elum.ndim > 1: + elum = elum[0] + if self.ssp.params["sfh"] == 3: + # tabular sfh + mass = np.sum(self.params.get('mass', 1.0)) + elum /= mass + + return ewave, elum + + @property + def logage(self): + return self.ssp.ssp_ages.copy() + + @property + def wavelengths(self): + return self.ssp.wavelengths.copy() + + @property + def spectral_resolution(self): + r = getattr(self.ssp, "resolutions", np.array(0)) + return r + +class FastStepBasis(SSPBasis): + """Subclass of :py:class:`SSPBasis` that implements a "nonparameteric" + (i.e. binned) SFH. This is accomplished by generating a tabular SFH with + the proper form to be passed to FSPS. The key parameters for this SFH are: + + * ``agebins`` - array of shape ``(nbin, 2)`` giving the younger and older + (in lookback time) edges of each bin in log10(years) + + * ``mass`` - array of shape ``(nbin,)`` giving the total stellar mass + (in solar masses) **formed** in each bin. + """ -to_cgs = to_cgs_at_10pc + def get_galaxy_spectrum(self, **params): + """Construct the tabular SFH and feed it to the ``ssp``. + """ + self.update(**params) + # --- check to make sure agebins have minimum spacing of 1million yrs --- + # (this can happen in flex models and will crash FSPS) + if np.min(np.diff(10**self.params['agebins'])) < 1e6: + raise ValueError + + mtot = self.params['mass'].sum() + time, sfr, tmax = self.convert_sfh(self.params['agebins'], self.params['mass']) + self.ssp.params["sfh"] = 3 # Hack to avoid rewriting the superclass + self.ssp.set_tabular_sfh(time, sfr) + wave, spec = self.ssp.get_spectrum(tage=tmax, peraa=False) + return wave, spec / mtot, self.ssp.stellar_mass / mtot + + def convert_sfh(self, agebins, mformed, epsilon=1e-4, maxage=None): + """Given arrays of agebins and formed masses with each bin, calculate a + tabular SFH. The resulting time vector has time points either side of + each bin edge with a "closeness" defined by a parameter epsilon. + + :param agebins: + An array of bin edges, log(yrs). This method assumes that the + upper edge of one bin is the same as the lower edge of another bin. + ndarray of shape ``(nbin, 2)`` + + :param mformed: + The stellar mass formed in each bin. ndarray of shape ``(nbin,)`` + + :param epsilon: (optional, default 1e-4) + A small number used to define the fraction time separation of + adjacent points at the bin edges. + + :param maxage: (optional, default: ``None``) + A maximum age of stars in the population, in yrs. If ``None`` then the maximum + value of ``agebins`` is used. Note that an error will occur if maxage + < the maximum age in agebins. + + :returns time: + The output time array for use with sfh=3, in Gyr. ndarray of shape (2*N) + + :returns sfr: + The output sfr array for use with sfh=3, in M_sun/yr. ndarray of shape (2*N) + + :returns maxage: + The maximum valid age in the returned isochrone. + """ + #### create time vector + agebins_yrs = 10**agebins.T + dt = agebins_yrs[1, :] - agebins_yrs[0, :] + bin_edges = np.unique(agebins_yrs) + if maxage is None: + maxage = agebins_yrs.max() # can replace maxage with something else, e.g. tuniv + t = np.concatenate((bin_edges * (1.-epsilon), bin_edges * (1+epsilon))) + t.sort() + t = t[1:-1] # remove older than oldest bin, younger than youngest bin + fsps_time = maxage - t + + #### calculate SFR at each t + sfr = mformed / dt + sfrout = np.zeros_like(t) + sfrout[::2] = sfr + sfrout[1::2] = sfr # * (1+epsilon) + + return (fsps_time / 1e9)[::-1], sfrout[::-1], maxage / 1e9 class CSPSpecBasis(SSPBasis): @@ -121,160 +347,3 @@ def get_galaxy_spectrum(self, **params): return wave, spectrum, mfrac_sum - -class MultiComponentCSPBasis(CSPSpecBasis): - - """Similar to :py:class`CSPSpecBasis`, a class for combinations of N composite stellar - populations (including single-age populations). The number of composite - stellar populations is given by the length of the `mass` parameter. - - However, in MultiComponentCSPBasis the SED of the different components are - tracked, and in get_spectrum() photometry can be drawn from a given - component or from the sum. - """ - - def get_galaxy_spectrum(self, **params): - """Update parameters, then loop over each component getting a spectrum - for each. Return all the component spectra, plus the sum. - - :param params: - A parameter dictionary that gets passed to the ``self.update`` - method and will generally include physical parameters that control - the stellar population and output spectrum or SED, some of which - may be vectors for the different componenets - - :returns wave: - Wavelength in angstroms. - - :returns spectrum: - Spectrum in units of Lsun/Hz/solar masses formed. ndarray of - shape(ncomponent+1, nwave). The last element is the sum of the - previous elements. - - :returns mass_fraction: - Fraction of the formed stellar mass that still exists, ndarray of - shape (ncomponent+1,) - """ - self.update(**params) - spectra = [] - mass = np.atleast_1d(self.params['mass']).copy() - mfrac = np.zeros_like(mass) - # Loop over mass components - for i, m in enumerate(mass): - self.update_component(i) - wave, spec = self.ssp.get_spectrum(tage=self.ssp.params['tage'], - peraa=False) - spectra.append(spec) - mfrac[i] = (self.ssp.stellar_mass) - - # Convert normalization units from per stellar mass to per mass formed - if np.all(self.params.get('mass_units', 'mformed') == 'mstar'): - mass /= mfrac - spectrum = np.dot(mass, np.array(spectra)) / mass.sum() - mfrac_sum = np.dot(mass, mfrac) / mass.sum() - - return wave, np.squeeze(spectra + [spectrum]), np.squeeze(mfrac.tolist() + [mfrac_sum]) - - def get_spectrum(self, outwave=None, filters=None, component=-1, **params): - """Get a spectrum and SED for the given params, choosing from different - possible components. - - :param outwave: (default: None) - Desired *vacuum* wavelengths. Defaults to the values in - `sps.wavelength`. - - :param peraa: (default: False) - If `True`, return the spectrum in erg/s/cm^2/AA instead of AB - maggies. - - :param filters: (default: None) - A list of filter objects for which you'd like photometry to be - calculated. - - :param component: (optional, default: -1) - An optional array where each element gives the index of the - component from which to choose the magnitude. scalar or iterable - of same length as `filters` - - :param **params: - Optional keywords giving parameter values that will be used to - generate the predicted spectrum. - - :returns spec: - Observed frame component spectra in AB maggies, unless `peraa=True` in which - case the units are erg/s/cm^2/AA. (ncomp+1, nwave) - - :returns phot: - Observed frame photometry in AB maggies, ndarray of shape (ncomp+1, nfilters) - - :returns mass_frac: - The ratio of the surviving stellar mass to the total mass formed. - """ - - # Spectrum in Lsun/Hz per solar mass formed, restframe - wave, spectrum, mfrac = self.get_galaxy_spectrum(**params) - - # Redshifting + Wavelength solution - # We do it ourselves. - a = 1 + self.params.get('zred', 0) - af = a - b = 0.0 - - if 'wavecal_coeffs' in self.params: - x = wave - wave.min() - x = 2.0 * (x / x.max()) - 1.0 - c = np.insert(self.params['wavecal_coeffs'], 0, 0) - # assume coeeficients give shifts in km/s - b = chebval(x, c) / (lightspeed*1e-13) - - wa, sa = wave * (a + b), spectrum * af # Observed Frame - if outwave is None: - outwave = wa - - # Observed frame photometry, as absolute maggies - if filters is not None: - # Magic to only do filter projections for unique filters, and get a - # mapping back into this list of unique filters - # note that this may scramble order of unique_filters - fnames = [f.name for f in filters] - unique_names, uinds, filter_ind = np.unique(fnames, return_index=True, return_inverse=True) - unique_filters = np.array(filters)[uinds] - mags = getSED(wa, lightspeed/wa**2 * sa * to_cgs, unique_filters) - phot = np.atleast_1d(10**(-0.4 * mags)) - else: - phot = 0.0 - filter_ind = 0 - - # Distance dimming and unit conversion - zred = self.params.get('zred', 0.0) - if (zred == 0) or ('lumdist' in self.params): - # Use 10pc for the luminosity distance (or a number - # provided in the dist key in units of Mpc) - dfactor = (self.params.get('lumdist', 1e-5) * 1e5)**2 - else: - lumdist = cosmo.luminosity_distance(zred).value - dfactor = (lumdist * 1e5)**2 - - # Spectrum will be in maggies - sa *= to_cgs / dfactor / (3631*jansky_cgs) - - # Convert from absolute maggies to apparent maggies - phot /= dfactor - - # Mass normalization - mass = np.atleast_1d(self.params['mass']) - mass = np.squeeze(mass.tolist() + [mass.sum()]) - - sa = (sa * mass[:, None]) - phot = (phot * mass[:, None])[component, filter_ind] - - return sa, phot, mfrac - - -def gauss(x, mu, A, sigma): - """Lay down mutiple gaussians on the x-axis. - """ - mu, A, sigma = np.atleast_2d(mu), np.atleast_2d(A), np.atleast_2d(sigma) - val = (A / (sigma * np.sqrt(np.pi * 2)) * - np.exp(-(x[:, None] - mu)**2 / (2 * sigma**2))) - return val.sum(axis=-1) diff --git a/prospect/sources/nebssp_basis.py b/prospect/sources/nebssp_basis.py index 37a1ae7b..3e662103 100644 --- a/prospect/sources/nebssp_basis.py +++ b/prospect/sources/nebssp_basis.py @@ -1,19 +1,23 @@ - +### SSP for cuejax import numpy as np +from pkg_resources import resource_filename -from .ssp_basis import FastStepBasis -from .fake_fsps import add_dust, add_igm +import fsps +from .galaxy_basis import SSPBasis, FastStepBasis, CSPSpecBasis +from .fake_fsps import add_dust, add_igm, idx try: - import cue + from cuejax.emulator import Emulator, fast_line_prediction, fast_cont_prediction + from cuejax.utils import fit_4loglinear_ionparam except: pass -__all__ = ["NebSSPBasis"] +__all__ = ["NebSSPBasis", "NebStepBasis", "NebCSPBasis"] + -class NebSSPBasis(FastStepBasis): +class NebSSPBasis(SSPBasis): """This is a class that wraps the fsps.StellarPopulation object, which is used for producing SSPs. The ``fsps.StellarPopulation`` object is accessed @@ -40,19 +44,188 @@ class NebSSPBasis(FastStepBasis): their functionality using (hopefully more efficient) custom algorithms. """ - def __init__(self, cue_kwargs={}, + def __init__(self, zcontinuous=1, reserved_params=['tage', 'sigma_smooth'], + interp_type='logarithmic', flux_interp='linear', + mint_log=-3, compute_vega_mags=False, + cue_kwargs={}, **kwargs): + """ + :param interp_type: (default: "logarithmic") + Specify whether to linearly interpolate the SSPs in log(t) or t. + For the latter, set this to "linear". - self.emul = cue.Emulator(**cue_kwargs) + :param flux_interp': (default: "linear") + Whether to compute the final spectrum as \sum_i w_i f_i or + e^{\sum_i w_i ln(f_i)}. Basically you should always do the former, + which is the default. + + :param mint_log: (default: -3) + The log of the age (in years) of the youngest SSP. Note that the + SSP at this age is assumed to have the same spectrum as the minimum + age SSP avalibale from fsps. Typically anything less than 4 or so + is fine for this parameter, since the integral converges as log(t) + -> -inf + + :param reserved_params: + These are parameters which have names like the FSPS parameters but + will not be passed to the StellarPopulation object because we are + overriding their functionality using (hopefully more efficient) + custom algorithms. + """ + self.interp_type = interp_type + self.mint_log = mint_log + self.flux_interp = flux_interp + self.ssp = fsps.StellarPopulation(compute_vega_mags=compute_vega_mags, + zcontinuous=zcontinuous) # we do these now rp = ["dust1", "dust2", "dust3", "add_dust_emission", "add_igm_absorption", "igm_factor", - "add_neb_emission", "add_neb_continuum", "neblemlineinspec", + "add_neb_emission", "add_neb_continuum", "nebemlineinspec", "fagn", "agn_tau"] reserved_params = kwargs.pop("reserved_params", []) + rp super().__init__(reserved_params=reserved_params, **kwargs) for k in ["add_igm_absorption", "add_dust_emission", "add_neb_emission", "nebemlineinspec"]: - self.ssp.params[k] = False + self.ssp.params[k] = False + self.ssp.params['sfh'] = 0 + self.reserved_params = reserved_params + self.params = {} + self.update(**kwargs) + + self.emul = Emulator(**cue_kwargs) # gas_logqion is fixed to 49.1 + # compile the functions first to speed up prediction, using an initial set of cue parameters in order + _ = fast_line_prediction([19.7, 5.3, 1.6, 0.6, 3.9, 0.01, 0.2, -2.5, 2.0, 0.0, 0.0, 0.0], self.emul) + _ = fast_cont_prediction([19.7, 5.3, 1.6, 0.6, 3.9, 0.01, 0.2, -2.5, 2.0, 0.0, 0.0, 0.0], + self.ssp.wavelengths, + self.emul, unit='Lsun/Hz') + self.emline_wavelengths = np.genfromtxt(resource_filename("cuejax", "data/cue_emlines_info.dat"), + dtype=[('wave', 'f8'), ('name', ' 1: + elum = elum[0] + if self.ssp.params["sfh"] == 3: + # tabular sfh + mass = np.sum(self.params.get('mass', 1.0)) + elum /= mass + + return ewave, elum + + +class NebStepBasis(FastStepBasis): + + """This is a class that wraps the fsps.StellarPopulation object, which is + used for producing SSPs. The ``fsps.StellarPopulation`` object is accessed + as ``SSPBasis().ssp``. + + This class allows for the custom calculation of relative SSP weights (by + overriding ``all_ssp_weights``) to produce spectra from arbitrary composite + SFHs. Alternatively, the entire ``get_galaxy_spectrum`` method can be + overridden to produce a galaxy spectrum in some other way, for example + taking advantage of weight calculations within FSPS for tabular SFHs or for + parameteric SFHs. + + The base implementation here produces an SSP interpolated to the age given + by ``tage``, with initial mass given by ``mass``. However, this is much + slower than letting FSPS calculate the weights, as implemented in + :py:class:`FastSSPBasis`. + + Furthermore, smoothing, redshifting, and filter projections are handled + outside of FSPS, allowing for fast and more flexible algorithms. + + :param reserved_params: + These are parameters which have names like the FSPS parameters but will + not be passed to the StellarPopulation object because we are overriding + their functionality using (hopefully more efficient) custom algorithms. + """ + def __init__(self, zcontinuous=1, reserved_params=['tage', 'sigma_smooth'], + interp_type='logarithmic', flux_interp='linear', + mint_log=-3, compute_vega_mags=False, + cue_kwargs={}, + **kwargs): + + self.interp_type = interp_type + self.mint_log = mint_log + self.flux_interp = flux_interp + self.ssp = fsps.StellarPopulation(compute_vega_mags=compute_vega_mags, + zcontinuous=zcontinuous) + # we do these now + rp = ["dust1", "dust2", "dust3", "add_dust_emission", + "add_igm_absorption", "igm_factor", + "add_neb_emission", "add_neb_continuum", "nebemlineinspec", + "fagn", "agn_tau"] + reserved_params = kwargs.pop("reserved_params", []) + rp + super().__init__(reserved_params=reserved_params, **kwargs) + for k in ["add_igm_absorption", "add_dust_emission", "add_neb_emission", "nebemlineinspec"]: + self.ssp.params[k] = False + self.ssp.params['sfh'] = 0 + self.reserved_params = reserved_params + self.params = {} + self.update(**kwargs) + + self.emul = Emulator(**cue_kwargs) # gas_logqion is fixed to 49.1 + # compile the functions first to speed up prediction, using an initial set of cue parameters in order + _ = fast_line_prediction([19.7, 5.3, 1.6, 0.6, 3.9, 0.01, 0.2, -2.5, 2.0, 0.0, 0.0, 0.0], self.emul) + _ = fast_cont_prediction([19.7, 5.3, 1.6, 0.6, 3.9, 0.01, 0.2, -2.5, 2.0, 0.0, 0.0, 0.0], + self.ssp.wavelengths, + self.emul, unit='Lsun/Hz') + self.emline_wavelengths = np.genfromtxt(resource_filename("cuejax", "data/cue_emlines_info.dat"), + dtype=[('wave', 'f8'), ('name', ' 1: + elum = elum[0] + if self.ssp.params["sfh"] == 3: + # tabular sfh + mass = np.sum(self.params.get('mass', 1.0)) + elum /= mass + + return ewave, elum + + +class NebCSPBasis(CSPSpecBasis): + + """A subclass of :py:class:`SSPBasis` for combinations of N composite + stellar populations (including single-age populations). The number of + composite stellar populations is given by the length of the ``"mass"`` + parameter. Other population properties can also be vectors of the same + length as ``"mass"`` if they are independent for each component. + """ + + def __init__(self, zcontinuous=1, reserved_params=['sigma_smooth'], + vactoair_flag=False, compute_vega_mags=False, + cue_kwargs={}, **kwargs): + + # This is a StellarPopulation object from fsps + self.ssp = fsps.StellarPopulation(compute_vega_mags=compute_vega_mags, + zcontinuous=zcontinuous, + vactoair_flag=vactoair_flag) + # we do these now + rp = ["dust1", "dust2", "dust3", "add_dust_emission", + "add_igm_absorption", "igm_factor", + "add_neb_emission", "add_neb_continuum", "nebemlineinspec", + "fagn", "agn_tau"] + reserved_params = kwargs.pop("reserved_params", []) + rp + super().__init__(reserved_params=reserved_params, **kwargs) + for k in ["add_igm_absorption", "add_dust_emission", "add_neb_emission", "nebemlineinspec"]: + self.ssp.params[k] = False + + self.emul = Emulator(**cue_kwargs) + # compile the functions first to speed up prediction, using an initial set of cue parameters in order + _ = fast_line_prediction([19.7, 5.3, 1.6, 0.6, 3.9, 0.01, 0.2, -2.5, 0.0, 0.0, 0.0, 49.1], self.emul) + _ = fast_cont_prediction([19.7, 5.3, 1.6, 0.6, 3.9, 0.01, 0.2, -2.5, 0.0, 0.0, 0.0, 49.1], + self.ssp.wavelengths, + self.emul, unit='Lsun/Hz') + self.emline_wavelengths = np.genfromtxt(resource_filename("cuejax", "data/cue_emlines_info.dat"), + dtype=[('wave', 'f8'), ('name', ' 1: + elum = elum[0] + if self.ssp.params["sfh"] == 3: + # tabular sfh + mass = np.sum(self.params.get('mass', 1.0)) + elum /= mass + + return ewave, elum + + +cue_keys = ['ionspec_index1', + 'ionspec_index2', + 'ionspec_index3', + 'ionspec_index4', + 'ionspec_logLratio1', + 'ionspec_logLratio2', + 'ionspec_logLratio3', + 'gas_logu', + 'gas_lognH', + 'gas_logz', + 'gas_logno', + 'gas_logco'] + +def get_spectrum(ssp, params, emul, ewave, tage=0): + """ + Add the nebular continuum from Cue to the young and old population and do the dust attenuation and igm absorption. + Also, calculate the line luminoisities from the young csp and old csp and do the dust attenuation and igm absorption. + The output spec/line luminosity need to be divided by total formed mass to get the specific number. + :param use_stellar_ionizing: + If true, fit CSPs and to get the ionizing spectrum parameters, else read from ssp + """ + add_neb = params.get("add_neb_emission", False) + use_stars = params.get("use_stellar_ionizing", False) + #params = {k: v.item() if isinstance(v, np.ndarray) else v for k, v in params.items()} wave, tspec = ssp.get_spectrum(tage=tage, peraa=False) young, old = ssp._csp_young_old csps = [young, old] # could combine with previous line lines = [] - for spec in csps: - if add_neb: - if use_stars: - ion_params = fit_log_linear_ionparam(wave, spec) - params.update(**ion_params) - line_prediction = emul.predict_lines(**params) - lines.append(line_prediction) - spec += emul.predict_cont(wave, **params) + mass = np.sum(params.get('mass', 1.0)) + if not add_neb: + lines = [np.zeros_like(ewave), np.zeros_like(ewave)] + elif add_neb: + if not use_stars: + cue_params = {k: params[k] for k in cue_keys} + theta = np.array(list(cue_params.values())) + line_prediction = fast_line_prediction(theta, emul)[0].squeeze() * 10**(params["gas_logqion"] - 49.1) + #if ssp.params["sfh"] == 3: + # line_prediction /= mass + lines = [line_prediction, np.zeros_like(ewave)] + csps[0][wave>=912] += fast_cont_prediction(theta, wave[wave>=912], emul, unit='Lsun/Hz')[0].squeeze() * 10**(params["gas_logqion"] - 49.1) + elif use_stars: + for spec in csps: + params.update(**fit_4loglinear_ionparam(wave, spec)) + cue_params = {k: params[k] for k in cue_keys} + theta = np.array(list(cue_params.values())) + line_prediction = fast_line_prediction(theta, emul)[0].squeeze() * 10**(params["gas_logqion"] - 49.1) + lines.append(line_prediction) + spec[wave>=912] += fast_cont_prediction(theta, wave[wave>=912], emul, unit='Lsun/Hz')[0].squeeze() * 10**(params["gas_logqion"] - 49.1) else: - lines.append(np.zeros_like(ewave)) + raise KeyError('No "use_stellar_ionizing" in model') - sspec, lines = add_dust(wave, csps, ewave, lines, **params) + sspec, lines = add_dust(wave, csps, ewave, lines, dust1_index=ssp.params['dust1_index'], **params) sspec = add_igm(wave, sspec, **params) - return wave, sspec, lines + return wave, sspec, lines \ No newline at end of file diff --git a/prospect/sources/ssp_basis.py b/prospect/sources/ssp_basis.py deleted file mode 100644 index 156a2b80..00000000 --- a/prospect/sources/ssp_basis.py +++ /dev/null @@ -1,403 +0,0 @@ -from copy import deepcopy -import numpy as np -from numpy.polynomial.chebyshev import chebval - -from ..utils.smoothing import smoothspec -from .constants import cosmo, lightspeed, jansky_cgs, to_cgs_at_10pc - -try: - import fsps - from sedpy.observate import getSED -except(ImportError, RuntimeError): - pass - -__all__ = ["SSPBasis", "FastSSPBasis", "FastStepBasis", - "MultiSSPBasis"] - - -to_cgs = to_cgs_at_10pc - - -class SSPBasis(object): - - """This is a class that wraps the fsps.StellarPopulation object, which is - used for producing SSPs. The ``fsps.StellarPopulation`` object is accessed - as ``SSPBasis().ssp``. - - This class allows for the custom calculation of relative SSP weights (by - overriding ``all_ssp_weights``) to produce spectra from arbitrary composite - SFHs. Alternatively, the entire ``get_galaxy_spectrum`` method can be - overridden to produce a galaxy spectrum in some other way, for example - taking advantage of weight calculations within FSPS for tabular SFHs or for - parameteric SFHs. - - The base implementation here produces an SSP interpolated to the age given - by ``tage``, with initial mass given by ``mass``. However, this is much - slower than letting FSPS calculate the weights, as implemented in - :py:class:`FastSSPBasis`. - - Furthermore, smoothing, redshifting, and filter projections are handled - outside of FSPS, allowing for fast and more flexible algorithms. - - :param reserved_params: - These are parameters which have names like the FSPS parameters but will - not be passed to the StellarPopulation object because we are overriding - their functionality using (hopefully more efficient) custom algorithms. - """ - - def __init__(self, zcontinuous=1, reserved_params=['tage', 'sigma_smooth'], - interp_type='logarithmic', flux_interp='linear', - mint_log=-3, compute_vega_mags=False, - **kwargs): - """ - :param interp_type: (default: "logarithmic") - Specify whether to linearly interpolate the SSPs in log(t) or t. - For the latter, set this to "linear". - - :param flux_interp': (default: "linear") - Whether to compute the final spectrum as \sum_i w_i f_i or - e^{\sum_i w_i ln(f_i)}. Basically you should always do the former, - which is the default. - - :param mint_log: (default: -3) - The log of the age (in years) of the youngest SSP. Note that the - SSP at this age is assumed to have the same spectrum as the minimum - age SSP avalibale from fsps. Typically anything less than 4 or so - is fine for this parameter, since the integral converges as log(t) - -> -inf - - :param reserved_params: - These are parameters which have names like the FSPS parameters but - will not be passed to the StellarPopulation object because we are - overriding their functionality using (hopefully more efficient) - custom algorithms. - """ - - self.interp_type = interp_type - self.mint_log = mint_log - self.flux_interp = flux_interp - self.ssp = fsps.StellarPopulation(compute_vega_mags=compute_vega_mags, - zcontinuous=zcontinuous) - self.ssp.params['sfh'] = 0 - self.reserved_params = reserved_params - self.params = {} - self.update(**kwargs) - - def update(self, **params): - """Update the parameters, passing the *unreserved* FSPS parameters - through to the ``fsps.StellarPopulation`` object. - - :param params: - A parameter dictionary. - """ - for k, v in params.items(): - # try to make parameters scalar - try: - if (len(v) == 1) and callable(v[0]): - self.params[k] = v[0] - else: - self.params[k] = np.squeeze(v) - except: - self.params[k] = v - # Parameters named like FSPS params but that we reserve for use - # here. Do not pass them to FSPS. - if k in self.reserved_params: - continue - # Otherwise if a parameter exists in the FSPS parameter set, pass a - # copy of it in. - if k in self.ssp.params.all_params: - self.ssp.params[k] = deepcopy(v) - - # We use FSPS for SSPs !!ONLY!! - # except for FastStepBasis. And CSPSpecBasis. and... - # assert self.ssp.params['sfh'] == 0 - - def get_galaxy_spectrum(self, **params): - """Update parameters, then multiply SSP weights by SSP spectra and - stellar masses, and sum. - - :returns wave: - Wavelength in angstroms. - - :returns spectrum: - Spectrum in units of Lsun/Hz/solar masses formed. - - :returns mass_fraction: - Fraction of the formed stellar mass that still exists. - """ - self.update(**params) - - # Get the SSP spectra and masses (caching the latter), adding an extra - # mass and spectrum for t=0, using the first SSP spectrum. - wave, ssp_spectra = self.ssp.get_spectrum(tage=0, peraa=False) - ssp_spectra = np.vstack([ssp_spectra[0, :], ssp_spectra]) - self.ssp_stellar_masses = np.insert(self.ssp.stellar_mass, 0, 1.0) - if self.flux_interp == 'logarithmic': - ssp_spectra = np.log(ssp_spectra) - - # Get weighted sum of spectra, adding the t=0 spectrum using the first SSP. - weights = self.all_ssp_weights - spectrum = np.dot(weights, ssp_spectra) / weights.sum() - if self.flux_interp == 'logarithmic': - spectrum = np.exp(spectrum) - - # Get the weighted stellar_mass/mformed ratio - mass_frac = (self.ssp_stellar_masses * weights).sum() / weights.sum() - return wave, spectrum, mass_frac - - def get_galaxy_elines(self): - """Get the wavelengths and specific emission line luminosity of the nebular emission lines - predicted by FSPS. These lines are in units of Lsun/solar mass formed. - This assumes that `get_galaxy_spectrum` has already been called. - - :returns ewave: - The *restframe* wavelengths of the emission lines, AA - - :returns elum: - Specific luminosities of the nebular emission lines, - Lsun/stellar mass formed - """ - ewave = self.ssp.emline_wavelengths - # This allows subclasses to set their own specific emission line - # luminosities within other methods, e.g., get_galaxy_spectrum, by - # populating the `_specific_line_luminosity` attribute. - elum = getattr(self, "_line_specific_luminosity", None) - - if elum is None: - elum = self.ssp.emline_luminosity.copy() - if elum.ndim > 1: - elum = elum[0] - if self.ssp.params["sfh"] == 3: - # tabular sfh - mass = np.sum(self.params.get('mass', 1.0)) - elum /= mass - - return ewave, elum - - def get_spectrum(self, outwave=None, filters=None, peraa=False, **params): - """Get a spectrum and SED for the given params. - - :param outwave: (default: None) - Desired *vacuum* wavelengths. Defaults to the values in - ``sps.wavelength``. - - :param peraa: (default: False) - If `True`, return the spectrum in erg/s/cm^2/AA instead of AB - maggies. - - :param filters: (default: None) - A list of filter objects for which you'd like photometry to be calculated. - - :param params: - Optional keywords giving parameter values that will be used to - generate the predicted spectrum. - - :returns spec: - Observed frame spectrum in AB maggies, unless ``peraa=True`` in which - case the units are erg/s/cm^2/AA. - - :returns phot: - Observed frame photometry in AB maggies. - - :returns mass_frac: - The ratio of the surviving stellar mass to the total mass formed. - """ - # Spectrum in Lsun/Hz per solar mass formed, restframe - wave, spectrum, mfrac = self.get_galaxy_spectrum(**params) - - # Redshifting + Wavelength solution - # We do it ourselves. - a = 1 + self.params.get('zred', 0) - af = a - b = 0.0 - - if 'wavecal_coeffs' in self.params: - x = wave - wave.min() - x = 2.0 * (x / x.max()) - 1.0 - c = np.insert(self.params['wavecal_coeffs'], 0, 0) - # assume coeeficients give shifts in km/s - b = chebval(x, c) / (lightspeed*1e-13) - - wa, sa = wave * (a + b), spectrum * af # Observed Frame - if outwave is None: - outwave = wa - - # Observed frame photometry, as absolute maggies - if filters is not None: - flambda = lightspeed/wa**2 * sa * to_cgs - phot = 10**(-0.4 * np.atleast_1d(getSED(wa, flambda, filters))) - # TODO: below is faster for sedpy > 0.2.0 - #phot = np.atleast_1d(getSED(wa, lightspeed/wa**2 * sa * to_cgs, - # filters, linear_flux=True)) - else: - phot = 0.0 - - # Spectral smoothing. - do_smooth = (('sigma_smooth' in self.params) and - ('sigma_smooth' in self.reserved_params)) - if do_smooth: - # We do it ourselves. - smspec = self.smoothspec(wa, sa, self.params['sigma_smooth'], - outwave=outwave, **self.params) - elif outwave is not wa: - # Just interpolate - smspec = np.interp(outwave, wa, sa, left=0, right=0) - else: - # no interpolation necessary - smspec = sa - - # Distance dimming and unit conversion - zred = self.params.get('zred', 0.0) - if (zred == 0) or ('lumdist' in self.params): - # Use 10pc for the luminosity distance (or a number - # provided in the dist key in units of Mpc) - dfactor = (self.params.get('lumdist', 1e-5) * 1e5)**2 - else: - lumdist = cosmo.luminosity_distance(zred).value - dfactor = (lumdist * 1e5)**2 - if peraa: - # spectrum will be in erg/s/cm^2/AA - smspec *= to_cgs / dfactor * lightspeed / outwave**2 - else: - # Spectrum will be in maggies - smspec *= to_cgs / dfactor / (3631*jansky_cgs) - - # Convert from absolute maggies to apparent maggies - phot /= dfactor - - # Mass normalization - mass = np.sum(self.params.get('mass', 1.0)) - if np.all(self.params.get('mass_units', 'mformed') == 'mstar'): - # Convert input normalization units from current stellar mass to mass formed - mass /= mfrac - - return smspec * mass, phot * mass, mfrac - - @property - def all_ssp_weights(self): - """Weights for a single age population. This is a slow way to do this! - """ - if self.interp_type == 'linear': - sspages = np.insert(10**self.logage, 0, 0) - tb = self.params['tage'] * 1e9 - - elif self.interp_type == 'logarithmic': - sspages = np.insert(self.logage, 0, self.mint_log) - tb = np.log10(self.params['tage']) + 9 - - ind = np.searchsorted(sspages, tb) # index of the higher bracketing lookback time - dt = (sspages[ind] - sspages[ind - 1]) - ww = np.zeros(len(sspages)) - ww[ind - 1] = (sspages[ind] - tb) / dt - ww[ind] = (tb - sspages[ind-1]) / dt - return ww - - def smoothspec(self, wave, spec, sigma, outwave=None, **kwargs): - outspec = smoothspec(wave, spec, sigma, outwave=outwave, **kwargs) - return outspec - - @property - def logage(self): - return self.ssp.ssp_ages.copy() - - @property - def wavelengths(self): - return self.ssp.wavelengths.copy() - - -class FastSSPBasis(SSPBasis): - """A subclass of :py:class:`SSPBasis` that is a faster way to do SSP models by letting - FSPS do the weight calculations. - """ - - def get_galaxy_spectrum(self, **params): - self.update(**params) - wave, spec = self.ssp.get_spectrum(tage=float(self.params['tage']), peraa=False) - return wave, spec, self.ssp.stellar_mass - - -class FastStepBasis(SSPBasis): - """Subclass of :py:class:`SSPBasis` that implements a "nonparameteric" - (i.e. binned) SFH. This is accomplished by generating a tabular SFH with - the proper form to be passed to FSPS. The key parameters for this SFH are: - - * ``agebins`` - array of shape ``(nbin, 2)`` giving the younger and older - (in lookback time) edges of each bin in log10(years) - - * ``mass`` - array of shape ``(nbin,)`` giving the total stellar mass - (in solar masses) **formed** in each bin. - """ - - def get_galaxy_spectrum(self, **params): - """Construct the tabular SFH and feed it to the ``ssp``. - """ - self.update(**params) - # --- check to make sure agebins have minimum spacing of 1million yrs --- - # (this can happen in flex models and will crash FSPS) - if np.min(np.diff(10**self.params['agebins'])) < 1e6: - raise ValueError - - mtot = self.params['mass'].sum() - time, sfr, tmax = self.convert_sfh(self.params['agebins'], self.params['mass']) - self.ssp.params["sfh"] = 3 # Hack to avoid rewriting the superclass - self.ssp.set_tabular_sfh(time, sfr) - wave, spec = self.ssp.get_spectrum(tage=tmax, peraa=False) - return wave, spec / mtot, self.ssp.stellar_mass / mtot - - def convert_sfh(self, agebins, mformed, epsilon=1e-4, maxage=None): - """Given arrays of agebins and formed masses with each bin, calculate a - tabular SFH. The resulting time vector has time points either side of - each bin edge with a "closeness" defined by a parameter epsilon. - - :param agebins: - An array of bin edges, log(yrs). This method assumes that the - upper edge of one bin is the same as the lower edge of another bin. - ndarray of shape ``(nbin, 2)`` - - :param mformed: - The stellar mass formed in each bin. ndarray of shape ``(nbin,)`` - - :param epsilon: (optional, default 1e-4) - A small number used to define the fraction time separation of - adjacent points at the bin edges. - - :param maxage: (optional, default: ``None``) - A maximum age of stars in the population, in yrs. If ``None`` then the maximum - value of ``agebins`` is used. Note that an error will occur if maxage - < the maximum age in agebins. - - :returns time: - The output time array for use with sfh=3, in Gyr. ndarray of shape (2*N) - - :returns sfr: - The output sfr array for use with sfh=3, in M_sun/yr. ndarray of shape (2*N) - - :returns maxage: - The maximum valid age in the returned isochrone. - """ - #### create time vector - agebins_yrs = 10**agebins.T - dt = agebins_yrs[1, :] - agebins_yrs[0, :] - bin_edges = np.unique(agebins_yrs) - if maxage is None: - maxage = agebins_yrs.max() # can replace maxage with something else, e.g. tuniv - t = np.concatenate((bin_edges * (1.-epsilon), bin_edges * (1+epsilon))) - t.sort() - t = t[1:-1] # remove older than oldest bin, younger than youngest bin - fsps_time = maxage - t - - #### calculate SFR at each t - sfr = mformed / dt - sfrout = np.zeros_like(t) - sfrout[::2] = sfr - sfrout[1::2] = sfr # * (1+epsilon) - - return (fsps_time / 1e9)[::-1], sfrout[::-1], maxage / 1e9 - - -class MultiSSPBasis(SSPBasis): - """An array of basis spectra with different ages, metallicities, and possibly dust - attenuations. - """ - def get_galaxy_spectrum(self): - raise(NotImplementedError) diff --git a/prospect/sources/star_basis.py b/prospect/sources/star_basis.py index abe733ad..7f532d61 100644 --- a/prospect/sources/star_basis.py +++ b/prospect/sources/star_basis.py @@ -3,7 +3,7 @@ from numpy.polynomial.chebyshev import chebval from scipy.spatial import Delaunay -from ..utils.smoothing import smoothspec +from sedpy.smoothing import smoothspec from .constants import lightspeed, lsun, jansky_cgs, to_cgs_at_10pc try: diff --git a/prospect/utils/prospect_args.py b/prospect/utils/prospect_args.py index 15c3d31a..d0b0aa93 100644 --- a/prospect/utils/prospect_args.py +++ b/prospect/utils/prospect_args.py @@ -14,7 +14,7 @@ def show_default_args(): parser.print_help() -def get_parser(fitters=["optimize", "emcee", "dynesty"]): +def get_parser(fitters=["optimize", "emcee", "nested"]): """Get a default prospector argument parser """ @@ -46,8 +46,8 @@ def get_parser(fitters=["optimize", "emcee", "dynesty"]): if "emcee" in fitters: parser = add_emcee_args(parser) - if "dynesty" in fitters: - parser = add_dynesty_args(parser) + if "nested" in fitters: + parser = add_nested_args(parser) return parser @@ -108,53 +108,44 @@ def add_emcee_args(parser): return parser -def add_dynesty_args(parser): +def add_nested_args(parser): # --- dynesty parameters --- - parser.add_argument("--dynesty", action="store_true", - help="If set, do nested sampling with dynesty.") + parser.add_argument("--nested_sampler", type=str, default="", + choices=["", "dynesty", "nautilus", "ultranest"], + help="do sampling with this sampler") + + parser.add_argument("--nested_nlive", dest="nested_nlive", type=int, default=1000, + help="Number of live points for the nested sampling run.") + + parser.add_argument("--nested_target_n_effective", type=int, default=10000, + help=("Stop when the estimated number of *effective* posterior samples " + "reaches the target number.")) + + parser.add_argument("--nested_dlogz", type=float, default=0.05, + dest="dlogz_init", + help=("Stop the initial dynesty run when the remaining evidence is estimated " + "to be less than this.")) parser.add_argument("--nested_bound", type=str, default="multi", + dest="bound", choices=["single", "multi", "balls", "cubes"], - help=("Method for bounding the prior volume when drawing new points. " + help=("Method for bounding the prior volume when drawing new points with dynesty. " "One of single | multi | balls | cubes")) - parser.add_argument("--nested_sample", "--nested_method", type=str, dest="nested_sample", - default="slice", choices=["unif", "rwalk", "slice"], - help=("Method for drawing new points during sampling. " - "One of unif | rwalk | slice")) + parser.add_argument("--nested_sample", "--nested_method", type=str, dest="sample", + default="auto", choices=["auto", "unif", "rwalk", "slice", "hslice"], + help=("Method for drawing new points during dynesty sampling. " + "One of auto | unif | rwalk | slice | hslice")) parser.add_argument("--nested_walks", type=int, default=48, - help=("Number of Metropolis steps to take when " + dest="walks", + help=("Number of Metropolis steps for dynesty to take when " "`nested_sample` is 'rwalk'")) - parser.add_argument("--nlive_init", dest="nested_nlive_init", type=int, default=100, - help="Number of live points for the intial nested sampling run.") - - parser.add_argument("--nlive_batch", dest="nested_nlive_batch", type=int, default=100, - help="Number of live points for the dynamic nested sampling batches") - - parser.add_argument("--nested_dlogz_init", type=float, default=0.05, - help=("Stop the initial run when the remaining evidence is estimated " - "to be less than this.")) - - parser.add_argument("--nested_maxcall", type=int, default=int(5e7), - help=("Maximum number of likelihood calls during nested sampling. " - "This will only be enforced after the initial pass")) - - parser.add_argument("--nested_maxiter", type=int, default=int(1e6), - help=("Maximum number of iterations during nested sampling. " - "This will only be enforced after the initial pass")) - - parser.add_argument("--nested_maxbatch", type=int, default=10, - help="Maximum number of dynamic batches.") - parser.add_argument("--nested_bootstrap", type=int, default=0, + dest="bootstrap", help=("Number of bootstrap resamplings to use when estimating " - "ellipsoid expansion factor.")) - - parser.add_argument("--nested_target_n_effective", type=int, default=10000, - help=("Stop when the number of *effective* posterior samples as estimated " - "by dynesty reaches the target number.")) + "ellipsoid expansion factor with dynesty.")) return parser diff --git a/prospect/utils/smoothing.py b/prospect/utils/smoothing.py deleted file mode 100644 index e1e17540..00000000 --- a/prospect/utils/smoothing.py +++ /dev/null @@ -1,665 +0,0 @@ -# Spectral smoothing functionality -# To do: -# 3) add extra zero-padding for FFT algorithms so they don't go funky at the -# edges? - -import numpy as np -from numpy.fft import fft, ifft, fftfreq, rfftfreq - -__all__ = ["smoothspec", "smooth_wave", "smooth_vel", "smooth_lsf", - "smooth_wave_fft", "smooth_vel_fft", "smooth_fft", "smooth_lsf_fft", - "mask_wave", "resample_wave"] - -ckms = 2.998e5 -sigma_to_fwhm = 2.355 - - -def smoothspec(wave, spec, resolution=None, outwave=None, - smoothtype="vel", fftsmooth=True, - min_wave_smooth=0, max_wave_smooth=np.inf, **kwargs): - """ - Parameters - ---------- - wave : ndarray of shape ``(N_pix,)`` - The wavelength vector of the input spectrum. Assumed Angstroms. - - spec : ndarray of shape ``(N_pix,)`` - The flux vector of the input spectrum. - - resolution : float - The smoothing parameter. Units depend on ``smoothtype``. - - outwave : ``None`` or ndarray of shape ``(N_pix_out,)`` - The output wavelength vector. If ``None`` then the input wavelength - vector will be assumed, though if ``min_wave_smooth`` or - ``max_wave_smooth`` are also specified, then the output spectrum may - have different length than ``spec`` or ``wave``, or the convolution may - be strange outside of ``min_wave_smooth`` and ``max_wave_smooth``. - Basically, always set ``outwave`` to be safe. - - smoothtype : string, optional, default: "vel" - The type of smoothing to perform. One of: - - + ``"vel"`` - velocity smoothing, ``resolution`` units are in km/s - (dispersion not FWHM) - + ``"R"`` - resolution smoothing, ``resolution`` is in units of - :math:`\lambda/ \sigma_\lambda` (where :math:`\sigma_\lambda` is - dispersion, not FWHM) - + ``"lambda"`` - wavelength smoothing. ``resolution`` is in units of - Angstroms - + ``"lsf"`` - line-spread function. Use an aribitrary line spread - function, which can be given as a vector the same length as ``wave`` - that gives the dispersion (in AA) at each wavelength. Alternatively, - if ``resolution`` is ``None`` then a line-spread function must be - present as an additional ``lsf`` keyword. In this case all additional - keywords as well as the ``wave`` vector will be passed to this ``lsf`` - function. - - fftsmooth : bool, optional, default: True - Switch to use FFTs to do the smoothing, usually resulting in massive - speedups of all algorithms. However, edge effects may be present. - - min_wave_smooth : float, optional default: 0 - The minimum wavelength of the input vector to consider when smoothing - the spectrum. If ``None`` then it is determined from the output - wavelength vector and padded by some multiple of the desired resolution. - - max_wave_smooth : float, optional, default: inf - The maximum wavelength of the input vector to consider when smoothing - the spectrum. If None then it is determined from the output wavelength - vector and padded by some multiple of the desired resolution. - - inres : float, optional - If given, this parameter specifies the resolution of the input. This - resolution is subtracted in quadrature from the target output resolution - before the kernel is formed. - - In certain cases this can be used to properly switch from resolution - that is constant in velocity to one that is constant in wavelength, - taking into account the wavelength dependence of the input resolution - when defined in terms of lambda. This is possible iff: - * ``fftsmooth`` is False - * ``smoothtype`` is ``"lambda"`` - * The optional ``in_vel`` parameter is supplied and True. - - The units of ``inres`` should be the same as the units of - ``resolution``, except in the case of switching from velocity to - wavelength resolution, in which case the units of ``inres`` should be - in units of lambda/sigma_lambda. - - in_vel : float (optional) - If supplied and True, the ``inres`` parameter is assumed to be in units - of lambda/sigma_lambda. This parameter is ignored **unless** the - ``smoothtype`` is ``"lambda"`` and ``fftsmooth`` is False. - - Returns - ------- - flux : ndarray of shape ``(N_pix_out,)`` - The smoothed spectrum on the `outwave` grid, ndarray. - """ - if smoothtype == 'vel': - linear = False - units = 'km/s' - sigma = resolution - fwhm = sigma * sigma_to_fwhm - Rsigma = ckms / sigma - R = ckms / fwhm - width = Rsigma - assert np.size(sigma) == 1, "`resolution` must be scalar for `smoothtype`='vel'" - - elif smoothtype == 'R': - linear = False - units = 'km/s' - Rsigma = resolution - sigma = ckms / Rsigma - fwhm = sigma * sigma_to_fwhm - R = ckms / fwhm - width = Rsigma - assert np.size(sigma) == 1, "`resolution` must be scalar for `smoothtype`='R'" - # convert inres from Rsigma to sigma (km/s) - try: - kwargs['inres'] = ckms / kwargs['inres'] - except(KeyError): - pass - - elif smoothtype == 'lambda': - linear = True - units = 'AA' - sigma = resolution - fwhm = sigma * sigma_to_fwhm - Rsigma = None - R = None - width = sigma - assert np.size(sigma) == 1, "`resolution` must be scalar for `smoothtype`='lambda'" - - elif smoothtype == 'lsf': - linear = True - width = 100 - sigma = resolution - - else: - raise ValueError("smoothtype {} is not valid".format(smoothtype)) - - # Mask the input spectrum depending on outwave or the wave_smooth kwargs - mask = mask_wave(wave, width=width, outwave=outwave, linear=linear, - wlo=min_wave_smooth, whi=max_wave_smooth, **kwargs) - w = wave[mask] - s = spec[mask] - if outwave is None: - outwave = wave - - # Choose the smoothing method - if smoothtype == 'lsf': - if fftsmooth: - smooth_method = smooth_lsf_fft - if sigma is not None: - # mask the resolution vector - sigma = resolution[mask] - else: - smooth_method = smooth_lsf - if sigma is not None: - # convert to resolution on the output wavelength grid - sigma = np.interp(outwave, wave, resolution) - elif linear: - if fftsmooth: - smooth_method = smooth_wave_fft - else: - smooth_method = smooth_wave - else: - if fftsmooth: - smooth_method = smooth_vel_fft - else: - smooth_method = smooth_vel - - # Actually do the smoothing and return - return smooth_method(w, s, outwave, sigma, **kwargs) - - -def smooth_vel(wave, spec, outwave, sigma, nsigma=10, inres=0, **extras): - """Smooth a spectrum in velocity space. This is insanely slow, but general - and correct. - - :param wave: - Wavelength vector of the input spectrum. - - :param spec: - Flux vector of the input spectrum. - - :param outwave: - Desired output wavelength vector. - - :param sigma: - Desired velocity resolution (km/s), *not* FWHM. - - :param nsigma: - Number of sigma away from the output wavelength to consider in the - integral. If less than zero, all wavelengths are used. Setting this - to some positive number decreses the scaling constant in the O(N_out * - N_in) algorithm used here. - - :param inres: - The velocity resolution of the input spectrum (km/s), *not* FWHM. - """ - sigma_eff_sq = sigma**2 - inres**2 - if np.any(sigma_eff_sq) < 0.0: - raise ValueError("Desired velocity resolution smaller than the value" - "possible for this input spectrum.".format(inres)) - # sigma_eff is in units of sigma_lambda / lambda - sigma_eff = np.sqrt(sigma_eff_sq) / ckms - - lnwave = np.log(wave) - flux = np.zeros(len(outwave)) - for i, w in enumerate(outwave): - x = (np.log(w) - lnwave) / sigma_eff - if nsigma > 0: - good = np.abs(x) < nsigma - x = x[good] - _spec = spec[good] - else: - _spec = spec - f = np.exp(-0.5 * x**2) - flux[i] = np.trapz(f * _spec, x) / np.trapz(f, x) - return flux - - -def smooth_vel_fft(wavelength, spectrum, outwave, sigma_out, inres=0.0, - **extras): - """Smooth a spectrum in velocity space, using FFTs. This is fast, but makes - some assumptions about the form of the input spectrum and can have some - issues at the ends of the spectrum depending on how it is padded. - - :param wavelength: - Wavelength vector of the input spectrum. An assertion error will result - if this is not a regular grid in wavelength. - - :param spectrum: - Flux vector of the input spectrum. - - :param outwave: - Desired output wavelength vector. - - :param sigma_out: - Desired velocity resolution (km/s), *not* FWHM. Scalar or length 1 array. - - :param inres: - The velocity resolution of the input spectrum (km/s), dispersion *not* - FWHM. - """ - # The kernel width for the convolution. - sigma = np.sqrt(sigma_out**2 - inres**2) - if sigma <= 0: - return np.interp(outwave, wavelength, spectrum) - - # make length of spectrum a power of 2 by resampling - wave, spec = resample_wave(wavelength, spectrum) - - # get grid resolution (*not* the resolution of the input spectrum) and make - # sure it's nearly constant. It should be, by design (see resample_wave) - invRgrid = np.diff(np.log(wave)) - assert invRgrid.max() / invRgrid.min() < 1.05 - dv = ckms * np.median(invRgrid) - - # Do the convolution - spec_conv = smooth_fft(dv, spec, sigma) - # interpolate onto output grid - if outwave is not None: - spec_conv = np.interp(outwave, wave, spec_conv) - - return spec_conv - - -def smooth_wave(wave, spec, outwave, sigma, nsigma=10, inres=0, in_vel=False, - **extras): - """Smooth a spectrum in wavelength space. This is insanely slow, but - general and correct (except for the treatment of the input resolution if it - is velocity) - - :param wave: - Wavelength vector of the input spectrum. - - :param spec: - Flux vector of the input spectrum. - - :param outwave: - Desired output wavelength vector. - - :param sigma: - Desired resolution (*not* FWHM) in wavelength units. This can be a - vector of same length as ``wave``, in which case a wavelength dependent - broadening is calculated - - :param nsigma: (optional, default=10) - Number of sigma away from the output wavelength to consider in the - integral. If less than zero, all wavelengths are used. Setting this - to some positive number decreses the scaling constant in the O(N_out * - N_in) algorithm used here. - - :param inres: (optional, default: 0.0) - Resolution of the input, in either wavelength units or - lambda/dlambda (c/v). Ignored if <= 0. - - :param in_vel: (optional, default: False) - If True, the input spectrum has been smoothed in velocity - space, and ``inres`` is assumed to be in lambda/dlambda. - - :returns flux: - The output smoothed flux vector, same length as ``outwave``. - """ - # sigma_eff is in angstroms - if inres <= 0: - sigma_eff_sq = sigma**2 - elif in_vel: - # Make an approximate correction for the intrinsic wavelength - # dependent dispersion. This sort of maybe works. - sigma_eff_sq = sigma**2 - (wave / inres)**2 - else: - sigma_eff_sq = sigma**2 - inres**2 - if np.any(sigma_eff_sq < 0): - raise ValueError("Desired wavelength sigma is lower than the value " - "possible for this input spectrum.") - - sigma_eff = np.sqrt(sigma_eff_sq) - flux = np.zeros(len(outwave)) - for i, w in enumerate(outwave): - x = (wave - w) / sigma_eff - if nsigma > 0: - good = np.abs(x) < nsigma - x = x[good] - _spec = spec[good] - else: - _spec = spec - f = np.exp(-0.5 * x**2) - flux[i] = np.trapz(f * _spec, x) / np.trapz(f, x) - return flux - - -def smooth_wave_fft(wavelength, spectrum, outwave, sigma_out=1.0, - inres=0.0, **extras): - """Smooth a spectrum in wavelength space, using FFTs. This is fast, but - makes some assumptions about the input spectrum, and can have some - issues at the ends of the spectrum depending on how it is padded. - - :param wavelength: - Wavelength vector of the input spectrum. - - :param spectrum: - Flux vector of the input spectrum. - - :param outwave: - Desired output wavelength vector. - - :param sigma: - Desired resolution (*not* FWHM) in wavelength units. - - :param inres: - Resolution of the input, in wavelength units (dispersion not FWHM). - - :returns flux: - The output smoothed flux vector, same length as ``outwave``. - """ - # restrict wavelength range (for speed) - # should also make nearest power of 2 - wave, spec = resample_wave(wavelength, spectrum, linear=True) - - # The kernel width for the convolution. - sigma = np.sqrt(sigma_out**2 - inres**2) - if sigma < 0: - return np.interp(wave, outwave, flux) - - # get grid resolution (*not* the resolution of the input spectrum) and make - # sure it's nearly constant. Should be by design (see resample_wave) - Rgrid = np.diff(wave) - assert Rgrid.max() / Rgrid.min() < 1.05 - dw = np.median(Rgrid) - - # Do the convolution - spec_conv = smooth_fft(dw, spec, sigma) - # interpolate onto output grid - if outwave is not None: - spec_conv = np.interp(outwave, wave, spec_conv) - return spec_conv - - -def smooth_lsf(wave, spec, outwave, sigma=None, lsf=None, return_kernel=False, - **kwargs): - """Broaden a spectrum using a wavelength dependent line spread function. - This function is only approximate because it doesn't actually do the - integration over pixels, so for sparsely sampled points you'll have - problems. This function needs to be checked and possibly rewritten. - - :param wave: - Input wavelengths. ndarray of shape (nin,) - - :param spec: - Input spectrum. ndarray of same shape as ``wave``. - - :param outwave: - Output wavelengths, ndarray of shape (nout,) - - :param sigma: (optional, default: None) - The dispersion (not FWHM) as a function of wavelength that you want to - apply to the input spectrum. ``None`` or ndarray of same length as - ``outwave``. If ``None`` then the wavelength dependent dispersion will be - calculated from the function supplied with the ``lsf`` keyward. - - :param lsf: - A function that returns the gaussian dispersion at each wavelength. - This is assumed to be in sigma, not FWHM. - - :param kwargs: - Passed to the function supplied in the ``lsf`` keyword. - - :param return_kernel: (optional, default: False) - If True, return the kernel used to broaden the spectrum as ndarray of - shape (nout, nin). - - :returns newspec: - The broadened spectrum, same length as ``outwave``. - """ - if (lsf is None) and (sigma is None): - return np.interp(outwave, wave, spec) - dw = np.gradient(wave) - if sigma is None: - sigma = lsf(outwave, **kwargs) - kernel = outwave[:, None] - wave[None, :] - kernel = (1 / (sigma * np.sqrt(np.pi * 2))[:, None] * - np.exp(-kernel**2 / (2 * sigma[:, None]**2)) * - dw[None, :]) - # should this be axis=0 or axis=1? - kernel = kernel / kernel.sum(axis=1)[:, None] - newspec = np.dot(kernel, spec) - # kernel /= np.trapz(kernel, wave, axis=1)[:, None] - # newspec = np.trapz(kernel * spec[None, :], wave, axis=1) - if return_kernel: - return newspec, kernel - return newspec - - -def smooth_lsf_fft(wave, spec, outwave, sigma=None, lsf=None, pix_per_sigma=2, - eps=0.25, preserve_all_input_frequencies=False, **kwargs): - """Smooth a spectrum by a wavelength dependent line-spread function, using - FFTs. - - :param wave: - Wavelength vector of the input spectrum. - - :param spectrum: - Flux vector of the input spectrum. - - :param outwave: - Desired output wavelength vector. - - :param sigma: (optional) - Dispersion (in same units as ``wave``) as a function `wave`. ndarray - of same length as ``wave``. If not given, sigma will be computed from - the function provided by the ``lsf`` keyword. - - :param lsf: (optional) - Function used to calculate the dispersion as a function of wavelength. - Must be able to take as an argument the ``wave`` vector and any extra - keyword arguments and return the dispersion (in the same units as the - input wavelength vector) at every value of ``wave``. If not provided - then ``sigma`` must be specified. - - :param pix_per_sigma: (optional, default: 2) - Number of pixels per sigma of the smoothed spectrum to use in - intermediate interpolation and FFT steps. Increasing this number will - increase the accuracy of the output (to a point), and the run-time, by - preserving all high-frequency information in the input spectrum. - - :param preserve_all_input_frequencies: (default: False) - This is a switch to use a very dense sampling of the input spectrum - that preserves all input frequencies. It can significantly increase - the call time for often modest gains... - - :param eps: (optional) - Deprecated. - - :param **kwargs: - All additional keywords are passed to the function supplied to the - ``lsf`` keyword, if present. - - :returns flux: - The input spectrum smoothed by the wavelength dependent line-spread - function. Same length as ``outwave``. - """ - # This is sigma vs lambda - if sigma is None: - sigma = lsf(wave, **kwargs) - - # Now we need the CDF of 1/sigma, which provides the relationship between x and lambda - # does dw go in numerator or denominator? - # I think numerator but should be tested - dw = np.gradient(wave) - cdf = np.cumsum(dw / sigma) - cdf /= cdf.max() - - # Now we create an evenly sampled grid in the x coordinate on the interval [0,1] - # and convert that to lambda using the cdf. - # This should result in some power of two x points, for FFT efficiency - - # Furthermore, the number of points should be high enough that the - # resolution is critically sampled. And we want to know what the - # resolution is in this new coordinate. - # There are two possible ways to do this - - # 1) Choose a point ~halfway in the spectrum - # half = len(wave) / 2 - # Now get the x coordinates of a point eps*sigma redder and bluer - # wave_eps = eps * np.array([-1, 1]) * sigma[halpha] - # x_h_eps = np.interp(wave[half] + wave_eps, wave, cdf) - # Take the differences to get dx and dsigma and ratio to get x per sigma - # x_per_sigma = np.diff(x_h_eps) / (2.0 * eps) #x_h_epsilon - x_h - - # 2) Get for all points (slower?): - sigma_per_pixel = (dw / sigma) - x_per_pixel = np.gradient(cdf) - x_per_sigma = np.nanmedian(x_per_pixel / sigma_per_pixel) - N = pix_per_sigma / x_per_sigma - - # Alternatively, just use the smallest dx of the input, divided by two for safety - # Assumes the input spectrum is critically sampled. - # And does not actually give x_per_sigma, so that has to be determined anyway - if preserve_all_input_frequencies: - # preserve more information in the input spectrum, even when way higher - # frequency than the resolution of the output. Leads to slightly more - # accurate output, but with a substantial time hit - N = max(N, 1.0 / np.nanmin(x_per_pixel)) - - # Now find the smallest power of two that divides the interval (0, 1) into - # segments that are smaller than dx - nx = int(2**np.ceil(np.log2(N))) - - # now evenly sample in the x coordinate - x = np.linspace(0, 1, nx) - dx = 1.0 / nx - - # And now we get the spectrum at the lambda coordinates of the even grid in x - lam = np.interp(x, cdf, wave) - newspec = np.interp(lam, wave, spec) - - # And now we convolve. - # If we did not know sigma in terms of x we could estimate it here - # from the resulting sigma(lamda(x)) / dlambda(x): - # dlam = np.gradient(lam) - # sigma_x = np.median(lsf(lam, **kwargs) / dlam) - # But the following just uses the fact that we know x_per_sigma (duh). - spec_conv = smooth_fft(dx, newspec, x_per_sigma) - - # and interpolate back to the output wavelength grid. - return np.interp(outwave, lam, spec_conv) - - -def smooth_fft(dx, spec, sigma): - """Basic math for FFT convolution with a gaussian kernel. - - :param dx: - The wavelength or velocity spacing, same units as sigma - - :param sigma: - The width of the gaussian kernel, same units as dx - - :param spec: - The spectrum flux vector - """ - # The Fourier coordinate - ss = rfftfreq(len(spec), d=dx) - # Make the fourier space taper; just the analytical fft of a gaussian - taper = np.exp(-2 * (np.pi ** 2) * (sigma ** 2) * (ss ** 2)) - ss[0] = 0.01 # hack - # Fourier transform the spectrum - spec_ff = np.fft.rfft(spec) - # Multiply in fourier space - ff_tapered = spec_ff * taper - # Fourier transform back - spec_conv = np.fft.irfft(ff_tapered) - return spec_conv - - -def mask_wave(wavelength, width=1, wlo=0, whi=np.inf, outwave=None, - nsigma_pad=20.0, linear=False, **extras): - """Restrict wavelength range (for speed) but include some padding based on - the desired resolution. - """ - # Base wavelength limits - if outwave is not None: - wlim = np.array([outwave.min(), outwave.max()]) - else: - wlim = np.squeeze(np.array([wlo, whi])) - # Pad by nsigma * sigma_wave - if linear: - wlim += nsigma_pad * width * np.array([-1, 1]) - else: - wlim *= (1 + nsigma_pad / width * np.array([-1, 1])) - mask = (wavelength > wlim[0]) & (wavelength < wlim[1]) - return mask - - -def resample_wave(wavelength, spectrum, linear=False): - """Resample spectrum, so that the number of elements is the next highest - power of two. This uses np.interp. Note that if the input wavelength grid - did not critically sample the spectrum then there is no gaurantee the - output wavelength grid will. - """ - wmin, wmax = wavelength.min(), wavelength.max() - nw = len(wavelength) - nnew = int(2.0**(np.ceil(np.log2(nw)))) - if linear: - Rgrid = np.diff(wavelength) # in same units as ``wavelength`` - w = np.linspace(wmin, wmax, nnew) - else: - Rgrid = np.diff(np.log(wavelength)) # actually 1/R - lnlam = np.linspace(np.log(wmin), np.log(wmax), nnew) - w = np.exp(lnlam) - # Make sure the resolution really is nearly constant - #assert Rgrid.max() / Rgrid.min() < 1.05 - s = np.interp(w, wavelength, spectrum) - return w, s - - -def subtract_input_resolution(res_in, res_target, smoothtype_in, smoothtype_target, wave=None): - """Subtract the input resolution (in quadrature) from a target output - resolution to get the width of the kernel that will convolve the input to - the output. Assumes all convolutions are with gaussians. - """ - if smoothtype_in == "R": - width_in = 1.0 / res_in - else: - width_in = res_in - if smoothtype_target == "R": - width_target = 1.0 / res_target - else: - width_target = res_target - - if smoothtype_in == smoothtype_target: - dwidth_sq = width_target**2 - width_in**2 - - elif (smoothtype_in == "vel") & (smoothype_target == "lambda"): - dwidth_sq = width_target**2 - (wave * width_in / ckms)**2 - - elif (smoothtype_in == "R") & (smoothype_target == "lambda"): - dwidth_sq = width_target**2 - (wave * width_in)**2 - - elif (smoothtype_in == "lambda") & (smoothtype_target == "vel"): - dwidth_sq = width_target**2 - (ckms * width_in / wave)**2 - - elif (smoothtype_in == "lambda") & (smoothtype_target == "R"): - dwidth_sq = width_target**2 - (width_in / wave)**2 - - elif (smoothtype_in == "R") & (smoothtype_target == "vel"): - print("srsly?") - return None - elif (smoothtype_in == "vel") & (smoothtype_target == "R"): - print("srsly?") - return None - - if np.any(dwidth_sq <= 0): - print("Warning: Desired resolution is better than input resolution") - dwidth_sq = np.clip(dwidth_sq, 0, np.inf) - - if smoothtype_target == "R": - return 1.0 / np.sqrt(dwidth_sq) - else: - return np.sqrt(dwidth_sq) - - return delta_width diff --git a/prospect/utils/plotting.py b/prospect/utils/stats.py similarity index 61% rename from prospect/utils/plotting.py rename to prospect/utils/stats.py index 9c1940e0..91d24dd2 100644 --- a/prospect/utils/plotting.py +++ b/prospect/utils/stats.py @@ -7,13 +7,49 @@ except(ImportError): pass -from ..plotting.utils import get_best __all__ = ["get_best", "get_truths", "get_percentiles", "get_stats", "posterior_samples", "hist_samples", "joint_pdf", "compute_sigma_level", "trim_walkers", "fill_between", "figgrid"] +def flatstruct(struct): + params = struct.dtype.names + m = [struct[s] for s in params] + return np.concatenate(m), params + + +def get_best(res, **kwargs): + """Get the maximum a posteriori parameters and their names + + :param res: + A ``results`` dictionary with the keys 'lnprobability', 'chain', and + 'theta_labels' + + :returns theta_names: + List of strings giving the names of the parameters, of length ``ndim`` + + :returns best: + ndarray with shape ``(ndim,)`` of parameter values corresponding to the + sample with the highest posterior probaility + """ + qbest, qnames = flatstruct(best_sample(res)) + return qnames, qbest + + +def best_sample(res): + """Get the posterior sample with the highest posterior probability. + """ + imax = np.argmax(res['lnprobability']) + # there must be a more elegant way to deal with differnt shapes + try: + i, j = np.unravel_index(imax, res['lnprobability'].shape) + Qbest = res['chain'][i, j].copy() + except(ValueError): + Qbest = res['chain'][imax].copy() + return Qbest + + def get_truths(res): import pickle try: @@ -48,16 +84,22 @@ def get_percentiles(res, ptile=[16, 50, 84], start=0.0, thin=1, **extras): requested percentiles for that parameter. """ - parnames = np.array(res.get('theta_labels', res['model'].theta_labels())) - niter = res['chain'].shape[-2] + chaincat = res["chain"] + niter = res['chain'].shape[0] + parnames = chaincat.dtype.names + weights = res.get("weights", None) + + start_index = np.floor(start * (niter-1)).astype(int) - if res["chain"].ndim > 2: - flatchain = res['chain'][:, start_index::thin, :] - dims = flatchain.shape - flatchain = flatchain.reshape(dims[0]*dims[1], dims[2]) - elif res["chain"].ndim == 2: - flatchain = res["chain"][start_index::thin, :] - pct = np.array([quantile(p, ptile, weights=res.get("weights", None)) for p in flatchain.T]) + if res["chain"].ndim > 1: + flatchain = res['chain'][:, start_index::thin] + flatchain = flatchain.reshape(-1) + elif res["chain"].ndim == 1: + flatchain = res["chain"][start_index::thin] + chain = flatstruct(chaincat) + + pct = [quantile(x, ptile, weights=weights, axis=0) + for x in chain.T] return dict(zip(parnames, pct)) @@ -111,38 +153,6 @@ def trim_walkers(res, threshold=-1e4): return trimmed -def joint_pdf(res, p1, p2, pmap={}, **kwargs): - """Build a 2-dimensional array representing the binned joint PDF of 2 - parameters, in terms of sigma or fraction of the total distribution. - - For example, to plot contours of the joint PDF of parameters ``"parname1"`` - and ``"parname2"`` from the last half of a chain with 30bins in each - dimension; - - :: - - xb, yb, sigma = joint_pdf(res, parname1, parname2, nbins=30, start=0.5) - ax.contour(xb, yb, sigma, **plotting_kwargs) - - :param p1: - The name of the parameter for the x-axis - - :param p2: - The name of the parameter for the y axis - - :returns xb, yb, sigma: - The bins and the 2-d histogram - """ - trace, pars = hist_samples(res, [p1, p2], **kwargs) - trace = trace.copy().T - if pars[0] == p1: - trace = trace[::-1, :] - x = pmap.get(p2, lambda x: x)(trace[0]) - y = pmap.get(p1, lambda x: x)(trace[1]) - xbins, ybins, sigma = compute_sigma_level(x, y, **kwargs) - return xbins, ybins, sigma.T - - def posterior_samples(res, nsample=None, **kwargs): """Pull samples of theta from the MCMC chain @@ -211,64 +221,5 @@ def hist_samples(res, showpars=None, start=0, thin=1, return flatchain, parnames[ind_show] -def compute_sigma_level(trace1, trace2, nbins=30, weights=None, extents=None, **extras): - """From a set of traces in two parameters, make a 2-d histogram of number - of standard deviations. Following examples from J Vanderplas. - """ - L, xbins, ybins = np.histogram2d(trace1, trace2, bins=nbins, - weights=weights, - range=extents) - L[L == 0] = 1E-16 - logL = np.log(L) - - shape = L.shape - L = L.ravel() - - # obtain the indices to sort and unsort the flattened array - i_sort = np.argsort(L)[::-1] - i_unsort = np.argsort(i_sort) - - L_cumsum = L[i_sort].cumsum() - L_cumsum /= L_cumsum[-1] - - xbins = 0.5 * (xbins[1:] + xbins[:-1]) - ybins = 0.5 * (ybins[1:] + ybins[:-1]) - - return xbins, ybins, L_cumsum[i_unsort].reshape(shape) - - -def figgrid(ny, nx, figsize=None, left=0.1, right=0.85, - top=0.9, bottom=0.1, wspace=0.2, hspace=0.10): - """Gridpars is - left, right - """ - from matplotlib import gridspec - if figsize is None: - figsize = (nx*4.5, ny*3) - fig = pl.figure(figsize=figsize) - axarray = np.zeros([ny, nx], dtype=np.dtype('O')) - gs1 = gridspec.GridSpec(ny, nx) - gs1.update(left=left, right=right, top=top, bottom=bottom, - wspace=wspace, hspace=hspace) - for i in range(ny): - for j in range(nx): - axarray[i, j] = fig.add_subplot(gs1[i, j]) - return fig, axarray - - -def fill_between(x, y1, y2=0, ax=None, **kwargs): - """Plot filled region between `y1` and `y2`. - - This function works exactly the same as matplotlib's fill_between, except - that it also plots a proxy artist (specifically, a rectangle of 0 size) - so that it can be added it appears on a legend. - """ - ax = ax if ax is not None else pl.gca() - ax.fill_between(x, y1, y2, **kwargs) - p = pl.Rectangle((0, 0), 0, 0, **kwargs) - ax.add_patch(p) - return p - - def logify(x): return np.log10(x) diff --git a/pyproject.toml b/pyproject.toml index e83a3d11..233c84c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ authors = [ { name="Ben Johnson", email="benjamin.johnson@cfa.harvard.edu" }, ] readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.9" license = { text = "MIT License" } classifiers = [ "Development Status :: 5 - Production/Stable", @@ -21,10 +21,9 @@ dependencies = ["numpy", "h5py"] [project.optional-dependencies] test = ["pytest", "pytest-xdist"] - [tool.setuptools] packages = ["prospect", - "prospect.models", "prospect.sources", + "prospect.models", "prospect.sources", "prospect.observation", "prospect.likelihood", "prospect.fitting", "prospect.io", "prospect.plotting", "prospect.utils"] diff --git a/requirements.txt b/requirements.txt index 8947ebd2..bb87a770 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ +python >= 3.9 numpy >= 1.14.2 scipy >= 1.1.0 astropy h5py -astro-sedpy +astro-sedpy >= 0.3.0 six \ No newline at end of file diff --git a/misc/test_compsp.py b/tests/misc/test_compsp.py similarity index 100% rename from misc/test_compsp.py rename to tests/misc/test_compsp.py diff --git a/misc/test_sft.py b/tests/misc/test_sft.py similarity index 100% rename from misc/test_sft.py rename to tests/misc/test_sft.py diff --git a/misc/test_stepsfh.py b/tests/misc/test_stepsfh.py similarity index 100% rename from misc/test_stepsfh.py rename to tests/misc/test_stepsfh.py diff --git a/misc/timing_smoothspec.py b/tests/misc/timing_smoothspec.py similarity index 100% rename from misc/timing_smoothspec.py rename to tests/misc/timing_smoothspec.py diff --git a/misc/timings_pyfsps.py b/tests/misc/timings_pyfsps.py similarity index 100% rename from misc/timings_pyfsps.py rename to tests/misc/timings_pyfsps.py diff --git a/misc/ztest.py b/tests/misc/ztest.py similarity index 100% rename from misc/ztest.py rename to tests/misc/ztest.py diff --git a/tests/test_agn_eline.py b/tests/test_agn_eline.py index 3fca249d..a1090bc1 100644 --- a/tests/test_agn_eline.py +++ b/tests/test_agn_eline.py @@ -1,11 +1,10 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from copy import deepcopy import numpy as np from sedpy import observate -from prospect.utils.obsutils import fix_obs +from prospect.observation import Spectrum, Photometry from prospect.models.sedmodel import AGNSpecModel from prospect.models.templates import TemplateLibrary from prospect.sources import CSPSpecBasis @@ -19,11 +18,14 @@ def test_agn_elines(): fnames = [f"sdss_{b}0" for b in "ugriz"] filts = observate.load_filters(fnames) - obs = dict(filters=filts, - wavelength=np.linspace(3000, 9000, 1000), - spectrum=np.ones(1000), - unc=np.ones(1000)*0.1) - obs = fix_obs(obs) + phot = Photometry(filters=filts, + flux=np.ones(len(filts)), + uncertainty=0.1 * np.ones(len(filts))) + spec = Spectrum(wavelength=np.linspace(3000, 9000, 1000), + flux=np.ones(1000), + uncertainty=np.ones(1000)*0.1) + obs = [spec, phot] + [ob.rectify() for ob in obs] # --- model --- model_pars = TemplateLibrary["parametric_sfh"] @@ -36,26 +38,21 @@ def test_agn_elines(): model = AGNSpecModel(model_pars) model.params["agn_elum"] = 1e-4 - spec0, phot0, x0 = model.predict(model.theta, obs, sps) + (spec0, phot0), x0 = model.predict(model.theta, obs, sps) model.params["agn_elum"] = 1e-6 - spec1, phot1, x1 = model.predict(model.theta, obs, sps) + (spec1, phot1), x1 = model.predict(model.theta, obs, sps) assert (not np.allclose(spec1, spec0)), "changing AGN luminosity had no effect" model.params["agn_elum"] = 1e-4 model.params["agn_eline_sigma"] = 400.0 - spec2, phot2, x2 = model.predict(model.theta, obs, sps) + (spec2, phot2), x2 = model.predict(model.theta, obs, sps) assert (not np.allclose(spec2, spec0)), "broadening lines had no effect on the spectrum" assert np.allclose(phot2, phot0), "broadening lines changed the photometry" # do a check for phot-only obs - pobs = dict(filters=filts, - maggies=np.ones(len(filts)), - maggies_unc=0.1 * np.ones(len(filts)), - wavelength=np.linspace(3000, 9000, 1000), - spectrum=None) - pobs = fix_obs(pobs) - spec3, phot3, x2 = model.predict(model.theta, obs=pobs, sps=sps) + pobs = [phot] + (phot3), x2 = model.predict(model.theta, observations=pobs, sps=sps) assert np.allclose(phot3, phot2), "Phot-only obs did not add AGn lines correctly" if False: diff --git a/tests/test_cue_eline.py b/tests/test_cue_eline.py new file mode 100644 index 00000000..c6858c54 --- /dev/null +++ b/tests/test_cue_eline.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import numpy as np + +import pytest + +from sedpy import observate + +from prospect.observation import from_oldstyle +from prospect.models.templates import TemplateLibrary +from prospect.models.sedmodel import SpecModel +from prospect.sources.nebssp_basis import NebSSPBasis, NebStepBasis + +@pytest.fixture +def get_sps(): + sps = NebSSPBasis(zcontinuous=1) + return sps + + +# test nebular line specification +def test_eline_parsing(): + model_pars = TemplateLibrary["parametric_sfh"] + model_pars.update(TemplateLibrary["cue_stellar_nebular"]) + + # test ignoring a line + lya = "Ly-alpha 1215" + model_pars["elines_to_ignore"] = dict(init=lya, isfree=False) + model = SpecModel(model_pars) + model.parse_elines() + assert not np.isin(lya, model.emline_info["name"][model._use_eline]) + assert np.isin("Ba-alpha 6563", model.emline_info["name"][model._use_eline]) + assert np.all(model._fix_eline) + assert model._use_eline.sum() == len(model._use_eline) - 1 + assert len(model._use_eline) == len(model.emline_info) + + # test fitting all the non-ignored lines + model_pars["marginalize_elines"] = dict(init=True) + model = SpecModel(model_pars) + model.parse_elines() + assert model._fit_eline.sum() == len(model._use_eline) + + # test fitting just a line or two + fit_lines = ["[O III] 5007"] + model_pars["elines_to_fit"] = dict(init=fit_lines) + model = SpecModel(model_pars) + model.parse_elines() + assert model._fit_eline.sum() == len(fit_lines) + assert model._fix_eline.sum() == (len(model._use_eline) - len(fit_lines)) + + # test fixing a line + _ = model_pars.pop("elines_to_fit") + fix_lines = ["H beta 4861"] + model_pars["elines_to_fix"] = dict(init=fit_lines) + model = SpecModel(model_pars) + model.parse_elines() + assert model._fix_eline.sum() == len(fix_lines) + assert model._fit_eline.sum() == (len(model._use_eline) - len(fix_lines)) + + +def build_obs(filts): + obs = dict(filters=filts, + wavelength=np.linspace(3000, 9000, 1000), + spectrum=np.ones(1000), + unc=np.ones(1000)*0.1, + maggies=np.ones(len(filts))*1e-7, + maggies_unc=np.ones(len(filts))*1e-8) + sdat, pdat = from_oldstyle(obs) + obslist = [sdat, pdat] + [obs.rectify() for obs in obslist] + return obslist + + +def test_nebline_phot_addition(get_sps): + fnames = [f"sdss_{b}0" for b in "ugriz"] + filts = observate.load_filters(fnames) + obslist = build_obs(filts) + + sps = get_sps + + # Make emission lines more prominent + zred = 1.0 + + # add nebline photometry in FSPS + model_pars = TemplateLibrary["parametric_sfh"] + model_pars["zred"]["init"] = zred + model_pars.update(TemplateLibrary["cue_stellar_nebular"]) + m1 = SpecModel(model_pars) + + # adding nebline photometry by hand + model_pars = TemplateLibrary["parametric_sfh"] + model_pars["zred"]["init"] = zred + model_pars.update(TemplateLibrary["cue_stellar_nebular"]) + model_pars["nebemlineinspec"]["init"] = False + m2 = SpecModel(model_pars) + + (s1, p1), _ = m1.predict(m1.theta, obslist, sps) + (s2, p2), _ = m2.predict(m2.theta, obslist, sps) + + # make sure some of the lines were important + p1n = m1.nebline_photometry(filts) + assert np.any(p1n / p1[1] > 0.05) + + # make sure you got the same-ish answer + assert np.all((np.abs(p1 - p2) / p1) < 1e-2) + + +def test_filtersets(get_sps): + """This test no longer relevant..... + """ + fnames = [f"sdss_{b}0" for b in "ugriz"] + flist = observate.load_filters(fnames) + obslist = build_obs(flist) + sdat, pdat = obslist + + sps = get_sps + + # Make emission lines more prominent + zred = 0.5 + models = [] + + # test SpecModel no nebular emission + model_pars = TemplateLibrary["parametric_sfh"] + model_pars["zred"]["init"] = zred + models.append(SpecModel(model_pars)) + + # test SpecModel with nebular emission added by SpecModel + model_pars = TemplateLibrary["parametric_sfh"] + model_pars["zred"]["init"] = zred + model_pars.update(TemplateLibrary["cue_stellar_nebular"]) + model_pars["nebemlineinspec"]["init"] = False + models.append(SpecModel(model_pars)) + + for i, model in enumerate(models): + (_, pset), _ = model.predict(model.theta, obslist, sps) + + # make sure some of the filters are affected by lines + # ( nebular flux > 10% of total flux) + if i == 1: + nebphot = model.nebline_photometry(pdat.filterset) + assert np.any(nebphot / pset > 0.1) + + # make sure photometry is consistent + # make sure we actually used different filter types + # We always use filtersets now + + +def test_eline_implementation(get_sps, plot=False): + + test_eline_parsing() + + filters = observate.load_filters([f"sdss_{b}0" for b in "ugriz"]) + obslist = build_obs(filters) + + model_pars = TemplateLibrary["parametric_sfh"] + model_pars.update(TemplateLibrary["cue_stellar_nebular"]) + model_pars["nebemlineinspec"]["init"] = False + model_pars["eline_sigma"] = dict(init=500) + model_pars["zred"]["init"] = 4 + model = SpecModel(model_pars) + + sps = get_sps + + # generate with all fixed lines added + (spec, phot), mfrac = model.predict(model.theta, obslist, sps=sps) + + # test ignoring a line + lya = "Ly-alpha 1215" + model_pars["elines_to_ignore"] = dict(init=lya, isfree=False) + model = SpecModel(model_pars) + (spec_nolya, phot_nolya), mfrac = model.predict(model.theta, obslist, sps=sps) + assert np.any((phot - phot_nolya) / phot != 0.0) + lint = np.trapz(spec - spec_nolya, obslist[0]["wavelength"]) + assert lint > 0 + + # test igoring a line, phot only + model = SpecModel(model_pars) + (phot_nolya_2,), mfrac = model.predict(model.theta, [obslist[1]], sps=sps) + assert np.all(phot_nolya == phot_nolya_2) + + if plot: + import matplotlib.pyplot as pl + pl.ion() + fig, ax = pl.subplots() + ax.plot(obslist[0]["wavelength"], spec) + ax.plot(obslist[0]["wavelength"], spec_nolya) + + +#def test_marginalizing_lines(): + # test marginalizing over lines + #model_pars["marginalize_elines"] = dict(init=True) + #model = SpecModel(model_pars) diff --git a/tests/test_dla.py b/tests/test_dla.py new file mode 100644 index 00000000..ebe61613 --- /dev/null +++ b/tests/test_dla.py @@ -0,0 +1,134 @@ +import sys +import numpy as np + +import pytest + +from prospect.sources import CSPSpecBasis +from prospect.models import SpecModel, templates, priors +from prospect.observation import Spectrum, Photometry + + +@pytest.fixture +def build_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(free_dla=True, damping_wing=False): + model_params = templates.TemplateLibrary["parametric_sfh"] + model_params["zred"]["isfree"] = True + model_params.update(templates.TemplateLibrary["nebular"]) + model_params["nebemlineinspec"]["init"] = False + lya = "Ly-alpha 1215" + model_params["elines_to_ignore"] = dict(init=lya, isfree=False) + + # scaling igm_factor is something like x_HI + model_params.update(templates.TemplateLibrary["igm"]) + model_params["igm_factor"]["isfree"] = True + + # Add the dla column density parameter + model_params["dla_logNh"] = dict(N=1, isfree=free_dla, init=18, + prior=priors.Uniform(mini=18, maxi=23)) + + # Add damping wing switch + model_params["igm_damping"] = dict(N=1, isfree=False, init=damping_wing) + + + return SpecModel(model_params) + + +def build_obs(): + + N = 1000 + wave = np.linspace(0.7e4, 4e4, N) + + lw = ["090w", "115w", "150w", "162m", "182m", "200w", "210m"] + sw = ["250m", "277w", "300m", "335m", "356w", "410m", "444w"] + fnames = list([f"jwst_f{b}" for b in sw+lw]) + Nf = len(fnames) + phot = [Photometry(filters=fnames, flux=np.ones(Nf), uncertainty=np.ones(Nf)/10)] + spec = [Spectrum(wavelength=wave, flux=np.ones(N), + uncertainty=np.ones(N) / 10, mask=slice(None))] + + obslist = spec + phot + [obs.rectify() for obs in obslist] + return obslist + + +def test_dla(build_sps, plot=False): + sps = build_sps + + obs = build_obs() + model = build_model() + + model.params["zred"] = 13 + model.params["dla_logNh"] = 0 + model.params["tage"] = 0.4 + model.params["tau"] = 0.2 + model.params["dust2"] = 0.0 + theta1 = model.theta.copy() + + pred1 = model.predict(theta1, obs, sps) + (spec1, phot1), mfrac = pred1 + + if plot: + import matplotlib.pyplot as pl + #pl.ion() + fig, ax = pl.subplots() + ax.plot(obs[0].wavelength/1e4, spec1) + ax.plot(obs[1].wavelength/1e4, phot1, "o") + + for nh in [21.5, 22, 23]: + model.params["dla_logNh"] = nh + theta = model.theta.copy() + pred = model.predict(theta, obs, sps) + (spec, phot), mfrac = pred + assert np.any(phot < phot1) + assert np.any(spec < spec1) + if plot: + ax.plot(obs[0].wavelength/1e4, spec, label=f"log N_h={nh}") + + if plot: + ax.plot(obs[1].wavelength/1e4, phot, "o", label=f"log N_h={nh}") + ax.legend() + ax.set_xlabel(r"$\lambda (\mu{\rm m})$") + ax.set_ylabel(r"$f_\nu$") + fig.savefig("prospector_dla.png") + + +def test_damping(build_sps, plot=False): + + sps = build_sps + + obs = build_obs() + model = build_model(damping_wing=False) + + model.params["zred"] = 13 + model.params["dla_logNh"] = 0 + model.params["tage"] = 0.4 + model.params["tau"] = 0.2 + model.params["dust2"] = 0.0 + model.params["igm_damping"] = False + theta1 = model.theta.copy() + + pred1 = model.predict(theta1, obs, sps) + (spec1, phot1), mfrac = pred1 + model.params["igm_damping"] = True + pred2 = model.predict(theta1, obs, sps) + (spec2, phot2), mfrac = pred2 + assert np.any(phot2 < phot1) + assert np.any(spec2 < spec1) + + if plot: + import matplotlib.pyplot as pl + fig, axes = pl.subplots(2, 1, sharex=True) + ax = axes[0] + ax.plot(obs[0].wavelength/1e4, spec2/spec1, label="e^{-tau}") + ax.legend() + ax = axes[1] + ax.plot(obs[0].wavelength/1e4, spec1, label="No damping wing") + ax.plot(obs[0].wavelength/1e4, spec2, label="with Damping wing") + ax.legend() + ax.set_xlabel(r"$\lambda (\mu{\rm m})$") + ax.set_ylabel(r"$f_\nu$") + fig.savefig("prospector_damping_wing.png") diff --git a/tests/test_eline.py b/tests/test_eline.py index 78acc6b1..3027a21d 100644 --- a/tests/test_eline.py +++ b/tests/test_eline.py @@ -3,28 +3,34 @@ import numpy as np +import pytest + from sedpy import observate -from prospect import prospect_args -from prospect.utils.obsutils import fix_obs +from prospect.observation import from_oldstyle from prospect.models.templates import TemplateLibrary -from prospect.models.sedmodel import SpecModel, SedModel - +from prospect.models.sedmodel import SpecModel from prospect.sources import CSPSpecBasis +@pytest.fixture +def get_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + # test nebular line specification def test_eline_parsing(): model_pars = TemplateLibrary["parametric_sfh"] model_pars.update(TemplateLibrary["nebular"]) # test ignoring a line - lya = "Ly alpha 1216" + lya = "Ly-alpha 1215" model_pars["elines_to_ignore"] = dict(init=lya, isfree=False) model = SpecModel(model_pars) model.parse_elines() assert not np.isin(lya, model.emline_info["name"][model._use_eline]) - assert np.isin("H alpha 6563", model.emline_info["name"][model._use_eline]) + assert np.isin("Ba-alpha 6563", model.emline_info["name"][model._use_eline]) assert np.all(model._fix_eline) assert model._use_eline.sum() == len(model._use_eline) - 1 assert len(model._use_eline) == len(model.emline_info) @@ -36,7 +42,7 @@ def test_eline_parsing(): assert model._fit_eline.sum() == len(model._use_eline) # test fitting just a line or two - fit_lines = ["[OIII]5007"] + fit_lines = ["[O III] 5007"] model_pars["elines_to_fit"] = dict(init=fit_lines) model = SpecModel(model_pars) model.parse_elines() @@ -53,17 +59,25 @@ def test_eline_parsing(): assert model._fit_eline.sum() == (len(model._use_eline) - len(fix_lines)) -def test_nebline_phot_addition(): - fnames = [f"sdss_{b}0" for b in "ugriz"] - filts = observate.load_filters(fnames) - +def build_obs(filts): obs = dict(filters=filts, wavelength=np.linspace(3000, 9000, 1000), spectrum=np.ones(1000), - unc=np.ones(1000)*0.1) - obs = fix_obs(obs) + unc=np.ones(1000)*0.1, + maggies=np.ones(len(filts))*1e-7, + maggies_unc=np.ones(len(filts))*1e-8) + sdat, pdat = from_oldstyle(obs) + obslist = [sdat, pdat] + [obs.rectify() for obs in obslist] + return obslist - sps = CSPSpecBasis(zcontinuous=1) + +def test_nebline_phot_addition(get_sps): + fnames = [f"sdss_{b}0" for b in "ugriz"] + filts = observate.load_filters(fnames) + obslist = build_obs(filts) + + sps = get_sps # Make emission lines more prominent zred = 1.0 @@ -81,28 +95,26 @@ def test_nebline_phot_addition(): model_pars["nebemlineinspec"]["init"] = False m2 = SpecModel(model_pars) - _, p1, _ = m1.predict(m1.theta, obs, sps) - _, p2, _ = m2.predict(m2.theta, obs, sps) + (s1, p1), _ = m1.predict(m1.theta, obslist, sps) + (s2, p2), _ = m2.predict(m2.theta, obslist, sps) # make sure some of the lines were important p1n = m1.nebline_photometry(filts) - assert np.any(p1n / p1 > 0.05) + assert np.any(p1n / p1[1] > 0.05) - # make sure you got the same answer - assert np.all(np.abs(p1 - p2) / p1 < 1e-3) + # make sure you got the same-ish answer + assert np.all((np.abs(p1 - p2) / p1) < 1e-2) -def test_filtersets(): +def test_filtersets(get_sps): + """This test no longer relevant..... + """ fnames = [f"sdss_{b}0" for b in "ugriz"] flist = observate.load_filters(fnames) - fset = observate.FilterSet(fnames) - - obs = dict(wavelength=np.linspace(3000, 9000, 1000), - spectrum=np.ones(1000), - unc=np.ones(1000)*0.1) - obs = fix_obs(obs) + obslist = build_obs(flist) + sdat, pdat = obslist - sps = CSPSpecBasis(zcontinuous=1) + sps = get_sps # Make emission lines more prominent zred = 0.5 @@ -120,45 +132,26 @@ def test_filtersets(): model_pars["nebemlineinspec"]["init"] = False models.append(SpecModel(model_pars)) - # test old usage w/ SedModel (mags computed in sps object) - model_pars = TemplateLibrary["parametric_sfh"] - model_pars["zred"]["init"] = zred - model_pars.update(TemplateLibrary["nebular"]) - models.append(SedModel(model_pars)) - for i, model in enumerate(models): - obs["filters"] = flist - _, plist, _ = model.predict(model.theta, obs, sps) - obs["filters"] = fset - _, pset, _ = model.predict(model.theta, obs, sps) + (_, pset), _ = model.predict(model.theta, obslist, sps) # make sure some of the filters are affected by lines + # ( nebular flux > 10% of total flux) if i == 1: - nebphot = model.nebline_photometry(flist) + nebphot = model.nebline_photometry(pdat.filterset) assert np.any(nebphot / pset > 0.1) - dmag = np.abs(pset - plist) / plist - #print(plist) - #print(dmag) - # make sure photometry is consistent - assert np.all(dmag < 5e-2), f"photometry inconsistent between Filter list and FilterSet on model {i}: {dmag}" # make sure we actually used different filter types - assert np.any(dmag > 0) + # We always use filtersets now -def test_eline_implementation(): +def test_eline_implementation(get_sps, plot=False): test_eline_parsing() filters = observate.load_filters([f"sdss_{b}0" for b in "ugriz"]) - obs = dict(filters=filters, - wavelength=np.linspace(3000, 9000, 1000), - spectrum=np.ones(1000), - unc=np.ones(1000)*0.1, - maggies=np.ones(len(filters))*1e-7, - maggies_unc=np.ones(len(filters))*1e-8) - obs = fix_obs(obs) + obslist = build_obs(filters) model_pars = TemplateLibrary["parametric_sfh"] model_pars.update(TemplateLibrary["nebular"]) @@ -167,32 +160,31 @@ def test_eline_implementation(): model_pars["zred"]["init"] = 4 model = SpecModel(model_pars) - sps = CSPSpecBasis(zcontinuous=1) + sps = get_sps # generate with all fixed lines added - spec, phot, mfrac = model.predict(model.theta, obs=obs, sps=sps) + (spec, phot), mfrac = model.predict(model.theta, obslist, sps=sps) # test ignoring a line - lya = "Ly alpha 1216" + lya = "Ly-alpha 1215" model_pars["elines_to_ignore"] = dict(init=lya, isfree=False) model = SpecModel(model_pars) - spec_nolya, phot_nolya, mfrac = model.predict(model.theta, obs=obs, sps=sps) + (spec_nolya, phot_nolya), mfrac = model.predict(model.theta, obslist, sps=sps) assert np.any((phot - phot_nolya) / phot != 0.0) - lint = np.trapz(spec - spec_nolya, obs["wavelength"]) + lint = np.trapz(spec - spec_nolya, obslist[0]["wavelength"]) assert lint > 0 # test igoring a line, phot only - obs_spec = obs.pop("spectrum") model = SpecModel(model_pars) - spec_nolya_2, phot_nolya_2, mfrac = model.predict(model.theta, obs=obs, sps=sps) - obs["spectrum"] = obs_spec + (phot_nolya_2,), mfrac = model.predict(model.theta, [obslist[1]], sps=sps) assert np.all(phot_nolya == phot_nolya_2) - #import matplotlib.pyplot as pl - #pl.ion() - #fig, ax = pl.subplots() - #ax.plot(obs["wavelength"], spec) - #ax.plot(obs["wavelength"], spec_nolya) + if plot: + import matplotlib.pyplot as pl + pl.ion() + fig, ax = pl.subplots() + ax.plot(obslist[0]["wavelength"], spec) + ax.plot(obslist[0]["wavelength"], spec_nolya) #def test_marginalizing_lines(): diff --git a/tests/test_lnlike.py b/tests/test_lnlike.py new file mode 100644 index 00000000..a1f4209c --- /dev/null +++ b/tests/test_lnlike.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import sys +import numpy as np + +import pytest + +from prospect.sources import CSPSpecBasis +from prospect.models import SpecModel, templates +from prospect.observation import Spectrum, Photometry +from prospect.likelihood import NoiseModel +from prospect.likelihood.likelihood import compute_lnlike +from prospect.fitting import lnprobfn + + +@pytest.fixture +def get_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(add_neb=False, add_outlier=False): + model_params = templates.TemplateLibrary["parametric_sfh"] + if add_neb: + model_params.update(templates.TemplateLibrary["nebular"]) + if add_outlier: + model_params.update(templates.TemplateLibrary["outlier_model"]) + model_params["f_outlier_phot"]["isfree"] = True + model_params["f_outlier_phot"]["init"] = 0.05 + return SpecModel(model_params) + + +def build_obs(multispec=True, add_outlier=True): + N = 1500 * (2 - multispec) + wmax = 7000 + wsplit = wmax - N * multispec + + fnames = list([f"sdss_{b}0" for b in "ugriz"]) + Nf = len(fnames) + phot = [Photometry(filters=fnames, flux=np.ones(Nf), uncertainty=np.ones(Nf)/10)] + spec = [Spectrum(wavelength=np.linspace(4000, wsplit, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + if add_outlier: + phot[0].noise = NoiseModel(frac_out_name='f_outlier_phot', + nsigma_out_name='nsigma_outlier_phot') + + if multispec: + spec += [Spectrum(wavelength=np.linspace(wsplit+1, wmax, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + obslist = spec + phot + [obs.rectify() for obs in obslist] + return obslist + + +def test_lnlike_shape(get_sps): + # testing lnprobfn + sps = get_sps + + for add_out in [True, False]: + observations = build_obs(add_outlier=add_out) + model = build_model(add_neb=add_out, add_outlier=add_out) + + model.set_parameters(model.theta) + [obs.noise.update(**model.params) for obs in observations + if obs.noise is not None] + predictions, x = model.predict(model.theta, observations, sps=sps) + + # check you get a scalar lnp for each observation + lnp_data = [compute_lnlike(pred, obs, vectors={}) for pred, obs + in zip(predictions, observations)] + assert np.all([np.isscalar(p) for p in lnp_data]), f"failed for add_outlier={add_out}" + assert len(lnp_data) == len(observations), f"failed for add_outlier={add_out}" + + # check lnprobfn returns scalar + lnp = lnprobfn(model.theta, model=model, observations=observations, sps=sps) + + assert np.isscalar(lnp), f"failed for add_outlier={add_out}" + + # %timeit model.prior_product(model.theta) + # arr = np.zeros(model.ndim) + # arr[-1] = 1 + # theta = model.theta.copy() + # %timeit predictions, x = model.predict(theta + np.random.uniform(-0.1, 0.1) * arr, observations=observations, sps=sps) + # %timeit lnp_data = [compute_lnlike(pred, obs, vectors={}) for pred, obs in zip(predictions, observations)] + # %timeit lnp = lnprobfn(theta + np.random.uniform(0, 3) * arr, model=model, observations=observations, sps=sps) diff --git a/tests/test_multispec.py b/tests/test_multispec.py new file mode 100644 index 00000000..d6ac17da --- /dev/null +++ b/tests/test_multispec.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import numpy as np +import pytest + +from prospect.sources import CSPSpecBasis +from prospect.models import SpecModel, templates +from prospect.observation import Spectrum, Photometry + + +@pytest.fixture(scope="module") +def build_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(add_neb=False): + model_params = templates.TemplateLibrary["parametric_sfh"] + if add_neb: + model_params.update(templates.TemplateLibrary["nebular"]) + return SpecModel(model_params) + + +def build_obs(multispec=True): + N = 1500 * (2 - multispec) + wmax = 7000 + wsplit = wmax - N * multispec + + fnames = list([f"sdss_{b}0" for b in "ugriz"]) + Nf = len(fnames) + phot = [Photometry(filters=fnames, flux=np.ones(Nf), uncertainty=np.ones(Nf)/10)] + spec = [Spectrum(wavelength=np.linspace(4000, wsplit, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + if multispec: + spec += [Spectrum(wavelength=np.linspace(wsplit+1, wmax, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + obslist = spec + phot + [obs.rectify() for obs in obslist] + return obslist + + +def test_multiline(): + """The goal is combine all constraints on the emission line luminosities. + """ + pass + + +def test_multires(): + # Test the smoothing of multiple spectra to different resolutions + # - give the same wavelength array different instrumental resolutions, assert similar but different, and that smoothing by the difference gives the right answer + # Test the use of two differernt smoothings, physical and instrumental + # - give an obs with no instrument smoothing and one with, make sure they are different + pass + + +def test_multinoise(): + pass + + +def test_multical(): + pass \ No newline at end of file diff --git a/tests/test_polycal.py b/tests/test_polycal.py new file mode 100644 index 00000000..b4ea4488 --- /dev/null +++ b/tests/test_polycal.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import numpy as np +import pytest + +from prospect.sources import CSPSpecBasis +from prospect.models import SpecModel, templates +from prospect.observation import Spectrum, Photometry, PolyOptCal + + +class PolySpectrum(PolyOptCal, Spectrum): + pass + + + +@pytest.fixture +def get_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(add_neb=False): + model_params = templates.TemplateLibrary["parametric_sfh"] + if add_neb: + model_params.update(templates.TemplateLibrary["nebular"]) + return SpecModel(model_params) + + +def build_obs(multispec=False): + N = 1500 * (2 - multispec) + wmax = 7000 + wsplit = wmax - N * multispec + + fnames = list([f"sdss_{b}0" for b in "ugriz"]) + Nf = len(fnames) + phot = [Photometry(filters=fnames, + flux=np.ones(Nf), + uncertainty=np.ones(Nf)/10)] + spec = [PolySpectrum(wavelength=np.linspace(4000, wsplit, N), + flux=np.ones(N), + uncertainty=np.ones(N) / 10, + mask=slice(None), + polynomial_order=5) + ] + + if multispec: + spec += [Spectrum(wavelength=np.linspace(wsplit+1, wmax, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + obslist = spec + phot + [obs.rectify() for obs in obslist] + return obslist + + +def test_polycal(get_sps, plot=False): + """Make sure the polynomial optimization works + """ + sps = get_sps + observations = build_obs() + model = build_model() + + preds, extra = model.predict(model.theta, observations=observations, sps=sps) + obs = observations[0] + + assert np.any(obs.response != 0) + + if plot: + import matplotlib.pyplot as pl + fig, axes = pl.subplots(3, 1, sharex=True) + ax = axes[0] + ax.plot(obs.wavelength, obs.flux, label="obseved flux (ones)") + ax.plot(obs.wavelength, preds[0], label="model flux (times response)") + ax = axes[1] + ax.plot(obs.wavelength, obs.response, label="instrumental response (polynomial)") + ax = axes[2] + ax.plot(obs.wavelength, preds[0]/ obs.response, label="intrinsic model spectrum") + ax.set_xlabel("wavelength") + [ax.legend() for ax in axes] \ No newline at end of file diff --git a/tests/test_predict.py b/tests/test_predict.py new file mode 100644 index 00000000..7f57282f --- /dev/null +++ b/tests/test_predict.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import numpy as np +import pytest + +from prospect.sources import CSPSpecBasis +from prospect.models import SpecModel, templates +from prospect.observation import Spectrum, Photometry, Lines + + +@pytest.fixture(scope="module") +def build_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(add_neb=False): + model_params = templates.TemplateLibrary["parametric_sfh"] + if add_neb: + model_params.update(templates.TemplateLibrary["nebular"]) + return SpecModel(model_params) + + +def build_obs(multispec=True, add_lines=False): + N = 1500 * (2 - multispec) + wmax = 7000 + wsplit = wmax - N * multispec + + fnames = list([f"sdss_{b}0" for b in "ugriz"]) + Nf = len(fnames) + phot = [Photometry(filters=fnames, flux=np.ones(Nf), uncertainty=np.ones(Nf)/10)] + spec = [Spectrum(wavelength=np.linspace(4000, wsplit, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + if multispec: + spec += [Spectrum(wavelength=np.linspace(wsplit+1, wmax, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + obs = spec + phot + + if add_lines: + line_ind = [59, 62, 74, 73, 75] # index of line in FSPS table + line_name = ["Hb", "OIII-5007", "Ha", "NII-6548", "NII-6584"] + line_wave = [4861, 5007, 6563, 6548, 6584] # can be approximate + n_line = len(line_ind) + lines = Lines(line_ind=line_ind, + flux=np.ones(n_line), # erg/s/cm^2 + uncertainty=np.ones(n_line)/10, + line_names=line_name, # optional + wavelength=line_wave, # optional + ) + obs += [lines] + + [ob.rectify() for ob in obs] + for ob in obs: + assert ob.ndof > 0 + + return obs + + +def test_prediction_nodata(build_sps): + sps = build_sps + model = build_model(add_neb=True) + sobs, pobs = build_obs(multispec=False) + pobs.flux = None + pobs.uncertainty = None + sobs.wavelength = None + sobs.flux = None + sobs.uncertainty = None + pred, mfrac = model.predict(model.theta, observations=[sobs, pobs], sps=sps) + assert len(pred[0]) == len(sps.wavelengths) + assert np.any(np.isfinite(pred[0])) + assert len(pred[1]) == len(pobs.filterset) + assert np.any(np.isfinite(pred[1])) + + +def test_multispec(build_sps, plot=False): + sps = build_sps + + obslist_single = build_obs(multispec=False) + obslist_multi = build_obs(multispec=True) + model = build_model(add_neb=True) + + preds_single, mfrac = model.predict(model.theta, observations=obslist_single, sps=sps) + preds_multi, mfrac = model.predict(model.theta, observations=obslist_multi, sps=sps) + + assert len(preds_single) == 2 + assert len(preds_multi) == 3 + assert np.allclose(preds_single[-1], preds_multi[-1]) + + # TODO: turn this plot into an actual test + if plot: + import matplotlib.pyplot as pl + fig, ax = pl.subplots() + ax.plot(obslist_single[0].wavelength, preds_single[0]) + for p, o in zip(preds_multi, obslist_multi): + if o.kind == "photometry": + ax.plot(o.wavelength, p, "o") + else: + ax.plot(o.wavelength, p) + + +def test_line(build_sps, plot=False): + + obs = build_obs(multispec=False, add_lines=True) + model = build_model(add_neb=True) + + sps = build_sps + preds, mfrac = model.predict(model.theta, observations=obs, sps=sps) + (spec, phot, lines) = preds + assert np.all(lines) > 0 + + #print(f"log(OIII[5007]/Hb)={np.log10(lines[1]/lines[0])}") + #print(f"log(NII/Ha)={np.log10(lines[-2:]/lines[2])}") + + +def lnlike_testing(build_sps): + # testing lnprobfn + + observations = build_obs() + model = build_model(add_neb=True) + sps = build_sps + + from prospect.likelihood.likelihood import compute_lnlike + from prospect.fitting import lnprobfn + + predictions, x = model.predict(model.theta, observations, sps=sps) + lnp_data = [compute_lnlike(pred, obs, vectors={}) for pred, obs + in zip(predictions, observations)] + assert np.all([np.isscalar(p) for p in lnp_data]) + assert len(lnp_data) == len(observations) + + lnp = lnprobfn(model.theta, model=model, observations=observations, sps=sps) + + assert np.isscalar(lnp) + + # %timeit model.prior_product(model.theta) + # arr = np.zeros(model.ndim) + # arr[-1] = 1 + # theta = model.theta.copy() + # %timeit predictions, x = model.predict(theta + np.random.uniform(-0.1, 0.1) * arr, observations=observations, sps=sps) + # %timeit lnp_data = [compute_lnlike(pred, obs, vectors={}) for pred, obs in zip(predictions, observations)] + # %timeit lnp = lnprobfn(theta + np.random.uniform(0, 3) * arr, model=model, observations=observations, sps=sps) diff --git a/tests/tests_io.py b/tests/tests_io.py new file mode 100644 index 00000000..c711ee94 --- /dev/null +++ b/tests/tests_io.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import numpy as np +import pytest + +import h5py + +from prospect.models import SpecModel, templates +from prospect.sources import CSPSpecBasis +from prospect.observation import Photometry, Spectrum +from prospect.io.write_results import write_obs_to_h5 +from prospect.io.read_results import obs_from_h5 + + +@pytest.fixture(scope="module") +def build_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(add_neb=False): + model_params = templates.TemplateLibrary["parametric_sfh"] + if add_neb: + model_params.update(templates.TemplateLibrary["nebular"]) + return SpecModel(model_params) + + +def build_obs(multispec=True): + N = 1500 * (2 - multispec) + wmax = 7000 + wsplit = wmax - N * multispec + + fnames = list([f"sdss_{b}0" for b in "ugriz"]) + Nf = len(fnames) + phot = [Photometry(filters=fnames, flux=np.ones(Nf), uncertainty=np.ones(Nf)/10)] + spec = [Spectrum(wavelength=np.linspace(4000, wsplit, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + if multispec: + spec += [Spectrum(wavelength=np.linspace(wsplit+1, wmax, N), + flux=np.ones(N), uncertainty=np.ones(N) / 10, + mask=slice(None))] + + obslist = spec + phot + [obs.rectify() for obs in obslist] + return obslist + + +def test_observation_io(build_sps, plot=False): + #sps = build_sps + #model = build_model(add_neb=True) + + obslist = build_obs(multispec=True) + + r = np.random.randint(0, 10000) #HAAACK + fn = f"./test-{r}.h5" + # obs writing + with h5py.File(fn, "w") as hf: + write_obs_to_h5(hf, obslist) + # obs reading + with h5py.File(fn, "r") as hf: + obsr = obs_from_h5(hf["observations"]) + + # cleanup? + import os + os.remove(fn) \ No newline at end of file diff --git a/tests/tests_samplers.py b/tests/tests_samplers.py new file mode 100644 index 00000000..3c231071 --- /dev/null +++ b/tests/tests_samplers.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import sys +import numpy as np + +#import pytest + +from prospect.utils.prospect_args import get_parser +from prospect.sources import CSPSpecBasis +from prospect.models import SpecModel, templates +from prospect.observation import Photometry +from prospect.fitting import fit_model +from prospect.io.write_results import write_hdf5 +from prospect.io.read_results import results_from + +#@pytest.fixture +def get_sps(**kwargs): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(add_neb=False, add_outlier=False, **kwargs): + model_params = templates.TemplateLibrary["parametric_sfh"] + model_params["logzsol"]["isfree"] = False # built for speed + if add_neb: # skip for speed + model_params.update(templates.TemplateLibrary["nebular"]) + if add_outlier: + model_params.update(templates.TemplateLibrary["outlier_model"]) + model_params["f_outlier_phot"]["isfree"] = True + model_params["f_outlier_phot"]["init"] = 0.05 + return SpecModel(model_params) + + +def build_obs(**kwargs): + + from astroquery.sdss import SDSS + from astropy.coordinates import SkyCoord + bands = "ugriz" + mcol = [f"cModelMag_{b}" for b in bands] + ecol = [f"cModelMagErr_{b}" for b in bands] + cat = SDSS.query_crossid(SkyCoord(ra=204.46376, dec=35.79883, unit="deg"), + data_release=16, + photoobj_fields=mcol + ecol + ["specObjID"]) + shdus = SDSS.get_spectra(plate=2101, mjd=53858, fiberID=220)[0] + assert int(shdus[2].data["SpecObjID"][0]) == cat[0]["specObjID"] + + fnames = [f"sdss_{b}0" for b in bands] + maggies = np.array([10**(-0.4 * cat[0][f"cModelMag_{b}"]) for b in bands]) + magerr = np.array([cat[0][f"cModelMagErr_{b}"] for b in bands]) + magerr = np.hypot(magerr, 0.05) + + phot = Photometry(filters=fnames, flux=maggies, uncertainty=magerr*maggies/1.086, + name=f'sdss_phot_specobjID{cat[0]["specObjID"]}') + + obslist = [phot] + [obs.rectify() for obs in obslist] + return obslist + + +if __name__ == "__main__": + + parser = get_parser() + parser.set_defaults(nested_target_n_effective=256, + nested_nlive=512, + verbose=0) + args = parser.parse_args() + run_params = vars(args) + run_params["param_file"] = __file__ + + # build stuff + model = build_model() + obs = build_obs() + sps = get_sps() + + # do a first model caching + (phot,), mfrac = model.predict(model.theta, obs, sps=sps) + print(model) + + # loop over samplers + results = {} + samplers = ["nautilus", "ultranest", "dynesty"] + for sampler in samplers: + print(sampler) + run_params["nested_sampler"] = sampler + out = fit_model(obs, model, sps, **run_params) + results[sampler] = out["sampling"] + hfile = f"./{sampler}_test.h5" + write_hdf5(hfile, + run_params, + model, + obs, + results[sampler], + None, + sps=sps) + ires, iobs, im = results_from(hfile) + assert (im is not None) + + # compare runtime + for sampler in samplers: + print(sampler, results[sampler]["duration"]) + + # compare posteriors + colors = ["royalblue", "darkorange", "firebrick"] + import matplotlib.pyplot as pl + from prospect.plotting import corner + ndim = model.ndim + cfig, axes = pl.subplots(ndim, ndim, figsize=(10,9)) + for sampler, color in zip(samplers, colors): + out = results[sampler] + axes = corner.allcorner(out["points"].T, + model.theta_labels(), + axes, + color=color, + weights= np.exp(out["log_weight"]), + show_titles=True) + cfig.savefig("sampler_test_corner.png") \ No newline at end of file diff --git a/tests/tests_smoothing.py b/tests/tests_smoothing.py index 9ea92f13..59742339 100644 --- a/tests/tests_smoothing.py +++ b/tests/tests_smoothing.py @@ -6,7 +6,7 @@ # TODO: have some tests that do not require a python-fsps install import numpy as np import matplotlib.pyplot as pl -from prospect.utils.smoothing import smooth_fft, smooth_wave_fft, smooth_lsf_fft, smoothspec +from sedpy.smoothing import smooth_fft, smooth_wave_fft, smooth_lsf_fft, smoothspec def lsf(wave, wave0=5000, a=5e-5, b=1e-7, c=1.0, **extras): diff --git a/tests/tests_undersampling.py b/tests/tests_undersampling.py new file mode 100644 index 00000000..8ee8d025 --- /dev/null +++ b/tests/tests_undersampling.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import numpy as np +#import pytest + +from prospect.sources import CSPSpecBasis +from prospect.models import SpecModel, templates +from prospect.observation import Spectrum, UndersampledSpectrum + +#@pytest.fixture(scope="module") +def build_sps(): + sps = CSPSpecBasis(zcontinuous=1) + return sps + + +def build_model(add_neb=False, sigma_v=200): + model_params = templates.TemplateLibrary["ssp"] + model_params.update(templates.TemplateLibrary["spectral_smoothing"]) + model_params["sigma_smooth"] = dict(N=1, isfree=False, init=sigma_v) + if add_neb: + model_params.update(templates.TemplateLibrary["nebular"]) + model_params["nebemlineinspec"]["init"] = np.array([False]) + model_params["eline_sigma"] = dict(N=1, isfree=False, init=sigma_v) + + model_params["tage"]["init"] = 0.2 + + return SpecModel(model_params) + + +def build_obs(undersampling=4): + + wmin, wmax = 4000, 7000 + fwhm = 5 # instrumental LSF FWHM in AA + dl_s = fwhm / 3 # well sampled spectrum + dl_u = fwhm / 2 * undersampling # horribly undersampled spectrum + + wave = np.arange(wmin, wmax, dl_s) + resolution = (fwhm/2.355) / wave * 2.998e5 # in km/s + full = Spectrum(wavelength=wave.copy(), + flux=np.ones(len(wave)), + uncertainty=np.ones(len(wave)) / 10, + resolution=resolution, + mask=slice(None), + name="Oversampled no pixelization") + + full_pix = UndersampledSpectrum(wavelength=wave.copy(), + flux=np.ones(len(wave)), + uncertainty=np.ones(len(wave)) / 10, + resolution=resolution, + mask=slice(None), + name="Oversampled but pixelized") + + wave = np.arange(wmin, wmax, dl_u) + resolution = (fwhm/2.355) / wave * 2.998e5 # in km/s + under_pix = UndersampledSpectrum(wavelength=wave.copy(), + flux=np.ones(len(wave)), + uncertainty=np.ones(len(wave)) / 10, + resolution=resolution, + mask=slice(None), + name="Undersampled and pixelized") + under = Spectrum(wavelength=wave.copy(), + flux=np.ones(len(wave)), + uncertainty=np.ones(len(wave)) / 10, + resolution=resolution, + mask=slice(None), + name="Undersampled no pixelization") + + obslist = [full, full_pix, under, under_pix] + [obs.rectify() for obs in obslist] + return obslist + + +#def test_undersample(build_sps, plot=False): +if __name__ == "__main__": + plot = True + + sps = build_sps() + obslist = build_obs() + model = build_model(add_neb=True) + + preds, x = model.predict(model.theta, observations=obslist, sps=sps) + + # TODO: turn this plot into an actual test + if plot: + import matplotlib.pyplot as pl + fig, axes = pl.subplots(2, 1, sharex=True) + ax = axes[0] + ax.plot(model.observed_wave(model._wave), model._smooth_spec, label="LOSVD only") + for p, o in zip(preds, obslist): + if o.kind == "photometry": + ax.plot(o.wavelength, p, "o") + else: + ax.step(o.wavelength, p, where="mid", label=o.name) + ax.legend() + + ax = axes[1] + ax.plot(obslist[0].wavelength, preds[0]/preds[1], label="Oversampled") + ax.plot(obslist[2].wavelength, preds[2]/preds[3], label="Undersampled") + ax.set_ylabel("Ratio of pixel-unconvolved to pixel-convolved flux") + ax.legend() + + ax.set_xlim(o.wavelength.min(), o.wavelength.max()) \ No newline at end of file diff --git a/demo/timing.py b/tests/timing_basis.py similarity index 97% rename from demo/timing.py rename to tests/timing_basis.py index a130c4db..49054949 100644 --- a/demo/timing.py +++ b/tests/timing_basis.py @@ -40,12 +40,12 @@ def make_agebins(nbin=5, minage=7.0, **extras): allages = np.linspace(minage, np.log10(tuniv), nbin) allages = np.insert(allages, 0, 0.) agebins = np.array([allages[:-1], allages[1:]]).T - return agebins + return agebins if __name__ == "__main__": - + step_params = {'agebins':[[]], 'mass': [], 'tage': np.array([13.7]), @@ -63,8 +63,8 @@ def make_agebins(nbin=5, minage=7.0, **extras): zlist = [1, 2] nlist = [False, True] - print("Using {} isochrones and {} spectra.\nAsking for single ages.".format(*libs)) - + print("Using {} isochrones and {} spectra.\nAsking for single ages.".format(*libs)) + # FSPS string = "StellarPopulation takes {:7.5f}s per call {} nebular emission and zcontinuous={}." params = deepcopy(csp_params) @@ -84,7 +84,7 @@ def make_agebins(nbin=5, minage=7.0, **extras): sps = CSPBasis(zcontinuous=zcont) dur = call_duration(sps, ntry, add_neb_emission=[neb], **params) print(string.format(dur, w[int(neb)], zcont)) - + # Step SFH nbin = 10 @@ -92,7 +92,7 @@ def make_agebins(nbin=5, minage=7.0, **extras): params['agebins'] = make_agebins(nbin) params['mass'] = np.ones(nbin) * 1.0 string = "FastStepBasis ({} bins) takes {:7.5f}s per call {} nebular emission and zcontinuous={}." - + for zcont in zlist: print("\n") for neb in nlist: @@ -101,12 +101,12 @@ def make_agebins(nbin=5, minage=7.0, **extras): #print(sps.params, sps.ssp.params['add_neb_emission']) print(string.format(nbin, dur, w[int(neb)], zcont)) - - + + # sys.exit() - + # Now time calls for random Z (which always causes dirtiness=1) #setup = "from __main__ import test; import numpy as np" #call = "out=get_model(sps, logzsol=[np.random.uniform(-1, 0)], **params)" diff --git a/tests/timing.py b/tests/timing_fsps.py similarity index 100% rename from tests/timing.py rename to tests/timing_fsps.py diff --git a/tests/timing_tests.py b/tests/timing_tests.py new file mode 100644 index 00000000..749546d5 --- /dev/null +++ b/tests/timing_tests.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import time +import numpy as np +from sedpy import observate + +from prospect import prospect_args +from prospect.utils.obsutils import fix_obs +from prospect.models.templates import TemplateLibrary +from prospect.models.sedmodel import SpecModel + +from prospect.sources import CSPSpecBasis, FastStepBasis + + +def build_model(nebular=True): + model_pars = TemplateLibrary["continuity_sfh"] + if nebular: + model_pars.update(TemplateLibrary["nebular"]) + Basis = FastStepBasis + return SpecModel(model_pars), Basis(zcontinuous=1) + + +def build_obs(spec=False): + fnames = [f"sdss_{b}0" for b in "ugriz"] + filts = observate.load_filters(fnames, gridded=True) + obs = dict(filters=filts, + maggies=np.zeros(len(fnames)), + maggies_unc=np.ones(len(fnames)), + wavelength=np.linspace(3000, 9000, 1000), + spectrum=np.ones(1000), + unc=np.ones(1000)*0.1) + if not spec: + obs["spectrum"] = None + obs = fix_obs(obs) + + try: + from prospect.observation import from_oldstyle + obs = from_oldstyle(obs) + except(ImportError): + pass + + return obs + + +def predict(model, obs, sps): + z = np.random.uniform(-1, 0) + model.params["logzsol"] = np.array([z]) + preds, x = model.predict(model.theta, observations=obs, sps=sps) + return preds + + +if __name__ == "__main__": + + obs = build_obs() + model, sps = build_model(nebular=False) + + # make sure models get cached + logzsol_prior = model.config_dict["logzsol"]['prior'] + lo, hi = logzsol_prior.range + logzsol_grid = np.around(np.arange(lo, hi, step=0.1), decimals=2) + for logzsol in logzsol_grid: + model.params["logzsol"] = np.array([logzsol]) + _ = model.predict(model.theta, observations=obs, sps=sps) + + N = 100 + tic = time.time() + for i in range(N): + _ = predict(model, obs, sps) + toc = time.time() + print((toc-tic) / N) \ No newline at end of file