Source code for fiesta.train.trainers.LightcurveSurrogateTrainer

"""
DEPRECATED training API for per-filter lightcurve surrogates (SVD-decomposed magnitudes).

This predates fiesta's spectral-flux training path and is kept only so that already
trained ``fiesta.models.surrogate_models.LightcurveSurrogate`` models can still be
reproduced/retrained. New surrogates should be trained with
``fiesta.train.trainers.FluxSurrogateTrainer`` instead, which trains a single
spectral-flux surrogate covering all filters at once and is the actively maintained
training path.
"""

import dill
import os
import pickle
from typing import Callable, Dict

import numpy as np
import matplotlib.pyplot as plt

import jax
from jaxtyping import Array, Float, Int

from fiesta.filters import Filter
from fiesta.train.DataLoader import DataLoader
from fiesta.scalers import MinMaxScalerJax
import fiesta.train.neuralnets as fiesta_nn
from fiesta.logging import logger

################
# TRAINING API #
################

[docs] class LightcurveSurrogateTrainer: """ DEPRECATED: Abstract class for training a collection of surrogate models per filter. Use ``fiesta.train.trainers.FluxSurrogateTrainer`` instead. This class is no longer actively developed and is kept only for backwards compatibility. """ name: str outdir: str filters: list[Filter] parameter_names: list[str] def __init__(self, name: str, outdir: str, plots_dir: str = None, save_preprocessed_data: bool = False) -> None: logger.warning( f"\n !!!! {type(self).__name__} is deprecated and no longer actively " "maintained. Use fiesta.train.trainers.FluxSurrogateTrainer instead. " "Proceed at your own risk!!! \n" ) self.name = name # Check if directories exists, otherwise, create: self.outdir = outdir if not os.path.exists(self.outdir): os.makedirs(self.outdir) self.plots_dir = plots_dir if self.plots_dir is not None and not os.path.exists(self.plots_dir): os.makedirs(self.plots_dir) self.save_preprocessed_data = save_preprocessed_data # To be loaded by child classes self.filters = None self.parameter_names = None self.train_X = None self.train_y = None self.val_X = None self.val_y = None def __repr__(self) -> str: return f"LightcurveSurrogateTrainer(name={self.name})"
[docs] def preprocess(self): # NOTE: generic fallback preprocessing for a subclass that has already # populated self.X_raw / self.y_raw itself before calling this. No # currently-maintained subclass uses this path: SVDTrainer overrides # preprocess() and populates self.train_X / self.train_y / self.val_X / # self.val_y directly, which is what fit() below actually consumes. logger.info("Preprocessing data by minmax scaling . . .") self.X_scaler = MinMaxScalerJax() self.X = self.X_scaler.fit_transform(self.X_raw) self.y_scaler: dict[str, MinMaxScalerJax] = {} self.y = {} for filt in self.filters: y_scaler = MinMaxScalerJax() self.y[filt.name] = y_scaler.fit_transform(self.y_raw[filt.name]) self.y_scaler[filt.name] = y_scaler logger.info("Preprocessing data . . . done")
[docs] def fit(self, config: fiesta_nn.NeuralnetConfig, key: jax.random.PRNGKey = jax.random.PRNGKey(0), verbose: bool = True) -> None: """ The config controls which architecture is built and therefore should not be specified here. Args: config (nn.NeuralnetConfig, optional): _description_. Defaults to None. """ self.preprocess() if self.save_preprocessed_data: self._save_preprocessed_data() # preprocess() may append derived parameters (e.g. SVDTrainer adds # "redshift"), so the network's input size is only known after # preprocessing; fiesta_nn.MLP builds its input layer from config.input_size. config.input_size = len(self.parameter_names) self.config = config self.models = {} for filt in self.filters: logger.info("\n \n") logger.info(f"Training {filt.name}...") logger.info(f"----------------------------------\n") # Create neural network and initialize the state net = fiesta_nn.MLP(config=config, key=key) # Perform training loop state, train_losses, val_losses = net.train_loop(self.train_X, self.train_y[filt.name], self.val_X, self.val_y[filt.name], verbose=verbose) self.models[filt.name] = net # Plot and save the plot if so desired if self.plots_dir is not None: plt.figure(figsize=(10, 5)) ls = "-o" ms = 3 plt.plot([i+1 for i in range(len(train_losses))], train_losses, ls, markersize=ms, label="Train", color="red") plt.plot([i+1 for i in range(len(val_losses))], val_losses, ls, markersize=ms, label="Validation", color="blue") plt.legend() plt.xlabel("Epoch") plt.ylabel("MSE loss") plt.yscale('log') plt.title("Learning curves") plt.savefig(os.path.join(self.plots_dir, f"learning_curves_{filt.name}.png")) plt.close()
[docs] def plot_example_lc(self, lc_model): X, y = self.data_manager.load_from_file("val", index=-1) y = y.reshape(len(self.data_manager.nus), len(self.data_manager.times)) mJys_val = np.power(10, y) params = dict(zip(self.parameter_names, X.flatten())) _, mag_predict = lc_model.predict_abs_mag(params) mag_val = {Filt.name: Filt.get_mag(mJys_val, self.data_manager.nus) for Filt in lc_model.Filters} for filt in lc_model.Filters: plt.plot(lc_model.times, mag_val[filt.name], color = "red", label="Base model") plt.plot(lc_model.times, mag_predict[filt.name], color = "blue", label="Surrogate prediction") upper_bound = mag_predict[filt.name] + 1 lower_bound = mag_predict[filt.name] - 1 plt.fill_between(lc_model.times, lower_bound, upper_bound, color='blue', alpha=0.2) plt.ylabel(f"mag for {filt.name}") plt.xlabel("$t$ in days") plt.legend() plt.gca().invert_yaxis() plt.xscale('log') plt.xlim(lc_model.times[0], lc_model.times[-1]) if self.plots_dir is None: self.plots_dir = "." plt.savefig(os.path.join(self.plots_dir, f"{self.name}_{filt.name}_example.png")) plt.close()
[docs] def save(self): """ Save the trained model and all the used metadata to the outdir. """ # Save the metadata meta_filename = os.path.join(self.outdir, f"{self.name}_metadata.pkl") save = {} save["times"] = self.times save["parameter_names"] = self.parameter_names save["parameter_distributions"] = self.parameter_distributions save["X_scaler"] = self.X_scaler save["y_scaler"] = self.y_scaler save["model_type"] = "MLP" with open(meta_filename, "wb") as meta_file: dill.dump(save, meta_file) # Save the NN for filt in self.filters: model = self.models[filt.name] model.save_model(outfile = os.path.join(self.outdir, f"{self.name}_{filt.name}.pkl"))
def _save_preprocessed_data(self) -> None: logger.info("Saving preprocessed data . . .") np.savez(os.path.join(self.outdir, f"{self.name}_preprocessed_data.npz"), train_X=self.train_X, train_y = self.train_y, val_X = self.val_X, val_y = self.val_y) logger.info("Saving preprocessed data . . . done")
[docs] class SVDTrainer(LightcurveSurrogateTrainer): """DEPRECATED: see ``LightcurveSurrogateTrainer``.""" def __init__(self, name: str, outdir: str, filters: list[str], data_manager_args: dict, svd_ncoeff: Int = 50, conversion: str = None, plots_dir: str = None, save_preprocessed_data: bool = False) -> None: """ Initialize the surrogate model trainer that decomposes the training data into its SVD coefficients. The initialization also takes care of reading data and preprocessing it, but does not automatically fit the model. Users may want to inspect the data before fitting the model. Args: name (str): Name of the surrogate model. Will be used outdir (str): Directory where the trained surrogate model is to be saved. filters (list[str]): List of the filters for which the surrogate has to be trained. These have to be either bandpasses from sncosmo or specifiy the frequency through endign with GHz or keV. data_manager_args (dict): Arguments for the DataLoader class instance that will be used to read the data from the .h5 file in outdir and preprocess it. svd_ncoeff (int, optional) : Number of SVD coefficients to use in data reduction during training. Defaults to 50. conversion (str): references how to convert the parameters for the training. Defaults to None, in which case it's the identity. plots_dir (str, optional): Directory where the plots of the training process will be saved. Defaults to None, which means no plots will be generated. save_preprocessed_data (bool, optional): If True, the preprocessed data (reduced, rescaled) will be saved in the outdir. Defaults to False. """ super().__init__(name = name, outdir = outdir, plots_dir = plots_dir, save_preprocessed_data = save_preprocessed_data) self.svd_ncoeff = svd_ncoeff self.conversion = conversion self.data_manager = DataLoader(**data_manager_args) self.data_manager.print_file_info() # copy the metadata needed by fit()/save() off the DataLoader. Copy # parameter_names into a fresh list since preprocess() below mutates it # in place (appends "redshift") and must not alias data_manager's own list. self.parameter_names = list(self.data_manager.parameter_names) self.parameter_distributions = self.data_manager.parameter_distributions self.times = self.data_manager.times self.load_filters(filters)
[docs] def load_filters(self, filters): self.filters = [] for filt in filters: Filt = Filter(filt) if Filt.nus[0] < self.data_manager.nus[0] or Filt.nus[-1] > self.data_manager.nus[-1]: raise ValueError(f"Filter {filt} exceeds the frequency range of the training data.") self.filters.append(Filt)
[docs] def preprocess(self): """ Preprocessing method to get the SVD coefficients of the training and validation data. This includes scaling the inputs and outputs, as well as performing SVD decomposition. """ logger.info(f"Preprocessing data by decomposing training data into SVD coefficients.") self.train_X, self.train_y, self.val_X, self.val_y, self.X_scaler, self.y_scaler = self.data_manager.preprocess_svd(self.svd_ncoeff, self.filters, self.conversion) self.parameter_names += ["redshift"] self.parameter_distributions = self.parameter_distributions[:-1] + ", 'redshift': (0, 0.5, 'uniform')}" # TODO make adding redshift more flexible (i.e. whether to add redshift at all and its range) nan_filters = [] for key in self.train_y.keys(): if np.any(np.isnan(self.train_y[key])) or np.any(np.isnan(self.val_y[key])): logger.warning(f"Data preprocessing for {key} introduced nans. Check raw data for nans of infs or vanishing variance in a specific entry. Removing {key} from training.") nan_filters.append(key) self.filters = [filt for filt in self.filters if filt.name not in nan_filters] for key in nan_filters: del self.train_y[key] del self.val_y[key] del self.y_scaler[key] logger.info(f"Preprocessing data . . . done")