From 21898d3728e9b03ede6ee5207b44a94ee8f9cb1e Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Mon, 5 Oct 2020 09:23:31 +0200 Subject: [PATCH 01/15] fix optional input argument --- neuralhydrology/nh_run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neuralhydrology/nh_run.py b/neuralhydrology/nh_run.py index c9c4efc8..7748fb99 100644 --- a/neuralhydrology/nh_run.py +++ b/neuralhydrology/nh_run.py @@ -99,7 +99,7 @@ def continue_run(run_dir: Path, config_file: Path = None, gpu: int = None): start_training(base_config) -def eval_run(run_dir: Path, period: str, epoch: int, gpu: int = None): +def eval_run(run_dir: Path, period: str, epoch: int = None, gpu: int = None): """Start evaluating a trained model. Parameters From bd4fdbdd713c5fe66f891e8ad2229b8a3f7f7bad Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Mon, 5 Oct 2020 12:44:20 +0200 Subject: [PATCH 02/15] add github workflows (#2) * add github workflows * Update .github/workflows/pytest-ci.yml Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> --- .github/workflows/pytest-ci.yml | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/pytest-ci.yml diff --git a/.github/workflows/pytest-ci.yml b/.github/workflows/pytest-ci.yml new file mode 100644 index 00000000..07612002 --- /dev/null +++ b/.github/workflows/pytest-ci.yml @@ -0,0 +1,46 @@ +# Workflow to run the pytest test suite. + +name: pytest CI + +on: + pull_request: + branches: + - master + push: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + + steps: + # Checkout the repository under $GITHUB_WORKSPACE + - uses: actions/checkout@v2 + + # initialize conda + - name: Conda setup + uses: s-weigand/setup-conda@v1 + with: + update-conda: true + python-version: 3.7 + + # cache the conda installation to speedup the CI runs + - uses: actions/cache@v2 + id: cache + with: + path: /usr/share/miniconda/envs/neuralhydrology + key: ${{ runner.os }}-conda-cache-${{ hashFiles('environments/environment_cuda10_2.yml') }} + + # on cache miss, create the env from scratch + - name: Conda environment creation + if: steps.cache.outputs.cache-hit != 'true' + run: | + conda env create -f environments/environment_cuda10_2.yml + source activate neuralhydrology + + # Run the tests + - name: Testing with pytest + run: | + source activate neuralhydrology + pytest --cov=neuralhydrology From 8fa3b278384e9ac7fea53ea42aaac9181f42f854 Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Mon, 5 Oct 2020 12:52:03 +0200 Subject: [PATCH 03/15] automatic datetime coord inference (#1) * automatic datetime coord inference * Update neuralhydrology/data/utils.py Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> --- neuralhydrology/data/utils.py | 29 +++++ neuralhydrology/evaluation/metrics.py | 39 +++++-- neuralhydrology/evaluation/signatures.py | 130 +++++++++++++---------- 3 files changed, 132 insertions(+), 66 deletions(-) diff --git a/neuralhydrology/data/utils.py b/neuralhydrology/data/utils.py index b0e3b398..9e7e508a 100644 --- a/neuralhydrology/data/utils.py +++ b/neuralhydrology/data/utils.py @@ -4,6 +4,8 @@ import numpy as np import pandas as pd import xarray +from xarray.core.dataarray import DataArray +from xarray.core.dataset import Dataset ######################################################################################################################## # CAMELS US utility functions # @@ -512,3 +514,30 @@ def infer_frequency(index: Union[pd.DatetimeIndex, np.ndarray]) -> str: if pd.to_timedelta(native_frequency) == pd.to_timedelta(0): raise ValueError('Inferred dataset frequency is zero.') return native_frequency + + +def infer_datetime_coord(xr: Union[DataArray, Dataset]) -> str: + """Checks for coordinate with 'date' in its name and returns the name. + + Parameters + ---------- + xr : Union[DataArray, Dataset] + Array to infer coordinate name of. + + Returns + ------- + str + Name of datetime coordinate name. + + Raises + ------ + RuntimeError + If none or multiple coordinates with 'date' in its name are found. + """ + candidates = [c for c in list(xr.coords) if "date" in c] + if len(candidates) > 1: + raise RuntimeError("Found multiple coordinates with 'date' in its name.") + if not candidates: + raise RuntimeError("Did not find any coordinate with 'date' in its name") + + return candidates[0] diff --git a/neuralhydrology/evaluation/metrics.py b/neuralhydrology/evaluation/metrics.py index 833a83cf..32e62944 100644 --- a/neuralhydrology/evaluation/metrics.py +++ b/neuralhydrology/evaluation/metrics.py @@ -5,6 +5,8 @@ from scipy import stats, signal from xarray.core.dataarray import DataArray +from neuralhydrology.data import utils + def get_available_metrics() -> List[str]: """Get list of available metrics. @@ -513,7 +515,11 @@ def fdc_flv(obs: DataArray, sim: DataArray, l: float = 0.3) -> float: return flv * 100 -def mean_peak_timing(obs: DataArray, sim: DataArray, window: int = None, resolution: str = '1D') -> float: +def mean_peak_timing(obs: DataArray, + sim: DataArray, + window: int = None, + resolution: str = '1D', + datetime_coord: str = None) -> float: """Mean difference in peak flow timing. Uses scipy.find_peaks to find peaks in the observed time series. Starting with all observed peaks, those with a @@ -536,6 +542,8 @@ def mean_peak_timing(obs: DataArray, sim: DataArray, window: int = None, resolut for a resolution of '1H' the the window size is 12. resolution : str, optional Temporal resolution of the time series in pandas format, e.g. '1D' for daily and '1H' for hourly. + datetime_coord : str, optional + Name of datetime coordinate. Tried to infer automatically if not specified. Returns @@ -558,6 +566,10 @@ def mean_peak_timing(obs: DataArray, sim: DataArray, window: int = None, resolut # heuristic to get indices of peaks and their corresponding height. peaks, properties = signal.find_peaks(obs.values, distance=100, prominence=np.std(obs.values)) + # infer name of datetime index + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(obs) + if window is None: # infer a reasonable window size window = max((0.5 * pd.to_timedelta('1D')) // pd.to_timedelta(resolution), 3) @@ -567,8 +579,8 @@ def mean_peak_timing(obs: DataArray, sim: DataArray, window: int = None, resolut for idx in peaks: # skip peaks at the start and end of the sequence and peaks around missing observations # (NaNs that were removed in obs & sim would result in windows that span too much time). - if (idx - window < 0) or (idx + window >= len(obs)) or (pd.date_range(obs[idx - window]['datetime'].values, - obs[idx + window]['datetime'].values, + if (idx - window < 0) or (idx + window >= len(obs)) or (pd.date_range(obs[idx - window][datetime_coord].values, + obs[idx + window][datetime_coord].values, freq=resolution).size != 2 * window + 1): continue @@ -584,7 +596,7 @@ def mean_peak_timing(obs: DataArray, sim: DataArray, window: int = None, resolut peak_obs = obs[idx] # calculate the time difference between the peaks - delta = peak_obs.coords['datetime'] - peak_sim.coords['datetime'] + delta = peak_obs.coords[datetime_coord] - peak_sim.coords[datetime_coord] timing_error = np.abs(delta.values / pd.to_timedelta(resolution)) @@ -593,7 +605,8 @@ def mean_peak_timing(obs: DataArray, sim: DataArray, window: int = None, resolut return np.mean(timing_errors) if len(timing_errors) > 0 else np.nan -def calculate_all_metrics(obs: DataArray, sim: DataArray, resolution: str = "1D") -> Dict[str, float]: +def calculate_all_metrics(obs: DataArray, sim: DataArray, resolution: str = "1D", + datetime_coord: str = None) -> Dict[str, float]: """Calculate all metrics with default values. Parameters @@ -604,7 +617,9 @@ def calculate_all_metrics(obs: DataArray, sim: DataArray, resolution: str = "1D" Simulated time series. resolution : str, optional Temporal resolution of the time series in pandas format, e.g. '1D' for daily and '1H' for hourly. - + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. + Returns ------- Dict[str, float] @@ -621,13 +636,17 @@ def calculate_all_metrics(obs: DataArray, sim: DataArray, resolution: str = "1D" "FHV": fdc_fhv(obs, sim), "FMS": fdc_fms(obs, sim), "FLV": fdc_flv(obs, sim), - "Peak-Timing": mean_peak_timing(obs, sim, resolution=resolution) + "Peak-Timing": mean_peak_timing(obs, sim, resolution=resolution, datetime_coord=datetime_coord) } return results -def calculate_metrics(obs: DataArray, sim: DataArray, metrics: List[str], resolution: str = "1D") -> Dict[str, float]: +def calculate_metrics(obs: DataArray, + sim: DataArray, + metrics: List[str], + resolution: str = "1D", + datetime_coord: str = None) -> Dict[str, float]: """Calculate specific metrics with default values. Parameters @@ -640,6 +659,8 @@ def calculate_metrics(obs: DataArray, sim: DataArray, metrics: List[str], resolu List of metric names. resolution : str, optional Temporal resolution of the time series in pandas format, e.g. '1D' for daily and '1H' for hourly. + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. Returns ------- @@ -673,7 +694,7 @@ def calculate_metrics(obs: DataArray, sim: DataArray, metrics: List[str], resolu elif metric.lower() == "flv": values["FLV"] = fdc_flv(obs, sim) elif metric.lower() == "peak-timing": - values["Peak-Timing"] = mean_peak_timing(obs, sim, resolution=resolution) + values["Peak-Timing"] = mean_peak_timing(obs, sim, resolution=resolution, datetime_coord=datetime_coord) else: raise RuntimeError(f"Unknown metric {metric}") diff --git a/neuralhydrology/evaluation/signatures.py b/neuralhydrology/evaluation/signatures.py index 603b0964..dcc1e726 100644 --- a/neuralhydrology/evaluation/signatures.py +++ b/neuralhydrology/evaluation/signatures.py @@ -8,7 +8,7 @@ from numba import njit from xarray.core.dataarray import DataArray -from neuralhydrology.data.utils import infer_frequency +from neuralhydrology.data import utils def get_available_signatures() -> List[str]: @@ -26,7 +26,7 @@ def get_available_signatures() -> List[str]: return signatures -def calculate_all_signatures(da: DataArray, prcp: DataArray, datetime_coord: str = 'date') -> Dict[str, float]: +def calculate_all_signatures(da: DataArray, prcp: DataArray, datetime_coord: str = None) -> Dict[str, float]: """Calculate all signatures with default values. Parameters @@ -36,34 +36,35 @@ def calculate_all_signatures(da: DataArray, prcp: DataArray, datetime_coord: str prcp : DataArray Array of precipitation values. datetime_coord : str, optional - Datetime coordinate in the passed DataArray. + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. Returns ------- Dict[str, float] Dictionary with signature names as keys and signature values as values. """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) + results = { - "high_q_freq": high_q_freq(da, coord=datetime_coord), + "high_q_freq": high_q_freq(da, datetime_coord=datetime_coord), "high_q_dur": high_q_dur(da), - "low_q_freq": low_q_freq(da, coord=datetime_coord), + "low_q_freq": low_q_freq(da, datetime_coord=datetime_coord), "low_q_dur": low_q_dur(da), "zero_q_freq": zero_q_freq(da), "q95": q95(da), "q5": q5(da), "q_mean": q_mean(da), - "hfd_mean": hfd_mean(da, coord=datetime_coord), + "hfd_mean": hfd_mean(da, datetime_coord=datetime_coord), "baseflow_index": baseflow_index(da)[0], "slope_fdc": slope_fdc(da), - "stream_elas": stream_elas(da, prcp, coord=datetime_coord), - "runoff_ratio": runoff_ratio(da, prcp, coord=datetime_coord) + "stream_elas": stream_elas(da, prcp, datetime_coord=datetime_coord), + "runoff_ratio": runoff_ratio(da, prcp, datetime_coord=datetime_coord) } return results -def calculate_signatures(da: DataArray, - signatures: List[str], - datetime_coord: str = 'date', +def calculate_signatures(da: DataArray, signatures: List[str], datetime_coord: str = None, prcp: DataArray = None) -> Dict[str, float]: """Calculate the specified signatures with default values. @@ -74,7 +75,7 @@ def calculate_signatures(da: DataArray, signatures : List[str] List of names of the signatures to calculate. datetime_coord : str, optional - Datetime coordinate in the passed DataArray. + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. prcp : DataArray, optional Array of precipitation values. Required for signatures 'runoff_ratio' and 'streamflow_elas'. @@ -88,14 +89,17 @@ def calculate_signatures(da: DataArray, ValueError If a passed signature name does not exist. """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) + values = {} for signature in signatures: if signature == "high_q_freq": - values["high_q_freq"] = high_q_freq(da, coord=datetime_coord) + values["high_q_freq"] = high_q_freq(da, datetime_coord=datetime_coord) elif signature == "high_q_dur": values["high_q_dur"] = high_q_dur(da) elif signature == "low_q_freq": - values["low_q_freq"] = low_q_freq(da, coord=datetime_coord) + values["low_q_freq"] = low_q_freq(da, datetime_coord=datetime_coord) elif signature == "low_q_dur": values["low_q_dur"] = low_q_dur(da) elif signature == "zero_q_freq": @@ -107,15 +111,15 @@ def calculate_signatures(da: DataArray, elif signature == "q_mean": values["q_mean"] = q_mean(da) elif signature == "hfd_mean": - values["hfd_mean"] = hfd_mean(da, coord=datetime_coord) + values["hfd_mean"] = hfd_mean(da, datetime_coord=datetime_coord) elif signature == "baseflow_index": - values["baseflow_index"] = baseflow_index(da, coord=datetime_coord)[0] + values["baseflow_index"] = baseflow_index(da, datetime_coord=datetime_coord)[0] elif signature == "slope_fdc": values["slope_fdc"] = slope_fdc(da) elif signature == "runoff_ratio": - values["runoff_ratio"] = runoff_ratio(da, prcp, coord=datetime_coord) + values["runoff_ratio"] = runoff_ratio(da, prcp, datetime_coord=datetime_coord) elif signature == "stream_elas": - values["stream_elas"] = stream_elas(da, prcp, coord=datetime_coord) + values["stream_elas"] = stream_elas(da, prcp, datetime_coord=datetime_coord) else: ValueError(f"Unknown signatures {signature}") return values @@ -199,7 +203,6 @@ def low_q_dur(da: DataArray, threshold: float = 0.2) -> float: .. [#] Westerberg, I. K. and McMillan, H. K.: Uncertainty in hydrological signatures. Hydrology and Earth System Sciences, 2015, 19, 3951--3968, doi:10.5194/hess-19-3951-2015 """ - mean_flow = float(da.mean()) idx = np.where(da.values < threshold * mean_flow)[0] if len(idx) > 0: @@ -225,14 +228,13 @@ def zero_q_freq(da: DataArray) -> float: float Zero-flow frequency. """ - # number of steps with zero flow n_steps = (da == 0).sum() return float(n_steps / len(da)) -def high_q_freq(da: DataArray, coord: str = 'date', threshold: float = 9.) -> float: +def high_q_freq(da: DataArray, datetime_coord: str = None, threshold: float = 9.) -> float: """Calculate high-flow frequency. Frequency of high-flow events (>`threshold` times the median flow) [#]_, [#]_ (Table 2). @@ -241,8 +243,8 @@ def high_q_freq(da: DataArray, coord: str = 'date', threshold: float = 9.) -> fl ---------- da : DataArray Array of flow values. - coord : str, optional - Datetime coordinate in `da`. + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. threshold : float, optional High-flow threshold. Values larger than ``threshold * median`` are considered high flows. @@ -258,10 +260,12 @@ def high_q_freq(da: DataArray, coord: str = 'date', threshold: float = 9.) -> fl .. [#] Westerberg, I. K. and McMillan, H. K.: Uncertainty in hydrological signatures. Hydrology and Earth System Sciences, 2015, 19, 3951--3968, doi:10.5194/hess-19-3951-2015 """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) # determine the date of the first January 1st in the data period - first_date = da.coords[coord][0].values.astype('datetime64[s]').astype(datetime) - last_date = da.coords[coord][-1].values.astype('datetime64[s]').astype(datetime) + first_date = da.coords[datetime_coord][0].values.astype('datetime64[s]').astype(datetime) + last_date = da.coords[datetime_coord][-1].values.astype('datetime64[s]').astype(datetime) if first_date == datetime.strptime(f'{first_date.year}-01-01', '%Y-%m-%d'): start_date = first_date @@ -277,7 +281,7 @@ def high_q_freq(da: DataArray, coord: str = 'date', threshold: float = 9.) -> fl hqfs = [] while end_date < last_date: - data = da.sel({coord: slice(start_date, end_date)}) + data = da.sel({datetime_coord: slice(start_date, end_date)}) # number of steps with discharge higher than threshold * median in a one year period n_steps = (data > (threshold * median_flow)).sum() @@ -290,7 +294,7 @@ def high_q_freq(da: DataArray, coord: str = 'date', threshold: float = 9.) -> fl return np.mean(hqfs) -def low_q_freq(da: DataArray, coord: str = 'date', threshold: float = 0.2) -> float: +def low_q_freq(da: DataArray, datetime_coord: str = None, threshold: float = 0.2) -> float: """Calculate Low-flow frequency. Frequency of low-flow events (<`threshold` times the median flow) [#]_, [#]_ (Table 2). @@ -299,8 +303,8 @@ def low_q_freq(da: DataArray, coord: str = 'date', threshold: float = 0.2) -> fl ---------- da : DataArray Array of flow values. - coord : str, optional - Datetime coordinate in `da`. + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. threshold : float, optional Low-flow threshold. Values below ``threshold * median`` are considered low flows. @@ -316,10 +320,12 @@ def low_q_freq(da: DataArray, coord: str = 'date', threshold: float = 0.2) -> fl .. [#] Westerberg, I. K. and McMillan, H. K.: Uncertainty in hydrological signatures. Hydrology and Earth System Sciences, 2015, 19, 3951--3968, doi:10.5194/hess-19-3951-2015 """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) # determine the date of the first January 1st in the data period - first_date = da.coords[coord][0].values.astype('datetime64[s]').astype(datetime) - last_date = da.coords[coord][-1].values.astype('datetime64[s]').astype(datetime) + first_date = da.coords[datetime_coord][0].values.astype('datetime64[s]').astype(datetime) + last_date = da.coords[datetime_coord][-1].values.astype('datetime64[s]').astype(datetime) if first_date == datetime.strptime(f'{first_date.year}-01-01', '%Y-%m-%d'): start_date = first_date @@ -335,7 +341,7 @@ def low_q_freq(da: DataArray, coord: str = 'date', threshold: float = 0.2) -> fl lqfs = [] while end_date < last_date: - data = da.sel({coord: slice(start_date, end_date)}) + data = da.sel({datetime_coord: slice(start_date, end_date)}) # number of steps with discharge lower than threshold * median in a one year period n_steps = (data < (threshold * mean_flow)).sum() @@ -348,7 +354,7 @@ def low_q_freq(da: DataArray, coord: str = 'date', threshold: float = 0.2) -> fl return np.mean(lqfs) -def hfd_mean(da: DataArray, coord: str = 'date') -> float: +def hfd_mean(da: DataArray, datetime_coord: str = None) -> float: """Calculate mean half-flow duration. Mean half-flow date (step on which the cumulative discharge since October 1st @@ -358,8 +364,8 @@ def hfd_mean(da: DataArray, coord: str = 'date') -> float: ---------- da : DataArray Array of flow values. - coord : str, optional - Datetime coordinate name in `da`. + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. Returns ------- @@ -371,10 +377,12 @@ def hfd_mean(da: DataArray, coord: str = 'date') -> float: .. [#] Court, A.: Measures of streamflow timing. Journal of Geophysical Research (1896-1977), 1962, 67, 4335--4339, doi:10.1029/JZ067i011p04335 """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) # determine the date of the first October 1st in the data period - first_date = da.coords[coord][0].values.astype('datetime64[s]').astype(datetime) - last_date = da.coords[coord][-1].values.astype('datetime64[s]').astype(datetime) + first_date = da.coords[datetime_coord][0].values.astype('datetime64[s]').astype(datetime) + last_date = da.coords[datetime_coord][-1].values.astype('datetime64[s]').astype(datetime) if first_date > datetime.strptime(f'{first_date.year}-10-01', '%Y-%m-%d'): start_date = datetime.strptime(f'{first_date.year + 1}-10-01', '%Y-%m-%d') @@ -387,7 +395,7 @@ def hfd_mean(da: DataArray, coord: str = 'date') -> float: while end_date < last_date: # compute cumulative sum for the selected period - data = da.sel({coord: slice(start_date, end_date)}) + data = da.sel({datetime_coord: slice(start_date, end_date)}) cs = data.cumsum(skipna=True) # find steps with more cumulative discharge than the half annual sum @@ -417,7 +425,6 @@ def q5(da: DataArray) -> float: float 5th flow quantile. """ - return float(da.quantile(0.05)) @@ -498,7 +505,7 @@ def baseflow_index(da: DataArray, alpha: float = 0.98, warmup: int = 30, n_passes: int = None, - coord: str = 'date') -> Tuple[float, DataArray]: + datetime_coord: str = None) -> Tuple[float, DataArray]: """Calculate baseflow index. Ratio of mean baseflow to mean discharge [#]_. If `da` contains NaN values, the baseflow is calculated for each @@ -515,8 +522,9 @@ def baseflow_index(da: DataArray, n_passes : int, optional Number of passes (alternating forward and backward) to perform. Should be an odd number. If None, will use 3 for daily and 9 for hourly data and fail for all other input frequencies. - coord : str, optional - Datetime coordinate in `da`, used to infer the frequency if `n_passes` is None. + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. Used to infer the + frequency if `n_passes` is None. Returns ------- @@ -535,9 +543,11 @@ def baseflow_index(da: DataArray, Lyne and Hollick Filter. Australasian Journal of Water Resources, Taylor & Francis, 2013, 17, 25--34, doi:10.7158/13241583.2013.11465417 """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) if n_passes is None: - freq = infer_frequency(da[coord].values) + freq = utils.infer_frequency(da[datetime_coord].values) if freq == '1D': n_passes = 3 elif freq == '1H': @@ -595,7 +605,7 @@ def slope_fdc(da: DataArray, lower_quantile: float = 0.33, upper_quantile: float return value -def runoff_ratio(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: +def runoff_ratio(da: DataArray, prcp: DataArray, datetime_coord: str = None) -> float: """Calculate runoff ratio. Runoff ratio (ratio of mean discharge to mean precipitation) [#]_ (Eq. 2). @@ -606,8 +616,8 @@ def runoff_ratio(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: Array of flow values. prcp : DataArray Array of precipitation values. - coord : str, optional - Datetime dimension name in `da`. + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. Returns ------- @@ -620,11 +630,14 @@ def runoff_ratio(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: analysis of hydrologic similarity based on catchment function in the eastern USA. Hydrology and Earth System Sciences, 2011, 15, 2895--2911, doi:10.5194/hess-15-2895-2011 """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) + # rename precip coordinate name (to avoid problems with 'index' or 'date') - prcp = prcp.rename({list(prcp.coords.keys())[0]: coord}) + prcp = prcp.rename({list(prcp.coords.keys())[0]: datetime_coord}) # slice prcp to the same time window as the discharge - prcp = prcp.sel({coord: slice(da.coords[coord][0], da.coords[coord][-1])}) + prcp = prcp.sel({datetime_coord: slice(da.coords[datetime_coord][0], da.coords[datetime_coord][-1])}) # calculate runoff ratio value = da.mean() / prcp.mean() @@ -632,7 +645,7 @@ def runoff_ratio(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: return float(value) -def stream_elas(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: +def stream_elas(da: DataArray, prcp: DataArray, datetime_coord: str = None) -> float: """Calculate stream elasticity. Streamflow precipitation elasticity (sensitivity of streamflow to changes in precipitation at @@ -644,8 +657,8 @@ def stream_elas(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: Array of flow values. prcp : DataArray Array of precipitation values. - coord : str, optional - Datetime dimension name in `da`. + datetime_coord : str, optional + Datetime coordinate in the passed DataArray. Tried to infer automatically if not specified. Returns ------- @@ -657,15 +670,18 @@ def stream_elas(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: .. [#] Sankarasubramanian, A., Vogel, R. M., and Limbrunner, J. F.: Climate elasticity of streamflow in the United States. Water Resources Research, 2001, 37, 1771--1781, doi:10.1029/2000WR900330 """ + if datetime_coord is None: + datetime_coord = utils.infer_datetime_coord(da) + # rename precip coordinate name (to avoid problems with 'index' or 'date') - prcp = prcp.rename({list(prcp.coords.keys())[0]: coord}) + prcp = prcp.rename({list(prcp.coords.keys())[0]: datetime_coord}) # slice prcp to the same time window as the discharge - prcp = prcp.sel({coord: slice(da.coords[coord][0], da.coords[coord][-1])}) + prcp = prcp.sel({datetime_coord: slice(da.coords[datetime_coord][0], da.coords[datetime_coord][-1])}) # determine the date of the first October 1st in the data period - first_date = da.coords[coord][0].values.astype('datetime64[s]').astype(datetime) - last_date = da.coords[coord][-1].values.astype('datetime64[s]').astype(datetime) + first_date = da.coords[datetime_coord][0].values.astype('datetime64[s]').astype(datetime) + last_date = da.coords[datetime_coord][-1].values.astype('datetime64[s]').astype(datetime) if first_date > datetime.strptime(f'{first_date.year}-10-01', '%Y-%m-%d'): start_date = datetime.strptime(f'{first_date.year + 1}-10-01', '%Y-%m-%d') @@ -685,8 +701,8 @@ def stream_elas(da: DataArray, prcp: DataArray, coord: str = 'date') -> float: values = [] while end_date < last_date: - q = da.sel({coord: slice(start_date, end_date)}) - p = prcp.sel({coord: slice(start_date, end_date)}) + q = da.sel({datetime_coord: slice(start_date, end_date)}) + p = prcp.sel({datetime_coord: slice(start_date, end_date)}) val = (q.mean() - q_mean_total) / (p.mean() - p_mean_total) * (p_mean_total / q_mean_total) values.append(val) From 89580e6ee05988521d2f07a4d2c96c63dd52b922 Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Mon, 5 Oct 2020 14:18:12 +0200 Subject: [PATCH 04/15] Added introduction notebook to docs, some docs updates (#3) * Added introduction notebook * added nbsphinx resources * Apply suggestions from code review Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> * Update package name, integrated code review comments * Update docs/source/usage/models.rst Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> --- .gitignore | 4 +- docs/source/api/neuralhydrology.rst | 4 +- docs/source/conf.py | 16 +- docs/source/index.rst | 8 +- docs/source/tutorials/index.rst | 11 + docs/source/tutorials/introduction.nblink | 3 + docs/source/usage/models.rst | 54 +- docs/source/usage/quickstart.rst | 19 +- environments/environment_cpu.yml | 3 +- environments/environment_cuda10_2.yml | 3 +- environments/environment_cuda9_2.yml | 3 +- examples/01-Introduction/1_basin.txt | 1 + examples/01-Introduction/1_basin.yml | 145 ++++ examples/01-Introduction/Introduction.ipynb | 891 ++++++++++++++++++++ 14 files changed, 1124 insertions(+), 41 deletions(-) create mode 100644 docs/source/tutorials/index.rst create mode 100644 docs/source/tutorials/introduction.nblink create mode 100644 examples/01-Introduction/1_basin.txt create mode 100644 examples/01-Introduction/1_basin.yml create mode 100644 examples/01-Introduction/Introduction.ipynb diff --git a/.gitignore b/.gitignore index c1832336..3a8ddfb2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,8 @@ dist/* neuralhydrology.egg-info/* .vscode/* .idea/* -runs/* +runs/ configs/* -.ipynb_checkpoints/* +.ipynb_checkpoints/ data/* docs/build/* diff --git a/docs/source/api/neuralhydrology.rst b/docs/source/api/neuralhydrology.rst index 21746964..eddeb281 100644 --- a/docs/source/api/neuralhydrology.rst +++ b/docs/source/api/neuralhydrology.rst @@ -1,5 +1,5 @@ -neuralhydrology -=============== +neuralhydrology API +=================== .. automodule:: neuralhydrology :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index 75568bd9..044745aa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,18 +10,22 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # +import datetime import os import sys sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath('../../')) # -- Project information ----------------------------------------------------- +about = {} +with open('../../neuralhydrology/__about__.py', "r") as fp: + exec(fp.read(), about) -project = 'NeuralHydrology' -copyright = '2020, Frederik Kratzert' +project = 'neuralHydrology' +copyright = f'{datetime.datetime.now().year}, Frederik Kratzert' author = 'Frederik Kratzert' # The full version, including alpha/beta/rc tags -release = '0.9.0-beta' +release = about["__version__"] # -- General configuration --------------------------------------------------- @@ -31,7 +35,9 @@ extensions = [ 'sphinx.ext.autodoc', # autodocument 'sphinx.ext.napoleon', # google and numpy doc string support - 'sphinx.ext.mathjax' # latex rendering of equations using MathJax + 'sphinx.ext.mathjax', # latex rendering of equations using MathJax + 'nbsphinx', # for direct embedding of jupyter notebooks into sphinx docs + 'nbsphinx_link' # to be able to include notebooks from outside of the docs folder ] # Add any paths that contain templates here, relative to this directory. @@ -40,7 +46,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [] +exclude_patterns = ['**.ipynb_checkpoints'] # -- Options for HTML output ------------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index c57b6085..0e023dc4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,13 +3,17 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to NeuralHydrology's documentation! +Welcome to neuralHydrology's documentation! =========================================== +The documentation is still work-in-progress. Stay tuned for a lot of updates during the next days/weeks, as well as +a handful of tutorials. + .. toctree:: - :maxdepth: 5 + :maxdepth: 2 :caption: Contents: usage/quickstart usage/models + tutorials/index api/neuralhydrology diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst new file mode 100644 index 00000000..5f6e8275 --- /dev/null +++ b/docs/source/tutorials/index.rst @@ -0,0 +1,11 @@ +Tutorials +--------- + +We will gradually add more tutorials over the next couple of weeks, highlighting some of +the functionality of this Python package. + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + introduction diff --git a/docs/source/tutorials/introduction.nblink b/docs/source/tutorials/introduction.nblink new file mode 100644 index 00000000..bd966504 --- /dev/null +++ b/docs/source/tutorials/introduction.nblink @@ -0,0 +1,3 @@ +{ + "path": "../../../examples/01-Introduction/Introduction.ipynb" +} \ No newline at end of file diff --git a/docs/source/usage/models.rst b/docs/source/usage/models.rst index 567b77bb..e1095ad4 100644 --- a/docs/source/usage/models.rst +++ b/docs/source/usage/models.rst @@ -1,28 +1,29 @@ -Models -====== +Modelzoo +======== -The following section gives an overview over all implemented models. +The following section gives an overview of all implemented models. See `Implementing a new model`_ for details +on how to add your own model to the neuralHydrology package. + +BaseModel +--------- +Abstract base class from which all models derive. Do not use this class for model training. CudaLSTM -------- :py:class:`neuralhydrology.modelzoo.cudalstm.CudaLSTM` is a network using the standard PyTorch LSTM implementation. -All features (``x_d``, ``x_s``, ``x_one_hot``) are concatenated and passed at each time step. -Initial forget gate bias can be set (in config.yml) and will be set during model initialization. +All features (``x_d``, ``x_s``, ``x_one_hot``) are concatenated and passed to the network at each time step. +The initial forget gate bias can be defined in config.yml (``initial_forget_bias``) and will be set accordingly during +model initialization. EA-LSTM ------- -:py:class:`neuralhydrology.modelzoo.ealstm.EALSTM` is an implementation of the Entity-Aware LSTM, as used -in the 2019 HESS paper. The static features (``x_s`` and/or ``x_one_hot``) are used to compute the input gate -activations, while ``x_d`` is used in all other gates of the network. -Initial forget gate bias can be set, and if ``embedding_hiddens`` is passed, the input gate consists of the so-defined +:py:class:`neuralhydrology.modelzoo.ealstm.EALSTM` is an implementation of the Entity-Aware LSTM, as introduced in +`Kratzert et al. "Towards learning universal, regional, and local hydrological behaviors via machine learning applied to large-sample datasets" `__. +The static features (``x_s`` and/or ``x_one_hot``) are used to compute the input gate activations, while the dynamic +inputs ``x_d`` are used in all other gates of the network. +The initial forget gate bias can be defined in config.yml (``initial_forget_bias``). If ``embedding_hiddens`` is passed, the input gate consists of the so-defined FC network and not a single linear layer. -LSTM ----- -:py:class:`neuralhydrology.modelzoo.lstm.LSTM` is an own LSTM implementation. -Momentarily, the only advantage compared to CudaLSTM is the return of the entire cell state array. -This class will be most likely adapted/changed in the near future to provide much more flexibility for various settings. - EmbCudaLSTM ----------- :py:class:`neuralhydrology.modelzoo.embcudalstm.EmbCudaLSTM` is similar to `CudaLSTM`_, @@ -31,6 +32,26 @@ with the only difference that static inputs (``x_s`` and/or ``x_one_hot``) are p at each time step. +LSTM +---- +:py:class:`neuralhydrology.modelzoo.lstm.LSTM` is a PyTorch port of the CudaLSTM that returns all gate and state +activations for all time steps. This class is implemented for exploratory reasons. You can use the method +``model.copy_weights()`` to copy the weights of a ``CudaLSTM`` model into an ``LSTM`` model. This allows to use the fast +CUDA implementation for training, and only use this class for inference with more detailed outputs. + +MultiFreqLSTM +------------- +:py:class:`neuralhydrology.modelzoo.multifreqlstm.MultiFreqLSTM` is a newly proposed model by Gauch et al. (pre-print +published soon). This model allows the training on more than one temporal frequency (e.g. daily and hourly inputs) and +returns multi-frequency model predictions accordingly. A more detailed tutorial will follow shortly. + +ODELSTM +------- +:py:class:`neuralhydrology.modelzoo.odelstm.ODELSTM` is a PyTorch implementation of the ODE-LSTM proposed by +`Lechner and Hasani `_. This model can be used with unevenly sampled inputs and can +be queried to return predictions for any arbitrary time step. + + Implementing a new model ------------------------ The listing below shows the skeleton of a template model you can use to start implementing your own model. @@ -38,7 +59,7 @@ Once you have implemented your model, make sure to modify :py:func:`neuralhydrol Furthermore, make sure to select a *unique* model abbreviation that will be used to specify the model in the config.yml files. -:: +.. code-block:: python from typing import Dict @@ -105,4 +126,3 @@ files. # Implement forward pass here # ############################### pass - diff --git a/docs/source/usage/quickstart.rst b/docs/source/usage/quickstart.rst index 25c396c5..18a018a8 100644 --- a/docs/source/usage/quickstart.rst +++ b/docs/source/usage/quickstart.rst @@ -3,18 +3,17 @@ Quick Start Installation ------------ -The neuralhydrology project is available on PyPI. -Hence, installation is as easy as:: +For now, download or clone the repository to your local machine and install a local, editable copy. +This is a good idea if you want to edit the ``neuralhydrology`` code (e.g., adding new models or datasets) - pip install neuralhydrology +.. code-block:: -Alternatively, you can clone the repository and install the local, editable copy. This is a good idea if you want to -edit the ``neuralhydrology`` code (e.g., adding new models or datasets).:: - - git clone https://github.com/kratzert/lstm_based_hydrology.git - cd lstm_based_hydrology + git clone https://github.com/neuralhydrology/neuralhydrology.git + cd neuralhydrology pip install -e . +Besides adding the package to your Python environment, it will also add three bash scripts: +`nh-run`, `nh-run-scheduler` and `nh-results-ensemble`. For details, see below. Data ---- @@ -35,7 +34,7 @@ To train a model, prepare a configuration file, then run:: If you want to train multiple models, you can make use of the ``nh-run-scheduler`` command. Place all configs in a folder, then run:: - nh-run-scheduler --config-dir /path/to/config_dir/ --runs-per-gpu X --gpu-ids Y + nh-run-scheduler train --config-dir /path/to/config_dir/ --runs-per-gpu X --gpu-ids Y With X, you can specify how many models should be trained on parallel on a single GPU. With Y, you can specify which GPUs to use for training (use the id as specified in ``nvidia-smi``). @@ -52,7 +51,7 @@ the weights of the last epoch are used. To evaluate all runs in a specific directory you can, similarly to training, run:: - nh-run-scheduler --mode evaluate --run-dir /path/to/config_dir/ --runs-per-gpu X --gpu-ids Y + nh-run-scheduler evaluate --run-dir /path/to/config_dir/ --runs-per-gpu X --gpu-ids Y To merge the predictons of a number of runs (stored in ``$DIR1``, ...) into one averaged ensemble, diff --git a/environments/environment_cpu.yml b/environments/environment_cpu.yml index cb1e768b..ce9d16af 100644 --- a/environments/environment_cpu.yml +++ b/environments/environment_cpu.yml @@ -27,4 +27,5 @@ dependencies: - pip: - tensorboard - sphinx-rtd-theme - + - nbsphinx + - nbsphinx-link \ No newline at end of file diff --git a/environments/environment_cuda10_2.yml b/environments/environment_cuda10_2.yml index 0b2f5a8f..1e6ab168 100644 --- a/environments/environment_cuda10_2.yml +++ b/environments/environment_cuda10_2.yml @@ -27,4 +27,5 @@ dependencies: - pip: - tensorboard - sphinx-rtd-theme - + - nbsphinx + - nbsphinx-link \ No newline at end of file diff --git a/environments/environment_cuda9_2.yml b/environments/environment_cuda9_2.yml index 5e06c5ff..54020dbd 100644 --- a/environments/environment_cuda9_2.yml +++ b/environments/environment_cuda9_2.yml @@ -27,4 +27,5 @@ dependencies: - pip: - tensorboard - sphinx-rtd-theme - + - nbsphinx + - nbsphinx-link \ No newline at end of file diff --git a/examples/01-Introduction/1_basin.txt b/examples/01-Introduction/1_basin.txt new file mode 100644 index 00000000..94860eb2 --- /dev/null +++ b/examples/01-Introduction/1_basin.txt @@ -0,0 +1 @@ +01022500 diff --git a/examples/01-Introduction/1_basin.yml b/examples/01-Introduction/1_basin.yml new file mode 100644 index 00000000..0b5e3e28 --- /dev/null +++ b/examples/01-Introduction/1_basin.yml @@ -0,0 +1,145 @@ +# --- Experiment configurations -------------------------------------------------------------------- + +# experiment name, used as folder name +experiment_name: test_run + +# files to specify training, validation and test basins (relative to code root or absolute path) +train_basin_file: 1_basin.txt +validation_basin_file: 1_basin.txt +test_basin_file: 1_basin.txt + +# training, validation and test time periods (format = 'dd/mm/yyyy') +train_start_date: '01/10/1999' +train_end_date: '30/09/2008' +validation_start_date: '01/10/1980' +validation_end_date: '30/09/1989' +test_start_date: '01/10/1989' +test_end_date: '30/09/1999' + +# which GPU (id) to use [in format of cuda:0, cuda:1 etc, or cpu or None] +device: cuda:0 + +# --- Validation configuration --------------------------------------------------------------------- + +# specify after how many epochs to perform validation +validate_every: 3 + +# specify how many random basins to use for validation +validate_n_random_basins: 1 + +# specify which metrics to calculate during validation (see neuralhydrology.evaluation.metrics) +# this can either be a list or a dictionary. If a dictionary is used, the inner keys must match the name of the +# target_variable specified below. Using dicts allows for different metrics per target variable. +metrics: +- NSE + +# --- Model configuration -------------------------------------------------------------------------- + +# base model type [lstm, ealstm, cudalstm, embcudalstm, multifreqlstm] +# (has to match the if statement in modelzoo/__init__.py) +model: cudalstm + +# prediction head [regression]. Define the head specific parameters below +head: regression + +# ----> Regression settings <---- +output_activation: linear + +# ----> General settings <---- + +# Number of cell states of the LSTM +hidden_size: 20 + +# Initial bias value of the forget gate +initial_forget_bias: 3 + +# Dropout applied to the output of the LSTM +output_dropout: 0.4 + +# --- Training configuration ----------------------------------------------------------------------- + +# specify optimizer [Adam] +optimizer: Adam + +# specify loss [MSE, NSE, RMSE] +loss: MSE + +# specify learning rates to use starting at specific epochs (0 is the initial learning rate) +learning_rate: + 0: 1e-2 + 30: 5e-3 + 40: 1e-3 + +# Mini-batch size +batch_size: 256 + +# Number of training epochs +epochs: 50 + +# If a value, clips the gradients during training to that norm. +clip_gradient_norm: 1 + +# Defines which time steps are used to calculate the loss. Can't be larger than seq_length. +# If use_frequencies is used, this needs to be a dict mapping each frequency to a predict_last_n-value, else an int. +predict_last_n: 1 + +# Length of the input sequence +# If use_frequencies is used, this needs to be a dict mapping each frequency to a seq_length, else an int. +seq_length: 365 + +# Number of parallel workers used in the data pipeline +num_workers: 8 + +# Log the training loss every n steps +log_interval: 5 + +# If true, writes logging results into tensorboard file +log_tensorboard: True + +# If a value and greater than 0, logs n random basins as figures during validation +log_n_figures: 1 + +# Save model weights every n epochs +save_weights_every: 1 + +# --- Data configurations -------------------------------------------------------------------------- + +# which data set to use [camels_us, camels_gb, global, hourly_camels_us] +dataset: camels_us + +# Path to data set root +data_dir: /data/Hydrology/CAMELS_US + +# Forcing product [daymet, maurer, maurer_extended, nldas, nldas_extended, nldas_hourly] +# can be either a list of forcings or a single forcing product +forcings: +- maurer_extended +- daymet +- nldas_extended + +dynamic_inputs: +- PRCP(mm/day)_nldas_extended +- SRAD(W/m2)_nldas_extended +- Tmax(C)_nldas_extended +- Tmin(C)_nldas_extended +- Vp(Pa)_nldas_extended +- prcp(mm/day)_maurer_extended +- srad(W/m2)_maurer_extended +- tmax(C)_maurer_extended +- tmin(C)_maurer_extended +- vp(Pa)_maurer_extended +- prcp(mm/day)_daymet +- srad(W/m2)_daymet +- tmax(C)_daymet +- tmin(C)_daymet +- vp(Pa)_daymet + +# which columns to use as target +target_variables: +- QObs(mm/d) + +# clip negative predictions to zero for all variables listed below. Should be a list, even for single variables. +clip_target_to_zero: +- QObs(mm/d) + +zero_center_target: True diff --git a/examples/01-Introduction/Introduction.ipynb b/examples/01-Introduction/Introduction.ipynb new file mode 100644 index 00000000..aa2525cf --- /dev/null +++ b/examples/01-Introduction/Introduction.ipynb @@ -0,0 +1,891 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Introduction to neuralHydrology\n", + "The Python package `neuralHydrology` was was developed with a strong focus on research. The main application area is hydrology, however, in principle the code can be used with any data. To allow fast iteration of research ideas, we tried to develop the package as modular as possible so that new models, new data sets, new loss functions, new regularizations, new metrics etc. can be integrated with minor effort.\n", + "\n", + "There are two different ways to use this package:\n", + "\n", + "1. From the terminal, making use of some high-level entry points (such as `nh-run` and `nh-run-scheduler`)\n", + "2. From any other Python file or Jupyter Notebook, using neuralHydrology's API\n", + "\n", + "In this tutorial, we will give a very short overview of the two different modes.\n", + "\n", + "Both approaches require a **configuration file**. These are `.yml` files which define the entire run configuration (such as data set, basins, data periods, model specifications, etc.). A full list of config arguments is listed in the [Wiki on GitHub](https://github.com/neuralhydrology/neuralhydrology/wiki/Config-arguments) and we highly recommend to check this page and read the documentation carefully. There is a lot that you can do with this Python package and we can't cover everything in tutorials.\n", + "\n", + "For every run that you start, a new folder will be created. This folder is used to store the model and optimizer checkpoints, train data means/stds (needed for scaling during inference), tensorboard log file (can be used to monitor and compare training runs visually), validation results (optionally) and training progress figures (optionally, e.g., model predictions and observations for _n_ random basins). During inference, the evaluation results will also be stored in this directory (e.g., test period results).\n", + "\n", + "\n", + "### TensorBoard logging\n", + "By default, the training progress is logged in TensorBoard files (add `log_tensorboard: False` to the config to disable TensorBoard logging). If you installed a Python environment from one of our environment files, you have TensorBoard already installed. If not, you can install TensorBoard with:\n", + "\n", + "```\n", + "pip install tensorboard\n", + "``` \n", + "\n", + "To start the TensorBoard dashboard, run:\n", + "\n", + "```\n", + "tensorboard --logdir /path/to/run-dir\n", + "```\n", + "\n", + "You can also visualize multiple runs at once if you point the `--logdir` to the parent directory (useful for model intercomparison)\n", + "\n", + "### File logging\n", + "In addition to TensorBoard, you will always find a file called `output.log` in the run directory. This file is a dump of the console output you see during training and evaluation.\n", + "\n", + "\n", + "## Using `neuralHydrology` from the Terminal\n", + "\n", + "### nh-run\n", + "\n", + "\n", + "Given a run configuration file, you can use the bash command `nh-run` to train/evaluate a model. To train a model, use\n", + "\n", + "\n", + "```bash\n", + "nh-run train --config-file path/to/config.yml\n", + "```\n", + "\n", + "to evaluate the model after training, use\n", + "\n", + "```bash\n", + "nh-run evaluate --run-dir path/to/run-directory\n", + "```\n", + "\n", + "### nh-run-scheduler\n", + "\n", + "If you want to train/evaluate multiple models on different GPUs, you can use the `nh-run-scheduler`. This tool automatically distributes runs across GPUs and starts a new one, whenever one run finishes.\n", + "\n", + "Calling `nh-run-scheduler` in `train` mode will train one model for each `.yml` file in a directory (or its sub-directories).\n", + "\n", + "```bash\n", + "nh-run-scheduler train --directory /path/to/config-dir --runs-per-gpu 2 --gpu_ids 0 1 2 3 \n", + "```\n", + "Use `-runs-per-gpu` to define the number of models that are simultaneously trained on a _single_ GPU (2 in this case) and `--gpu-ids` to define which GPUs will be used (numbers are ids according to nvidia-smi). In this example, 8 models will train simultaneously on 4 different GPUs.\n", + "\n", + "Calling `nh-run-scheduler` in `evaluate` mode will evaluate all models in all run directories in a given root directory.\n", + "\n", + "```bash\n", + "nh-run-scheduler evaluate --directory /path/to/parent-run-dir/ --runs-per-gpu 2 --gpu_ids 0 1 2 3 \n", + "```\n", + "\n", + "## API usage\n", + "\n", + "Besides the command line tools, you can also use the neuralHydrology package just like any other Python package by importing its modules, classes, or functions.\n", + "\n", + "This can be helpful for exploratory studies with trained models, but also if you want to use some of the functions or classes within a different codebase. \n", + "\n", + "Look at the [API Documentation](https://neuralhydrology.readthedocs.io/en/latest/api/neuralhydrology.html) for a full list of functions/classes you could use.\n", + "\n", + "The following example shows how to train and evaluate a model via the API." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pickle\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "from neuralhydrology.evaluation import metrics\n", + "from neuralhydrology.nh_run import start_run, eval_run" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Train a model for a single config file" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "scrolled": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2020-10-05 12:52:52,508: Logging to /home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252/output.log initialized.\n", + "2020-10-05 12:52:52,510: ### Folder structure created at /home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252\n", + "2020-10-05 12:52:52,510: ### Run configurations for test_run\n", + "2020-10-05 12:52:52,511: experiment_name: test_run\n", + "2020-10-05 12:52:52,512: train_basin_file: 1_basin.txt\n", + "2020-10-05 12:52:52,513: validation_basin_file: 1_basin.txt\n", + "2020-10-05 12:52:52,514: test_basin_file: 1_basin.txt\n", + "2020-10-05 12:52:52,514: train_start_date: 1999-10-01 00:00:00\n", + "2020-10-05 12:52:52,515: train_end_date: 2008-09-30 00:00:00\n", + "2020-10-05 12:52:52,516: validation_start_date: 1980-10-01 00:00:00\n", + "2020-10-05 12:52:52,517: validation_end_date: 1989-09-30 00:00:00\n", + "2020-10-05 12:52:52,517: test_start_date: 1989-10-01 00:00:00\n", + "2020-10-05 12:52:52,518: test_end_date: 1999-09-30 00:00:00\n", + "2020-10-05 12:52:52,519: device: cuda:0\n", + "2020-10-05 12:52:52,520: validate_every: 3\n", + "2020-10-05 12:52:52,520: validate_n_random_basins: 1\n", + "2020-10-05 12:52:52,521: metrics: ['NSE']\n", + "2020-10-05 12:52:52,522: model: cudalstm\n", + "2020-10-05 12:52:52,524: head: regression\n", + "2020-10-05 12:52:52,524: output_activation: linear\n", + "2020-10-05 12:52:52,525: hidden_size: 20\n", + "2020-10-05 12:52:52,525: initial_forget_bias: 3\n", + "2020-10-05 12:52:52,526: output_dropout: 0.4\n", + "2020-10-05 12:52:52,527: optimizer: Adam\n", + "2020-10-05 12:52:52,527: loss: MSE\n", + "2020-10-05 12:52:52,528: learning_rate: {0: 0.01, 30: 0.005, 40: 0.001}\n", + "2020-10-05 12:52:52,529: batch_size: 256\n", + "2020-10-05 12:52:52,529: epochs: 50\n", + "2020-10-05 12:52:52,530: clip_gradient_norm: 1\n", + "2020-10-05 12:52:52,530: predict_last_n: 1\n", + "2020-10-05 12:52:52,531: seq_length: 365\n", + "2020-10-05 12:52:52,533: num_workers: 8\n", + "2020-10-05 12:52:52,534: log_interval: 5\n", + "2020-10-05 12:52:52,535: log_tensorboard: True\n", + "2020-10-05 12:52:52,536: log_n_figures: 1\n", + "2020-10-05 12:52:52,537: save_weights_every: 1\n", + "2020-10-05 12:52:52,537: dataset: camels_us\n", + "2020-10-05 12:52:52,538: data_dir: /data/Hydrology/CAMELS_US\n", + "2020-10-05 12:52:52,539: forcings: ['maurer_extended', 'daymet', 'nldas_extended']\n", + "2020-10-05 12:52:52,539: dynamic_inputs: ['PRCP(mm/day)_nldas_extended', 'SRAD(W/m2)_nldas_extended', 'Tmax(C)_nldas_extended', 'Tmin(C)_nldas_extended', 'Vp(Pa)_nldas_extended', 'prcp(mm/day)_maurer_extended', 'srad(W/m2)_maurer_extended', 'tmax(C)_maurer_extended', 'tmin(C)_maurer_extended', 'vp(Pa)_maurer_extended', 'prcp(mm/day)_daymet', 'srad(W/m2)_daymet', 'tmax(C)_daymet', 'tmin(C)_daymet', 'vp(Pa)_daymet']\n", + "2020-10-05 12:52:52,540: target_variables: ['QObs(mm/d)']\n", + "2020-10-05 12:52:52,541: clip_target_to_zero: ['QObs(mm/d)']\n", + "2020-10-05 12:52:52,542: zero_center_target: True\n", + "2020-10-05 12:52:52,543: number_of_basins: 1\n", + "2020-10-05 12:52:52,544: run_dir: /home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252\n", + "2020-10-05 12:52:52,544: train_dir: /home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252/train_data\n", + "2020-10-05 12:52:52,545: img_log_dir: /home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252/img_log\n", + "2020-10-05 12:52:52,586: ### Device cuda:0 will be used for training\n", + "2020-10-05 12:52:55,028: Loading basin data into xarray data set.\n", + "100%|██████████| 1/1 [00:00<00:00, 5.97it/s]\n", + "2020-10-05 12:52:55,213: Create lookup table and convert to pytorch tensor\n", + "100%|██████████| 1/1 [00:01<00:00, 1.29s/it]\n", + "# Epoch 1: 100%|██████████| 13/13 [00:00<00:00, 21.75it/s, Loss: 0.3369]\n", + "2020-10-05 12:52:57,229: Epoch 1 average loss: 0.40770340997439164\n", + "# Epoch 2: 100%|██████████| 13/13 [00:00<00:00, 21.06it/s, Loss: 0.1233]\n", + "2020-10-05 12:52:57,852: Epoch 2 average loss: 0.2696097624989656\n", + "# Epoch 3: 100%|██████████| 13/13 [00:00<00:00, 20.21it/s, Loss: 0.1440]\n", + "2020-10-05 12:52:58,503: Epoch 3 average loss: 0.2056583693394294\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 1.15it/s]\n", + "2020-10-05 12:52:59,650: -- Median validation metrics:NSE: 0.62918\n", + "# Epoch 4: 100%|██████████| 13/13 [00:00<00:00, 21.25it/s, Loss: 0.1681]\n", + "2020-10-05 12:53:00,265: Epoch 4 average loss: 0.16614325917684114\n", + "# Epoch 5: 100%|██████████| 13/13 [00:00<00:00, 20.18it/s, Loss: 0.0893]\n", + "2020-10-05 12:53:00,916: Epoch 5 average loss: 0.1314379280576339\n", + "# Epoch 6: 100%|██████████| 13/13 [00:00<00:00, 20.25it/s, Loss: 0.1715]\n", + "2020-10-05 12:53:01,565: Epoch 6 average loss: 0.11709093875609912\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 3.77it/s]\n", + "2020-10-05 12:53:02,108: -- Median validation metrics:NSE: 0.70598\n", + "# Epoch 7: 100%|██████████| 13/13 [00:00<00:00, 19.89it/s, Loss: 0.0765]\n", + "2020-10-05 12:53:02,766: Epoch 7 average loss: 0.10338054998562886\n", + "# Epoch 8: 100%|██████████| 13/13 [00:00<00:00, 19.77it/s, Loss: 0.0803]\n", + "2020-10-05 12:53:03,430: Epoch 8 average loss: 0.09337395773484157\n", + "# Epoch 9: 100%|██████████| 13/13 [00:00<00:00, 18.54it/s, Loss: 0.0631]\n", + "2020-10-05 12:53:04,137: Epoch 9 average loss: 0.09041231240217502\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 3.74it/s]\n", + "2020-10-05 12:53:04,691: -- Median validation metrics:NSE: 0.72472\n", + "# Epoch 10: 100%|██████████| 13/13 [00:01<00:00, 11.72it/s, Loss: 0.1419]\n", + "2020-10-05 12:53:05,804: Epoch 10 average loss: 0.08599556208803104\n", + "# Epoch 11: 100%|██████████| 13/13 [00:00<00:00, 20.62it/s, Loss: 0.1633]\n", + "2020-10-05 12:53:06,442: Epoch 11 average loss: 0.08094931651766483\n", + "# Epoch 12: 100%|██████████| 13/13 [00:00<00:00, 20.40it/s, Loss: 0.0485]\n", + "2020-10-05 12:53:07,085: Epoch 12 average loss: 0.0698587424479998\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 4.31it/s]\n", + "2020-10-05 12:53:07,590: -- Median validation metrics:NSE: 0.77600\n", + "# Epoch 13: 100%|██████████| 13/13 [00:00<00:00, 21.13it/s, Loss: 0.0707]\n", + "2020-10-05 12:53:08,210: Epoch 13 average loss: 0.07356188331659023\n", + "# Epoch 14: 100%|██████████| 13/13 [00:00<00:00, 22.66it/s, Loss: 0.0825]\n", + "2020-10-05 12:53:08,788: Epoch 14 average loss: 0.07214784335631591\n", + "# Epoch 15: 100%|██████████| 13/13 [00:00<00:00, 21.50it/s, Loss: 0.0473]\n", + "2020-10-05 12:53:09,399: Epoch 15 average loss: 0.06782861340504426\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 3.59it/s]\n", + "2020-10-05 12:53:09,974: -- Median validation metrics:NSE: 0.79518\n", + "# Epoch 16: 100%|██████████| 13/13 [00:00<00:00, 22.83it/s, Loss: 0.0356]\n", + "2020-10-05 12:53:10,548: Epoch 16 average loss: 0.06452611088752747\n", + "# Epoch 17: 100%|██████████| 13/13 [00:00<00:00, 18.94it/s, Loss: 0.0343]\n", + "2020-10-05 12:53:11,239: Epoch 17 average loss: 0.06379958213521884\n", + "# Epoch 18: 100%|██████████| 13/13 [00:00<00:00, 21.93it/s, Loss: 0.0528]\n", + "2020-10-05 12:53:11,838: Epoch 18 average loss: 0.06280213909653518\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 4.13it/s]\n", + "2020-10-05 12:53:12,351: -- Median validation metrics:NSE: 0.80859\n", + "# Epoch 19: 100%|██████████| 13/13 [00:00<00:00, 21.07it/s, Loss: 0.0701]\n", + "2020-10-05 12:53:12,972: Epoch 19 average loss: 0.0555433054956106\n", + "# Epoch 20: 100%|██████████| 13/13 [00:00<00:00, 21.90it/s, Loss: 0.0627]\n", + "2020-10-05 12:53:13,571: Epoch 20 average loss: 0.062321179188214816\n", + "# Epoch 21: 100%|██████████| 13/13 [00:00<00:00, 21.03it/s, Loss: 0.0453]\n", + "2020-10-05 12:53:14,194: Epoch 21 average loss: 0.05481961842339773\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 4.32it/s]\n", + "2020-10-05 12:53:14,698: -- Median validation metrics:NSE: 0.81559\n", + "# Epoch 22: 100%|██████████| 13/13 [00:00<00:00, 20.03it/s, Loss: 0.1060]\n", + "2020-10-05 12:53:15,351: Epoch 22 average loss: 0.06080526944536429\n", + "# Epoch 23: 100%|██████████| 13/13 [00:00<00:00, 20.79it/s, Loss: 0.0510]\n", + "2020-10-05 12:53:15,983: Epoch 23 average loss: 0.0530552279490691\n", + "# Epoch 24: 100%|██████████| 13/13 [00:00<00:00, 21.31it/s, Loss: 0.0569]\n", + "2020-10-05 12:53:16,598: Epoch 24 average loss: 0.05936390132858203\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 2.85it/s]\n", + "2020-10-05 12:53:17,205: -- Median validation metrics:NSE: 0.79431\n", + "# Epoch 25: 100%|██████████| 13/13 [00:00<00:00, 21.29it/s, Loss: 0.0278]\n", + "2020-10-05 12:53:17,821: Epoch 25 average loss: 0.05501310441356439\n", + "# Epoch 26: 100%|██████████| 13/13 [00:00<00:00, 21.44it/s, Loss: 0.0452]\n", + "2020-10-05 12:53:18,432: Epoch 26 average loss: 0.05229091515334753\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "# Epoch 27: 100%|██████████| 13/13 [00:00<00:00, 22.24it/s, Loss: 0.0588]\n", + "2020-10-05 12:53:19,021: Epoch 27 average loss: 0.04917494379557096\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 4.42it/s]\n", + "2020-10-05 12:53:19,514: -- Median validation metrics:NSE: 0.81387\n", + "# Epoch 28: 100%|██████████| 13/13 [00:00<00:00, 20.37it/s, Loss: 0.0417]\n", + "2020-10-05 12:53:20,157: Epoch 28 average loss: 0.046723469805258974\n", + "# Epoch 29: 100%|██████████| 13/13 [00:00<00:00, 19.72it/s, Loss: 0.0399]\n", + "2020-10-05 12:53:20,822: Epoch 29 average loss: 0.04707713339191217\n", + "2020-10-05 12:53:20,826: Setting learning rate to 0.005\n", + "# Epoch 30: 100%|██████████| 13/13 [00:00<00:00, 20.35it/s, Loss: 0.0569]\n", + "2020-10-05 12:53:21,468: Epoch 30 average loss: 0.04919698060705112\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 3.89it/s]\n", + "2020-10-05 12:53:22,026: -- Median validation metrics:NSE: 0.81469\n", + "# Epoch 31: 100%|██████████| 13/13 [00:00<00:00, 18.51it/s, Loss: 0.0412]\n", + "2020-10-05 12:53:22,732: Epoch 31 average loss: 0.046678220136807516\n", + "# Epoch 32: 100%|██████████| 13/13 [00:00<00:00, 19.51it/s, Loss: 0.0385]\n", + "2020-10-05 12:53:23,405: Epoch 32 average loss: 0.04431860951276926\n", + "# Epoch 33: 100%|██████████| 13/13 [00:00<00:00, 18.42it/s, Loss: 0.0381]\n", + "2020-10-05 12:53:24,116: Epoch 33 average loss: 0.04529069263774615\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 3.29it/s]\n", + "2020-10-05 12:53:24,701: -- Median validation metrics:NSE: 0.82519\n", + "# Epoch 34: 100%|██████████| 13/13 [00:00<00:00, 19.21it/s, Loss: 0.0354]\n", + "2020-10-05 12:53:25,382: Epoch 34 average loss: 0.047089104182445086\n", + "# Epoch 35: 100%|██████████| 13/13 [00:00<00:00, 20.25it/s, Loss: 0.0414]\n", + "2020-10-05 12:53:26,030: Epoch 35 average loss: 0.03992394959697357\n", + "# Epoch 36: 100%|██████████| 13/13 [00:00<00:00, 20.30it/s, Loss: 0.0488]\n", + "2020-10-05 12:53:26,678: Epoch 36 average loss: 0.04217390658763739\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 4.24it/s]\n", + "2020-10-05 12:53:27,180: -- Median validation metrics:NSE: 0.82312\n", + "# Epoch 37: 100%|██████████| 13/13 [00:00<00:00, 20.71it/s, Loss: 0.0486]\n", + "2020-10-05 12:53:27,811: Epoch 37 average loss: 0.04324197224699534\n", + "# Epoch 38: 100%|██████████| 13/13 [00:00<00:00, 20.47it/s, Loss: 0.0845]\n", + "2020-10-05 12:53:28,451: Epoch 38 average loss: 0.042667378599827104\n", + "# Epoch 39: 100%|██████████| 13/13 [00:00<00:00, 20.77it/s, Loss: 0.0399]\n", + "2020-10-05 12:53:29,082: Epoch 39 average loss: 0.043971521636614434\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 2.85it/s]\n", + "2020-10-05 12:53:29,695: -- Median validation metrics:NSE: 0.81838\n", + "2020-10-05 12:53:29,696: Setting learning rate to 0.001\n", + "# Epoch 40: 100%|██████████| 13/13 [00:00<00:00, 20.51it/s, Loss: 0.0629]\n", + "2020-10-05 12:53:30,334: Epoch 40 average loss: 0.047052909405185625\n", + "# Epoch 41: 100%|██████████| 13/13 [00:00<00:00, 21.16it/s, Loss: 0.0358]\n", + "2020-10-05 12:53:30,955: Epoch 41 average loss: 0.037803018608918555\n", + "# Epoch 42: 100%|██████████| 13/13 [00:00<00:00, 21.40it/s, Loss: 0.0514]\n", + "2020-10-05 12:53:31,568: Epoch 42 average loss: 0.04351525338223347\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 4.34it/s]\n", + "2020-10-05 12:53:32,075: -- Median validation metrics:NSE: 0.82342\n", + "# Epoch 43: 100%|██████████| 13/13 [00:00<00:00, 21.82it/s, Loss: 0.0397]\n", + "2020-10-05 12:53:32,675: Epoch 43 average loss: 0.03911813692404674\n", + "# Epoch 44: 100%|██████████| 13/13 [00:00<00:00, 18.98it/s, Loss: 0.0404]\n", + "2020-10-05 12:53:33,365: Epoch 44 average loss: 0.042462579905986786\n", + "# Epoch 45: 100%|██████████| 13/13 [00:00<00:00, 19.18it/s, Loss: 0.0339]\n", + "2020-10-05 12:53:34,051: Epoch 45 average loss: 0.04013453395320819\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 3.41it/s]\n", + "2020-10-05 12:53:34,639: -- Median validation metrics:NSE: 0.83013\n", + "# Epoch 46: 100%|██████████| 13/13 [00:00<00:00, 18.73it/s, Loss: 0.0397]\n", + "2020-10-05 12:53:35,337: Epoch 46 average loss: 0.04252039125332466\n", + "# Epoch 47: 100%|██████████| 13/13 [00:00<00:00, 19.62it/s, Loss: 0.0451]\n", + "2020-10-05 12:53:36,005: Epoch 47 average loss: 0.03530547395348549\n", + "# Epoch 48: 100%|██████████| 13/13 [00:00<00:00, 21.38it/s, Loss: 0.0298]\n", + "2020-10-05 12:53:36,621: Epoch 48 average loss: 0.039933502387541994\n", + "# Validation: 100%|██████████| 1/1 [00:00<00:00, 4.36it/s]\n", + "2020-10-05 12:53:37,112: -- Median validation metrics:NSE: 0.82898\n", + "# Epoch 49: 100%|██████████| 13/13 [00:00<00:00, 21.71it/s, Loss: 0.0408]\n", + "2020-10-05 12:53:37,714: Epoch 49 average loss: 0.04054238203053291\n", + "# Epoch 50: 100%|██████████| 13/13 [00:00<00:00, 21.83it/s, Loss: 0.0302]\n", + "2020-10-05 12:53:38,315: Epoch 50 average loss: 0.04161823354661465\n" + ] + } + ], + "source": [ + "start_run(config_file=Path(\"1_basin.yml\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Evaluate run on test set\n", + "The run directory that needs to be specified for evaluation is printed in the output log above." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2020-10-05 12:54:54,703: Using the model weights from /home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252/model_epoch050.pt\n", + "# Evaluation: 100%|██████████| 1/1 [00:00<00:00, 1.79it/s]\n", + "2020-10-05 12:54:55,269: Stored results at /home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252/test/model_epoch050/test_results.p\n" + ] + } + ], + "source": [ + "run_dir = Path(\"/home/frederik/Projects/neuralhydrology/examples/01-Introduction/runs/test_run_0510_125252\")\n", + "eval_run(run_dir=run_dir, period=\"test\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Load and inspect model predictions\n", + "Next, we load the results file and compare the model predictions with observations. The results file is always a pickled dictionary with one key per basin (even for a single basin). The next-lower dictionary level is the temporal resolution of the predictions. In this case, we trained a model only on daily data ('1D'). Within the temporal resolution, the next-lower dictionary level are `xr`(an xarray Dataset that contains observations and predictions), as well as one key for each metric that was specified in the config file." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "dict_keys(['01022500'])" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "with open(run_dir / \"test\" / \"model_epoch050\" / \"test_results.p\", \"rb\") as fp:\n", + " results = pickle.load(fp)\n", + " \n", + "results.keys()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The data variables in the xarray Dataset are named according to the name of the target variables, with suffix `_obs` for the observations and suffix `_sim` for the simulations." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset>\n",
+       "Dimensions:         (date: 3652, time_step: 1)\n",
+       "Coordinates:\n",
+       "  * date            (date) datetime64[ns] 1989-10-01 1989-10-02 ... 1999-09-30\n",
+       "  * time_step       (time_step) timedelta64[ns] 00:00:00\n",
+       "Data variables:\n",
+       "    QObs(mm/d)_obs  (date, time_step) float32 0.6203073 0.5536971 ... 0.9991529\n",
+       "    QObs(mm/d)_sim  (date, time_step) float32 0.6279986 0.6001396 ... 1.966821
" + ], + "text/plain": [ + "\n", + "Dimensions: (date: 3652, time_step: 1)\n", + "Coordinates:\n", + " * date (date) datetime64[ns] 1989-10-01 1989-10-02 ... 1999-09-30\n", + " * time_step (time_step) timedelta64[ns] 00:00:00\n", + "Data variables:\n", + " QObs(mm/d)_obs (date, time_step) float32 0.6203073 0.5536971 ... 0.9991529\n", + " QObs(mm/d)_sim (date, time_step) float32 0.6279986 0.6001396 ... 1.966821" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results['01022500']['1D']['xr']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's plot the model predictions vs. the observations" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Text(0.5, 1.0, 'Test period - NSE 0.791')" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# extract observations and simulations\n", + "qobs = results['01022500']['1D']['xr']['QObs(mm/d)_obs']\n", + "qsim = results['01022500']['1D']['xr']['QObs(mm/d)_sim']\n", + "\n", + "fig, ax = plt.subplots(figsize=(16,10))\n", + "ax.plot(qobs['date'], qobs)\n", + "ax.plot(qsim['date'], qsim)\n", + "ax.set_ylabel(\"Discharge (mm/d)\")\n", + "ax.set_title(f\"Test period - NSE {results['01022500']['1D']['NSE']:.3f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we are going to compute all metrics that are implemented in the neuralHydrology package. You will find additional hydrological signatures implemented in `neuralhydrology.evaluation.signatures`." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NSE: 0.791\n", + "MSE: 1.028\n", + "RMSE: 1.014\n", + "KGE: 0.864\n", + "Alpha-NSE: 0.929\n", + "Beta-NSE: 0.036\n", + "Pearson-r: 0.891\n", + "FHV: -8.793\n", + "FMS: -5.994\n", + "FLV: -876.161\n", + "Peak-Timing: 0.087\n" + ] + } + ], + "source": [ + "values = metrics.calculate_all_metrics(qobs.isel(time_step=-1), qsim.isel(time_step=-1))\n", + "for key, val in values.items():\n", + " print(f\"{key}: {val:.3f}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "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.7.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From e39330e5d2d2f950bf8cbf924d32785b8d83e7ee Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Mon, 5 Oct 2020 14:21:56 +0200 Subject: [PATCH 05/15] increment version number --- neuralhydrology/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neuralhydrology/__about__.py b/neuralhydrology/__about__.py index ea68199a..3f013dc7 100644 --- a/neuralhydrology/__about__.py +++ b/neuralhydrology/__about__.py @@ -1 +1 @@ -__version__ = "0.9.0-beta" +__version__ = "0.9.1-beta" From 664b4e7d6cfa977a8c157a4a143a7b5e4fdb53ce Mon Sep 17 00:00:00 2001 From: Martin Gauch Date: Mon, 5 Oct 2020 21:55:55 +0200 Subject: [PATCH 06/15] Addresses #6: Faster merging of date and time_step during evaluation --- neuralhydrology/evaluation/tester.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/neuralhydrology/evaluation/tester.py b/neuralhydrology/evaluation/tester.py index e001bc71..c6528192 100644 --- a/neuralhydrology/evaluation/tester.py +++ b/neuralhydrology/evaluation/tester.py @@ -258,12 +258,12 @@ def evaluate(self, # stack dates and time_steps so we don't just evaluate every 24H when use_frequencies=[1D, 1H] obs = xr.isel(time_step=slice(-frequency_factor, None)) \ .stack(datetime=['date', 'time_step'])[f"{target_variable}_obs"] - obs['datetime'] = [c[0] + c[1] for c in obs.coords['datetime'].values] + obs['datetime'] = obs.coords['date'] + obs.coords['time_step'] # check if not empty (in case no observations exist in this period if not all(obs.isnull()): sim = xr.isel(time_step=slice(-frequency_factor, None)) \ .stack(datetime=['date', 'time_step'])[f"{target_variable}_sim"] - sim['datetime'] = [c[0] + c[1] for c in sim.coords['datetime'].values] + sim['datetime'] = sim.coords['date'] + sim.coords['time_step'] # clip negative predictions to zero, if variable is listed in config 'clip_target_to_zero' if target_variable in self.cfg.clip_targets_to_zero: From 30021d602449046db6915c51ddda4031dd474be6 Mon Sep 17 00:00:00 2001 From: Martin Gauch Date: Tue, 6 Oct 2020 09:33:18 +0200 Subject: [PATCH 07/15] Rename multifreqlstm to MTS-LSTM --- ...neuralhydrology.modelzoo.multifreqlstm.rst | 6 +- docs/source/api/neuralhydrology.modelzoo.rst | 2 +- docs/source/usage/models.rst | 14 ++--- examples/01-Introduction/1_basin.yml | 2 +- examples/config.yml.example | 8 +-- neuralhydrology/modelzoo/__init__.py | 6 +- .../modelzoo/{multifreqlstm.py => mtslstm.py} | 63 ++++++++++++------- neuralhydrology/modelzoo/odelstm.py | 2 +- neuralhydrology/utils/config.py | 12 ++-- test/conftest.py | 18 +++--- test/test_config_runs.py | 26 ++++---- ...ml => multi_timescale_regression.test.yml} | 2 +- 12 files changed, 89 insertions(+), 72 deletions(-) rename neuralhydrology/modelzoo/{multifreqlstm.py => mtslstm.py} (73%) rename test/test_configs/{multifreq_regression.test.yml => multi_timescale_regression.test.yml} (98%) diff --git a/docs/source/api/neuralhydrology.modelzoo.multifreqlstm.rst b/docs/source/api/neuralhydrology.modelzoo.multifreqlstm.rst index 40909071..ff849478 100644 --- a/docs/source/api/neuralhydrology.modelzoo.multifreqlstm.rst +++ b/docs/source/api/neuralhydrology.modelzoo.multifreqlstm.rst @@ -1,7 +1,7 @@ -MultiFreqLSTM -============= +MTSLSTM +======= -.. automodule:: neuralhydrology.modelzoo.multifreqlstm +.. automodule:: neuralhydrology.modelzoo.mtslstm :members: :undoc-members: :show-inheritance: diff --git a/docs/source/api/neuralhydrology.modelzoo.rst b/docs/source/api/neuralhydrology.modelzoo.rst index 66e50c77..77cee575 100644 --- a/docs/source/api/neuralhydrology.modelzoo.rst +++ b/docs/source/api/neuralhydrology.modelzoo.rst @@ -16,6 +16,6 @@ nh.modelzoo neuralhydrology.modelzoo.fc neuralhydrology.modelzoo.head neuralhydrology.modelzoo.lstm - neuralhydrology.modelzoo.multifreqlstm + neuralhydrology.modelzoo.mtslstm neuralhydrology.modelzoo.odelstm neuralhydrology.modelzoo.template diff --git a/docs/source/usage/models.rst b/docs/source/usage/models.rst index e1095ad4..6c137352 100644 --- a/docs/source/usage/models.rst +++ b/docs/source/usage/models.rst @@ -39,14 +39,14 @@ activations for all time steps. This class is implemented for exploratory reason ``model.copy_weights()`` to copy the weights of a ``CudaLSTM`` model into an ``LSTM`` model. This allows to use the fast CUDA implementation for training, and only use this class for inference with more detailed outputs. -MultiFreqLSTM -------------- -:py:class:`neuralhydrology.modelzoo.multifreqlstm.MultiFreqLSTM` is a newly proposed model by Gauch et al. (pre-print -published soon). This model allows the training on more than one temporal frequency (e.g. daily and hourly inputs) and -returns multi-frequency model predictions accordingly. A more detailed tutorial will follow shortly. +MTS-LSTM +-------- +:py:class:`neuralhydrology.modelzoo.mtslstm.MTSLSTM` is a newly proposed model by Gauch et al. (pre-print +published soon). This model allows the training on more than temporal resolution (e.g. daily and hourly inputs) and +returns multi-timescale model predictions accordingly. A more detailed tutorial will follow shortly. -ODELSTM -------- +ODE-LSTM +-------- :py:class:`neuralhydrology.modelzoo.odelstm.ODELSTM` is a PyTorch implementation of the ODE-LSTM proposed by `Lechner and Hasani `_. This model can be used with unevenly sampled inputs and can be queried to return predictions for any arbitrary time step. diff --git a/examples/01-Introduction/1_basin.yml b/examples/01-Introduction/1_basin.yml index 0b5e3e28..d7119c1b 100644 --- a/examples/01-Introduction/1_basin.yml +++ b/examples/01-Introduction/1_basin.yml @@ -35,7 +35,7 @@ metrics: # --- Model configuration -------------------------------------------------------------------------- -# base model type [lstm, ealstm, cudalstm, embcudalstm, multifreqlstm] +# base model type [lstm, ealstm, cudalstm, embcudalstm, mtslstm] # (has to match the if statement in modelzoo/__init__.py) model: cudalstm diff --git a/examples/config.yml.example b/examples/config.yml.example index b7b3e770..0d4b5533 100644 --- a/examples/config.yml.example +++ b/examples/config.yml.example @@ -52,7 +52,7 @@ metrics: # --- Model configuration -------------------------------------------------------------------------- -# base model type [lstm, ealstm, cudalstm, embcudalstm, multifreqlstm] +# base model type [lstm, ealstm, cudalstm, embcudalstm, mtslstm] # (has to match the if statement in modelzoo/__init__.py) model: cudalstm @@ -79,13 +79,13 @@ embedding_activation: tanh # dropout applied to embedding network embedding_dropout: 0.0 -# ----> MultiFreqLSTM settings <---- +# ----> MTSLSTM settings <---- # Use an individual LSTM per frequencies (True) vs. use a single shared LSTM for all frequencies (False) -per_frequency_lstm: True +shared_mtslstm: True # how to transfer states from lower to higher frequencies. One of [identity, linear, None]. -transfer_multifreq_states: +transfer_mtslstm_states: h: identity c: identity diff --git a/neuralhydrology/modelzoo/__init__.py b/neuralhydrology/modelzoo/__init__.py index 0a30816e..d2b89a5a 100644 --- a/neuralhydrology/modelzoo/__init__.py +++ b/neuralhydrology/modelzoo/__init__.py @@ -5,7 +5,7 @@ from neuralhydrology.modelzoo.embcudalstm import EmbCudaLSTM from neuralhydrology.modelzoo.lstm import LSTM from neuralhydrology.modelzoo.odelstm import ODELSTM -from neuralhydrology.modelzoo.multifreqlstm import MultiFreqLSTM +from neuralhydrology.modelzoo.mtslstm import MTSLSTM from neuralhydrology.utils.config import Config SINGLE_FREQ_MODELS = ["cudalstm", "ealstm", "lstm", "embcudalstm"] @@ -35,8 +35,8 @@ def get_model(cfg: Config) -> nn.Module: model = LSTM(cfg=cfg) elif cfg.model == "embcudalstm": model = EmbCudaLSTM(cfg=cfg) - elif cfg.model == "multifreqlstm": - model = MultiFreqLSTM(cfg=cfg) + elif cfg.model == "mtslstm": + model = MTSLSTM(cfg=cfg) elif cfg.model == "odelstm": model = ODELSTM(cfg=cfg) else: diff --git a/neuralhydrology/modelzoo/multifreqlstm.py b/neuralhydrology/modelzoo/mtslstm.py similarity index 73% rename from neuralhydrology/modelzoo/multifreqlstm.py rename to neuralhydrology/modelzoo/mtslstm.py index fba41871..305dd3b2 100644 --- a/neuralhydrology/modelzoo/multifreqlstm.py +++ b/neuralhydrology/modelzoo/mtslstm.py @@ -13,10 +13,29 @@ LOGGER = logging.getLogger(__name__) -class MultiFreqLSTM(BaseModel): +class MTSLSTM(BaseModel): + """Multi-Timescale LSTM (MTS-LSTM) from Gauch et al. (preprint to be released soon). + + An LSTM architecture that allows simultaneous prediction at multiple timescales within one model. + There are two flavors of this model: MTS-LTSM and sMTS-LSTM (shared MTS-LSTM). The MTS-LSTM processes inputs at + low temporal resolutions up to a point in time. Then, the LSTM splits into one branch for each target timescale. + Each branch processes the inputs at its respective timescale. Finally, one prediction head per timescale generates + the predictions for that timescale based on the LSTM output. + Optionally, one can specify: + - a different hidden size for each LSTM branch (use a dict in the ``hidden_size`` config argument) + - different dynamic input variables for each timescale (use a dict in the ``dynamic_inputs`` config argument) + - the strategy to transfer states from the initial shared low-resolution LSTM to the per-timescale + higher-resolution LSTMs. By default, this is a linear transfer layer, but you can specify 'identity' to use an + identity operation or 'None' to turn off any transfer (via the ``transfer_mtlstm_states`` config argument). + + + The sMTS-LSTM variant has the same overall architecture, but the weights of the per-timescale branches (including + the output heads) are shared. + Thus, unlike MTS-LSTM, the sMTS-LSTM cannot use per-timescale hidden sizes or dynamic input variables. + """ def __init__(self, cfg: Config): - super(MultiFreqLSTM, self).__init__(cfg=cfg) + super(MTSLSTM, self).__init__(cfg=cfg) self.lstms = None self.transfer_fcs = None self.heads = None @@ -26,22 +45,22 @@ def __init__(self, cfg: Config): self._frequency_factors = [] self._seq_lengths = cfg.seq_length - self._per_frequency_lstm = self.cfg.per_frequency_lstm # default: a distinct LSTM per frequency - self._transfer_multifreq_states = self.cfg.transfer_multifreq_states # default: linear transfer layer + self._is_shared_mtslstm = self.cfg.shared_mtslstm # default: a distinct LSTM per timescale + self._transfer_mtslstm_states = self.cfg.transfer_mtslstm_states # default: linear transfer layer transfer_modes = [None, "None", "identity", "linear"] - if self._transfer_multifreq_states["h"] not in transfer_modes \ - or self._transfer_multifreq_states["c"] not in transfer_modes: - raise ValueError(f"MultiFreqLSTM supports state transfer modes {transfer_modes}") + if self._transfer_mtslstm_states["h"] not in transfer_modes \ + or self._transfer_mtslstm_states["c"] not in transfer_modes: + raise ValueError(f"MTS-LSTM supports state transfer modes {transfer_modes}") if len(cfg.use_frequencies) < 2: - raise ValueError("MultiFreqLSTM expects more than one input frequency") + raise ValueError("MTS-LSTM expects more than one input frequency") self._frequencies = sort_frequencies(cfg.use_frequencies) # start to count the number of inputs input_sizes = len(cfg.camels_attributes + cfg.hydroatlas_attributes + cfg.static_inputs) - # if not per_frequency_lstm, the LSTM gets an additional frequency flag as input. - if not self._per_frequency_lstm: + # if not is_shared_mtslstm, the LSTM gets an additional frequency flag as input. + if not self._is_shared_mtslstm: input_sizes += len(self._frequencies) if cfg.use_basin_id_encoding: @@ -52,8 +71,8 @@ def __init__(self, cfg: Config): if isinstance(cfg.dynamic_inputs, list): input_sizes = {freq: input_sizes + len(cfg.dynamic_inputs) for freq in self._frequencies} else: - if not self._per_frequency_lstm: - raise ValueError(f'Different inputs not allowed if per_frequency_lstm is False.') + if not self._is_shared_mtslstm: + raise ValueError(f'Different inputs not allowed if shared_mtslstm is False.') input_sizes = {freq: input_sizes + len(cfg.dynamic_inputs[freq]) for freq in self._frequencies} if not isinstance(cfg.hidden_size, dict): @@ -62,11 +81,11 @@ def __init__(self, cfg: Config): else: self._hidden_size = cfg.hidden_size - if (not self._per_frequency_lstm - or self._transfer_multifreq_states["h"] == "identity" - or self._transfer_multifreq_states["c"] == "identity") \ + if (not self._is_shared_mtslstm + or self._transfer_mtslstm_states["h"] == "identity" + or self._transfer_mtslstm_states["c"] == "identity") \ and any(size != self._hidden_size[self._frequencies[0]] for size in self._hidden_size.values()): - raise ValueError("All hidden sizes must be equal if per_frequency_lstm=False or state transfer=identity.") + raise ValueError("All hidden sizes must be equal if shared_mtslstm=False or state transfer=identity.") # create layer depending on selected frequencies self._init_modules(input_sizes) @@ -83,7 +102,7 @@ def _init_modules(self, input_sizes: Dict[str, int]): for idx, freq in enumerate(self._frequencies): freq_input_size = input_sizes[freq] - if not self._per_frequency_lstm and idx > 0: + if not self._is_shared_mtslstm and idx > 0: self.lstms[freq] = self.lstms[self._frequencies[idx - 1]] # same LSTM for all frequencies. self.heads[freq] = self.heads[self._frequencies[idx - 1]] # same head for all frequencies. else: @@ -92,10 +111,10 @@ def _init_modules(self, input_sizes: Dict[str, int]): if idx < len(self._frequencies) - 1: for state in ["c", "h"]: - if self._transfer_multifreq_states[state] == "linear": + if self._transfer_mtslstm_states[state] == "linear": self.transfer_fcs[f"{state}_{freq}"] = nn.Linear(self._hidden_size[freq], self._hidden_size[self._frequencies[idx + 1]]) - elif self._transfer_multifreq_states[state] == "identity": + elif self._transfer_mtslstm_states[state] == "identity": self.transfer_fcs[f"{state}_{freq}"] = nn.Identity() else: pass @@ -138,7 +157,7 @@ def _prepare_inputs(self, data: Dict[str, torch.Tensor], freq: str) -> torch.Ten else: pass - if not self._per_frequency_lstm: + if not self._is_shared_mtslstm: # add frequency one-hot encoding idx = self._frequencies.index(freq) one_hot_freq = torch.zeros(x_d.shape[0], x_d.shape[1], len(self._frequencies)).to(x_d) @@ -165,9 +184,9 @@ def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: (h_0_transfer, c_0_transfer)) # project the states through a hidden layer to the dimensions of the next LSTM - if self._transfer_multifreq_states["h"] is not None: + if self._transfer_mtslstm_states["h"] is not None: h_0_transfer = self.transfer_fcs[f"h_{freq}"](h_n_slice1) - if self._transfer_multifreq_states["c"] is not None: + if self._transfer_mtslstm_states["c"] is not None: c_0_transfer = self.transfer_fcs[f"c_{freq}"](c_n_slice1) # get predictions of remaining part and concat results diff --git a/neuralhydrology/modelzoo/odelstm.py b/neuralhydrology/modelzoo/odelstm.py index afa31999..f053eaec 100644 --- a/neuralhydrology/modelzoo/odelstm.py +++ b/neuralhydrology/modelzoo/odelstm.py @@ -57,7 +57,7 @@ def __init__(self, cfg: Config): raise ValueError('ODELSTM does not support per-frequency input variables or hidden sizes.') # Note: be aware that frequency_factors and slice_timesteps have a slightly different meaning here vs. in - # multifreqlstm. Here, the frequency_factor is relative to the _lowest_ (not the next-lower) frequency. + # MTSLSTM. Here, the frequency_factor is relative to the _lowest_ (not the next-lower) frequency. # slice_timesteps[freq] is the input step (counting backwards) in the next-*lower* frequency from where on input # data at frequency freq is available. self._frequency_factors = {} diff --git a/neuralhydrology/utils/config.py b/neuralhydrology/utils/config.py index 85abd525..98392af1 100644 --- a/neuralhydrology/utils/config.py +++ b/neuralhydrology/utils/config.py @@ -419,10 +419,6 @@ def per_basin_train_periods_file(self) -> Path: def per_basin_validation_periods_file(self) -> Path: return self._cfg.get("per_basin_validation_periods_file", None) - @property - def per_frequency_lstm(self) -> bool: - return self._cfg.get("per_frequency_lstm", True) - @property def predict_last_n(self) -> Union[int, Dict[str, int]]: return self._get_value_verbose("predict_last_n") @@ -470,6 +466,10 @@ def seed(self, seed: int): def seq_length(self) -> Union[int, Dict[str, int]]: return self._get_value_verbose("seq_length") + @property + def shared_mtslstm(self) -> bool: + return self._cfg.get("shared_mtslstm", True) + @property def static_inputs(self) -> List[str]: return self._as_default_list(self._cfg.get("static_inputs", [])) @@ -540,8 +540,8 @@ def train_start_date(self) -> pd.Timestamp: return self._get_value_verbose("train_start_date") @property - def transfer_multifreq_states(self) -> Dict[str, str]: - return self._cfg.get("transfer_multifreq_states", {'h': 'linear', 'c': 'linear'}) + def transfer_mtslstm_states(self) -> Dict[str, str]: + return self._cfg.get("transfer_mtslstm_states", {'h': 'linear', 'c': 'linear'}) @property def umal_extend_batch(self) -> bool: diff --git a/test/conftest.py b/test/conftest.py index feb18906..f204198f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -11,7 +11,7 @@ def pytest_addoption(parser): parser.addoption('--smoke-test', action='store_true', default=False, - help='Skips some tests for faster execution. Out of the single-frequency ' + help='Skips some tests for faster execution. Out of the single-timescale ' 'models and forcings, only test cudalstm on forcings that include daymet.') @@ -44,13 +44,13 @@ def _get_config(name): @pytest.fixture(params=['lstm', 'ealstm', 'cudalstm', 'embcudalstm']) -def single_freq_model(request) -> str: - """Fixture that provides models that support predicting only a single frequency. +def single_timescale_model(request) -> str: + """Fixture that provides models that support predicting only a single timescale. Returns ------- str - Name of the single-frequency model. + Name of the single-timescale model. """ if request.config.getoption('--smoke-test') and request.param != 'cudalstm': pytest.skip('--smoke-test skips this test.') @@ -62,7 +62,7 @@ def single_freq_model(request) -> str: (['daymet', 'nldas'], ['prcp(mm/day)_daymet', 'tmax(C)_daymet', 'PRCP(mm/day)_nldas', 'Tmax(C)_nldas'])], ids=lambda param: str(param[0])) -def single_freq_forcings(request) -> Dict[str, Union[str, List[str]]]: +def single_timescale_forcings(request) -> Dict[str, Union[str, List[str]]]: """Fixture that provides daily forcings. Returns @@ -75,14 +75,14 @@ def single_freq_forcings(request) -> Dict[str, Union[str, List[str]]]: return {'forcings': request.param[0], 'variables': request.param[1]} -@pytest.fixture(params=['multifreqlstm', 'odelstm']) -def multi_freq_model(request) -> str: - """Fixture that provides multifrequency models. +@pytest.fixture(params=['mtslstm', 'odelstm']) +def multi_timescale_model(request) -> str: + """Fixture that provides multi-timescale models. Returns ------- str - Name of the multifrequency model. + Name of the multi-timescale model. """ return request.param diff --git a/test/test_config_runs.py b/test/test_config_runs.py index d2f9b600..6eb760fb 100644 --- a/test/test_config_runs.py +++ b/test/test_config_runs.py @@ -15,28 +15,28 @@ from test import Fixture -def test_daily_regression(get_config: Fixture[Callable[[str], dict]], single_freq_model: Fixture[str], - daily_dataset: Fixture[str], single_freq_forcings: Fixture[str]): +def test_daily_regression(get_config: Fixture[Callable[[str], dict]], single_timescale_model: Fixture[str], + daily_dataset: Fixture[str], single_timescale_forcings: Fixture[str]): """Test regression training and evaluation for daily predictions. Parameters ---------- get_config : Fixture[Callable[[str], dict] Method that returns a run configuration to test. - single_freq_model : Fixture[str] + single_timescale_model : Fixture[str] Model to test. daily_dataset : Fixture[str] Daily dataset to use. - single_freq_forcings : Fixture[str] + single_timescale_forcings : Fixture[str] Daily forcings set to use. """ config = get_config('daily_regression') - config.log_only('model', single_freq_model) + config.log_only('model', single_timescale_model) config.log_only('dataset', daily_dataset['dataset']) config.log_only('data_dir', config.data_dir / daily_dataset['dataset']) config.log_only('target_variables', daily_dataset['target']) - config.log_only('forcings', single_freq_forcings['forcings']) - config.log_only('dynamic_inputs', single_freq_forcings['variables']) + config.log_only('forcings', single_timescale_forcings['forcings']) + config.log_only('dynamic_inputs', single_timescale_forcings['variables']) basin = '01022500' test_start_date, test_end_date = _get_test_start_end_dates(config) @@ -88,20 +88,18 @@ def test_daily_regression_additional_features(get_config: Fixture[Callable[[str] assert not pd.isna(results[f'{config.target_variables[0]}_sim']).any() -def test_multifreq_regression(get_config: Fixture[Callable[[str], dict]], multi_freq_model: Fixture[str]): - """Test regression training and evaluation for multifrequency predictions. +def test_multi_timescale_regression(get_config: Fixture[Callable[[str], dict]], multi_timescale_model: Fixture[str]): + """Test regression training and evaluation for multi-timescale predictions. Parameters ---------- get_config : Fixture[Callable[[str], dict] Method that returns a run configuration to test. - multi_freq_model : Fixture[str] + multi_timescale_model : Fixture[str] Model to test. - multi_freq_forcings : Fixture[str] - Forcings set to use. """ - config = get_config('multifreq_regression') - config.log_only('model', multi_freq_model) + config = get_config('multi_timescale_regression') + config.log_only('model', multi_timescale_model) basin = '01022500' test_start_date, test_end_date = _get_test_start_end_dates(config) diff --git a/test/test_configs/multifreq_regression.test.yml b/test/test_configs/multi_timescale_regression.test.yml similarity index 98% rename from test/test_configs/multifreq_regression.test.yml rename to test/test_configs/multi_timescale_regression.test.yml index 69aaf8d0..c6fcca57 100644 --- a/test/test_configs/multifreq_regression.test.yml +++ b/test/test_configs/multi_timescale_regression.test.yml @@ -28,7 +28,7 @@ metrics: - Beta-NSE # --- Model configuration -------------------------------------------------------------------------- -model: multifreqlstm +model: mtslstm head: regression output_activation: linear From bbb3cf7000dc03217cadddca01e0cb90870f1cad Mon Sep 17 00:00:00 2001 From: Martin Gauch Date: Tue, 6 Oct 2020 10:16:11 +0200 Subject: [PATCH 08/15] Add docstrings --- neuralhydrology/modelzoo/mtslstm.py | 21 ++++++- neuralhydrology/modelzoo/odelstm.py | 89 ++++++++++++++++++++++------- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/neuralhydrology/modelzoo/mtslstm.py b/neuralhydrology/modelzoo/mtslstm.py index 305dd3b2..f16f3566 100644 --- a/neuralhydrology/modelzoo/mtslstm.py +++ b/neuralhydrology/modelzoo/mtslstm.py @@ -32,6 +32,11 @@ class MTSLSTM(BaseModel): The sMTS-LSTM variant has the same overall architecture, but the weights of the per-timescale branches (including the output heads) are shared. Thus, unlike MTS-LSTM, the sMTS-LSTM cannot use per-timescale hidden sizes or dynamic input variables. + + Parameters + ---------- + cfg : Config + The run configuration. """ def __init__(self, cfg: Config): @@ -89,7 +94,7 @@ def __init__(self, cfg: Config): # create layer depending on selected frequencies self._init_modules(input_sizes) - self.reset_parameters() + self._reset_parameters() # frequency factors are needed to determine the time step of information transfer self._init_frequency_factors_and_slice_timesteps() @@ -131,7 +136,7 @@ def _init_frequency_factors_and_slice_timesteps(self): slice_timestep = int(self._seq_lengths[self._frequencies[idx + 1]] / self._frequency_factors[idx]) self._slice_timestep[freq] = slice_timestep - def reset_parameters(self): + def _reset_parameters(self): if self.cfg.initial_forget_bias is not None: for freq in self._frequencies: hidden_size = self._hidden_size[freq] @@ -167,6 +172,18 @@ def _prepare_inputs(self, data: Dict[str, torch.Tensor], freq: str) -> torch.Ten return x_d def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """Perform a forward pass on the MTS-LSTM model. + + Parameters + ---------- + data : Dict[str, torch.Tensor] + Input data for the forward pass. See the documentation overview of all models for details on the dict keys. + + Returns + ------- + Dict[str, torch.Tensor] + Model predictions for each target timescale. + """ x_d = {freq: self._prepare_inputs(data, freq) for freq in self._frequencies} # initial states for lowest frequencies are set to zeros diff --git a/neuralhydrology/modelzoo/odelstm.py b/neuralhydrology/modelzoo/odelstm.py index f053eaec..b8853670 100644 --- a/neuralhydrology/modelzoo/odelstm.py +++ b/neuralhydrology/modelzoo/odelstm.py @@ -42,6 +42,11 @@ class ODELSTM(BaseModel): 5. lowest-frequency steps to generate predict_last_n lowest-frequency predictions. 6. repeat steps four and five for the next-higher frequency (using the same random-frequency bounds but generating predictions for the next-higher frequency). + + Parameters + ---------- + cfg : Config + The run configuration. References ---------- @@ -171,6 +176,18 @@ def _randomize_freq(self, x_d: torch.Tensor, low_frequency: str, high_frequency: return torch.cat(x_d_randomized, dim=0) def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """Perform a forward pass on the ODE-LSTM model. + + Parameters + ---------- + data : Dict[str, torch.Tensor] + Input data for the forward pass. See the documentation overview of all models for details on the dict keys. + + Returns + ------- + Dict[str, torch.Tensor] + Model predictions for each target timescale. + """ x_d = {freq: self._prepare_inputs(data, freq) for freq in self._frequencies} @@ -245,19 +262,35 @@ def _run_odelstm(self, input_slice: torch.Tensor, h_0: torch.Tensor, class _ODERNNCell(nn.Module): - """An ODE-RNN cell (Adapted from https://github.com/mlech26l/learning-long-term-irregular-ts). """ + """An ODE-RNN cell (Adapted from https://github.com/mlech26l/learning-long-term-irregular-ts) [#]_. + + Parameters + ---------- + input_size : int + Input dimension + hidden_size : int + Size of the cell's hidden state + num_unfolds : int + Number of steps into which each timestep will be broken down to solve the ODE. + method : {'euler', 'heun', 'rk4'} + Method to use for ODE solving (Euler's method, Heun's method, or Runge-Kutta 4) + + References + ---------- + .. [#] Lechner, M.; Hasani, R.: Learning Long-Term Dependencies in Irregularly-Sampled Time Series. arXiv, 2020, + https://arxiv.org/abs/2006.04418. + """ - def __init__(self, input_size: int, hidden_size: int, num_unfolds: int, method: str, tau=1): + def __init__(self, input_size: int, hidden_size: int, num_unfolds: int, method: str): super(_ODERNNCell, self).__init__() self.method = { - 'euler': self.euler, - 'heun': self.heun, - 'rk4': self.rk4, + 'euler': self._euler, + 'heun': self._heun, + 'rk4': self._rk4, }[method] self.input_size = input_size self.hidden_size = hidden_size self.num_unfolds = num_unfolds - self.tau = tau self.w_ih = nn.Parameter(torch.FloatTensor(hidden_size, input_size)) self.w_hh = nn.Parameter(torch.FloatTensor(hidden_size, hidden_size)) @@ -267,12 +300,29 @@ def __init__(self, input_size: int, hidden_size: int, num_unfolds: int, method: self.reset_parameters() def reset_parameters(self): + """Reset the paramters of the ODERNNCell. """ nn.init.orthogonal_(self.w_hh) nn.init.xavier_uniform_(self.w_ih) nn.init.zeros_(self.bias) nn.init.constant_(self.scale, 1.0) def forward(self, new_hidden_state: torch.Tensor, old_hidden_state: torch.Tensor, elapsed: float) -> torch.Tensor: + """Perform a forward pass on the ODERNNCell. + + Parameters + ---------- + new_hidden_state : torch.Tensor + The current hidden state to be updated by the ODERNNCell. + old_hidden_state : torch.Tensor + The previous hidden state. + elapsed : float + Time elapsed between new and old hidden state. + + Returns + ------- + torch.Tensor + Predicted new hidden state + """ delta_t = elapsed / self.num_unfolds hidden_state = old_hidden_state @@ -280,29 +330,26 @@ def forward(self, new_hidden_state: torch.Tensor, old_hidden_state: torch.Tensor hidden_state = self.method(new_hidden_state, hidden_state, delta_t) return hidden_state - def dfdt(self, inputs: torch.Tensor, hidden_state: torch.Tensor) -> torch.Tensor: + def _dfdt(self, inputs: torch.Tensor, hidden_state: torch.Tensor) -> torch.Tensor: h_in = torch.matmul(inputs, self.w_ih) h_rec = torch.matmul(hidden_state, self.w_hh) dh_in = self.scale * torch.tanh(h_in + h_rec + self.bias) - if self.tau > 0: - dh = dh_in - hidden_state * self.tau - else: - dh = dh_in + dh = dh_in - hidden_state return dh - def euler(self, inputs: torch.Tensor, hidden_state: torch.Tensor, delta_t: float) -> torch.Tensor: - dy = self.dfdt(inputs, hidden_state) + def _euler(self, inputs: torch.Tensor, hidden_state: torch.Tensor, delta_t: float) -> torch.Tensor: + dy = self._dfdt(inputs, hidden_state) return hidden_state + delta_t * dy - def heun(self, inputs: torch.Tensor, hidden_state: torch.Tensor, delta_t: float) -> torch.Tensor: - k1 = self.dfdt(inputs, hidden_state) - k2 = self.dfdt(inputs, hidden_state + delta_t * k1) + def _heun(self, inputs: torch.Tensor, hidden_state: torch.Tensor, delta_t: float) -> torch.Tensor: + k1 = self._dfdt(inputs, hidden_state) + k2 = self._dfdt(inputs, hidden_state + delta_t * k1) return hidden_state + delta_t * 0.5 * (k1 + k2) - def rk4(self, inputs: torch.Tensor, hidden_state: torch.Tensor, delta_t: float) -> torch.Tensor: - k1 = self.dfdt(inputs, hidden_state) - k2 = self.dfdt(inputs, hidden_state + k1 * delta_t * 0.5) - k3 = self.dfdt(inputs, hidden_state + k2 * delta_t * 0.5) - k4 = self.dfdt(inputs, hidden_state + k3 * delta_t) + def _rk4(self, inputs: torch.Tensor, hidden_state: torch.Tensor, delta_t: float) -> torch.Tensor: + k1 = self._dfdt(inputs, hidden_state) + k2 = self._dfdt(inputs, hidden_state + k1 * delta_t * 0.5) + k3 = self._dfdt(inputs, hidden_state + k2 * delta_t * 0.5) + k4 = self._dfdt(inputs, hidden_state + k3 * delta_t) return hidden_state + delta_t * (k1 + 2 * k2 + 2 * k3 + k4) / 6.0 From 4ee0ae88e2c9a701d26b2b60f7e912ab38388eaf Mon Sep 17 00:00:00 2001 From: Martin Gauch Date: Tue, 6 Oct 2020 10:24:10 +0200 Subject: [PATCH 09/15] Make reset_parameters private --- neuralhydrology/modelzoo/odelstm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/neuralhydrology/modelzoo/odelstm.py b/neuralhydrology/modelzoo/odelstm.py index b8853670..63bd1979 100644 --- a/neuralhydrology/modelzoo/odelstm.py +++ b/neuralhydrology/modelzoo/odelstm.py @@ -297,9 +297,9 @@ def __init__(self, input_size: int, hidden_size: int, num_unfolds: int, method: self.bias = nn.Parameter(torch.FloatTensor(hidden_size)) self.scale = nn.Parameter(torch.FloatTensor(hidden_size)) - self.reset_parameters() + self._reset_parameters() - def reset_parameters(self): + def _reset_parameters(self): """Reset the paramters of the ODERNNCell. """ nn.init.orthogonal_(self.w_hh) nn.init.xavier_uniform_(self.w_ih) From c47562a6d5915ce32c89b651828ffaa9ffc14a48 Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Tue, 6 Oct 2020 13:53:29 +0200 Subject: [PATCH 10/15] Docs and restructuring (#12) * doc update and minor restructuring #5 * Update model docstrings * Apply suggestions from code review Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> * fix sphinx stuff, add space Co-authored-by: Martin Gauch <15731649+gauchm@users.noreply.github.com> --- ...t => neuralhydrology.modelzoo.mtslstm.rst} | 0 neuralhydrology/data/climateindices.py | 92 ++++++++++++------- neuralhydrology/data/dischargeinput.py | 72 +++++++++++---- neuralhydrology/data/pet.py | 90 +++++++++--------- neuralhydrology/modelzoo/basemodel.py | 45 ++++++++- neuralhydrology/modelzoo/cudalstm.py | 38 +++++++- neuralhydrology/modelzoo/ealstm.py | 45 ++++++++- neuralhydrology/modelzoo/embcudalstm.py | 38 +++++++- neuralhydrology/modelzoo/fc.py | 37 +++++++- neuralhydrology/modelzoo/head.py | 31 ++++++- neuralhydrology/modelzoo/lstm.py | 13 +-- neuralhydrology/modelzoo/mtslstm.py | 10 +- 12 files changed, 391 insertions(+), 120 deletions(-) rename docs/source/api/{neuralhydrology.modelzoo.multifreqlstm.rst => neuralhydrology.modelzoo.mtslstm.rst} (100%) diff --git a/docs/source/api/neuralhydrology.modelzoo.multifreqlstm.rst b/docs/source/api/neuralhydrology.modelzoo.mtslstm.rst similarity index 100% rename from docs/source/api/neuralhydrology.modelzoo.multifreqlstm.rst rename to docs/source/api/neuralhydrology.modelzoo.mtslstm.rst diff --git a/neuralhydrology/data/climateindices.py b/neuralhydrology/data/climateindices.py index 6c6b9c32..d564c818 100644 --- a/neuralhydrology/data/climateindices.py +++ b/neuralhydrology/data/climateindices.py @@ -2,36 +2,65 @@ import pickle import sys from pathlib import Path -from typing import List +from typing import List, Dict import numpy as np +import pandas as pd from numba import njit from tqdm import tqdm -from .pet import get_priestley_taylor_pet -from .utils import load_camels_us_attributes, load_camels_us_forcings, load_basin_file +from neuralhydrology.data import pet, utils LOGGER = logging.getLogger(__name__) -def precalculate_dyn_climate_indices(data_dir: Path, basin_file: Path, window_length: int, forcings: str): - basins = load_basin_file(basin_file=basin_file) - camels_attributes = load_camels_us_attributes(data_dir=data_dir, basins=basins) +def calculate_dyn_climate_indices(data_dir: Path, + basins: List[str], + window_length: int, + forcings: str, + output_file: Path = None) -> Dict[str, pd.DataFrame]: + """Calculate dynamic climate indices. + + Compared to the long-term static climate indices included in the CAMELS data set, this function computes the same + climate indices by a moving window approach over the entire data set. That is, for each time step, the climate + indices are re-computed from the last `window_length` time steps. The resulting dictionary of DataFrames can be + used with the `additional_feature_files` argument. + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. + basins : List[str] + List of basin ids. + window_length : int + Look-back period to use to compute the climate indices. + forcings : str + Can be e.g. 'daymet' or 'nldas', etc. Must match the folder names in the 'basin_mean_forcing' directory. + output_file : Path, optional + If specified, stores the resulting dictionary of DataFrames to this location as a pickle dump. + + Returns + ------- + Dict[str, pd.DataFrame] + Dictionary with one time-indexed DataFrame per basin. By definition, the climate indices for a given day in the + DataFrame are computed from the `window_length` previous time steps (including the given day). + """ + camels_attributes = utils.load_camels_us_attributes(data_dir=data_dir, basins=basins) additional_features = {} new_columns = [ 'p_mean_dyn', 'pet_mean_dyn', 'aridity_dyn', 't_mean_dyn', 'frac_snow_dyn', 'high_prec_freq_dyn', 'high_prec_dur_dyn', 'low_prec_freq_dyn', 'low_prec_dur_dyn' ] for basin in tqdm(basins, file=sys.stdout): - df, _ = load_camels_us_forcings(data_dir=data_dir, basin=basin, forcings=forcings) + df, _ = utils.load_camels_us_forcings(data_dir=data_dir, basin=basin, forcings=forcings) lat = camels_attributes.loc[camels_attributes.index == basin, 'gauge_lat'].values elev = camels_attributes.loc[camels_attributes.index == basin, 'elev_mean'].values - df["PET(mm/d)"] = get_priestley_taylor_pet(t_min=df["tmin(C)"].values, - t_max=df["tmax(C)"].values, - s_rad=df["srad(W/m2)"].values, - lat=lat, - elev=elev, - doy=df.index.dayofyear.values) + df["PET(mm/d)"] = pet.get_priestley_taylor_pet(t_min=df["tmin(C)"].values, + t_max=df["tmax(C)"].values, + s_rad=df["srad(W/m2)"].values, + lat=lat, + elev=elev, + doy=df.index.dayofyear.values) for col in new_columns: df[col] = np.nan @@ -41,7 +70,7 @@ def precalculate_dyn_climate_indices(data_dir: Path, basin_file: Path, window_le df['vp(Pa)'].values, df['PET(mm/d)'].values ]).T - new_features = numba_climate_indexes(x, window_length=window_length) + new_features = _numba_climate_indexes(x, window_length=window_length) if np.sum(np.isnan(new_features)) > 0: raise ValueError(f"NaN in new features of basin {basin}") @@ -62,20 +91,16 @@ def precalculate_dyn_climate_indices(data_dir: Path, basin_file: Path, window_le additional_features[basin] = df - filename = f"dyn_climate_indices_{forcings}_{len(basins)}basins_{window_length}lookback.p" - - output_file = Path(__file__).parent.parent.parent / 'data' / filename - - with output_file.open("wb") as fp: - pickle.dump(additional_features, fp) - - LOGGER.info(f"Precalculated features successfully stored at {output_file}") + if output_file is not None: + with output_file.open("wb") as fp: + pickle.dump(additional_features, fp) + LOGGER.info(f"Precalculated features successfully stored at {output_file}") return additional_features @njit -def numba_climate_indexes(features: np.ndarray, window_length: int) -> np.ndarray: +def _numba_climate_indexes(features: np.ndarray, window_length: int) -> np.ndarray: n_samples = features.shape[0] new_features = np.zeros((n_samples - 365 + 1, 9)) @@ -94,11 +119,11 @@ def numba_climate_indexes(features: np.ndarray, window_length: int) -> np.ndarra low_prec_freq = precip_days[precip_days[:, 0] < 1].shape[0] / precip_days.shape[0] idx = np.where(x[:, 0] < 1)[0] - groups = split_list(idx) + groups = _split_list(idx) low_prec_dur = np.mean(np.array([len(p) for p in groups])) idx = np.where(x[:, 0] >= 5 * p_mean)[0] - groups = split_list(idx) + groups = _split_list(idx) high_prec_dur = np.mean(np.array([len(p) for p in groups])) new_features[i, 0] = p_mean @@ -115,16 +140,15 @@ def numba_climate_indexes(features: np.ndarray, window_length: int) -> np.ndarra @njit -def split_list(alist: List) -> List: - newlist = [] +def _split_list(a_list: List) -> List: + new_list = [] start = 0 - end = 0 - for index, value in enumerate(alist): - if index < len(alist) - 1: - if alist[index + 1] > value + 1: + for index, value in enumerate(a_list): + if index < len(a_list) - 1: + if a_list[index + 1] > value + 1: end = index + 1 - newlist.append(alist[start:end]) + new_list.append(a_list[start:end]) start = end else: - newlist.append(alist[start:len(alist)]) - return newlist + new_list.append(a_list[start:len(a_list)]) + return new_list diff --git a/neuralhydrology/data/dischargeinput.py b/neuralhydrology/data/dischargeinput.py index b198a5fa..f8b97474 100644 --- a/neuralhydrology/data/dischargeinput.py +++ b/neuralhydrology/data/dischargeinput.py @@ -2,36 +2,72 @@ import pickle import sys from pathlib import Path +from typing import Dict, List +import pandas as pd from tqdm import tqdm -from neuralhydrology.data.utils import load_basin_file, load_camels_us_forcings, load_camels_us_discharge +from neuralhydrology.data import utils LOGGER = logging.getLogger(__name__) -def create_discharge_files(data_dir: Path, basin_file: Path, out_dir: Path, shift: int = 1): - - out_file = out_dir / f"discharge_input_shift{shift}.p" - if out_file.is_file(): - raise FileExistsError - - basins = load_basin_file(basin_file) - +def shift_discharge(data_dir: Path, basins: List[str], dataset: str, shift: int = 1, + output_file: Path = None) -> Dict[str, pd.DataFrame]: + """Return shifted discharge data. + + This function returns the shifted discharge data for each basin in `basins`. Useful when training + models with lagged discharge as input. Use the `output_file` argument to store the resulting dictionary as + pickle dump to disk. This pickle file can be used with the config argument `additional_feature_files` to make the + data available as input feature. + For CAMELS GB, the 'discharge_spec' column is used. + + Parameters + ---------- + data_dir : Path + Path to the dataset directory. + basins : List[str] + List of basin ids. + dataset : {'camels_us', 'camels_gb', 'hourly_camels_us'} + Which data set to use. + shift : int, optional + Number of discharge lag in time steps, by default 1. + output_file : Path, optional + If specified, stores the resulting dictionary of DataFrames to this location as a pickle dump. + + Returns + ------- + Dict[str, pd.DataFrame] + Dictionary with one time-indexed DataFrame per basin. The lagged discharge column is named according to the + discharge column name, with '_t-SHIFT' as suffix, where 'SHIFT' corresponds to the argument `shift`. + """ data = {} - for basin in tqdm(basins, file=sys.stdout): - df, area = load_camels_us_forcings(data_dir=data_dir, basin=basin, forcings="daymet") - df["QObs(mm/d)"] = load_camels_us_discharge(data_dir=data_dir, basin=basin, area=area) - - df[f"QObs(t-{shift})"] = df["QObs(mm/d)"].shift(shift) + # load discharge data + if dataset == "camels_us": + df, area = utils.load_camels_us_forcings(data_dir=data_dir, basin=basin, forcings="daymet") + df["QObs(mm/d)"] = utils.load_camels_us_discharge(data_dir=data_dir, basin=basin, area=area) + discharge_col = "QObs(mm/d)" + elif dataset == "camels_gb": + df = utils.load_camels_gb_timeseries(data_dir=data_dir, basin=basin) + discharge_col = "discharge_spec" + elif dataset == "hourly_camels_gb": + df = utils.load_hourly_us_discharge(data_dir=data_dir, basin=basin) + discharge_col = "QObs(mm/h)" - drop_columns = [col for col in df.columns if col != f"QObs(t-{shift})"] + # shift discharge data by `shift` time steps + df[f"{discharge_col}_t-{shift}"] = df[discharge_col].shift(shift) + # remove all columns from data set except shifted discharge + drop_columns = [col for col in df.columns if col != f"{discharge_col}_t-{shift}"] data[basin] = df.drop(labels=drop_columns, axis=1) - with out_file.open("wb") as fp: - pickle.dump(data, fp) + if output_file is not None: + # store pickle dump + with output_file.open("wb") as fp: + pickle.dump(data, fp) + + LOGGER.info(f"Data successfully stored at {output_file}") - LOGGER.info(f"Data successfully stored at {out_file}") + return data diff --git a/neuralhydrology/data/pet.py b/neuralhydrology/data/pet.py index 818b615f..3a721058 100644 --- a/neuralhydrology/data/pet.py +++ b/neuralhydrology/data/pet.py @@ -1,19 +1,14 @@ import numpy as np from numba import njit -LAMBDA = 2.45 # Kept constant, MJkg-1 -ALPHA = 1.26 # Calibrated in CAMELS, here static -STEFAN_BOLTZMAN = 4.903e-09 -PI = np.pi - @njit -def get_priestley_taylor_pet(t_min: np.ndarray, t_max: np.ndarray, s_rad: np.ndarray, lat: float, - elev: float, doy: np.ndarray) -> np.ndarray: +def get_priestley_taylor_pet(t_min: np.ndarray, t_max: np.ndarray, s_rad: np.ndarray, lat: float, elev: float, + doy: np.ndarray) -> np.ndarray: """Calculate potential evapotranspiration (PET) as an approximation following the Priestley-Taylor equation. - The ground head flux G is assumed to be 0 at daily time steps (see Newman et al. 2015). The - equation follow FAO-56 (Allen et al. (1998)) + The ground head flux G is assumed to be 0 at daily time steps (see Newman et al., 2015 [#]_). The + equations follow FAO-56 (Allen et al., 1998 [#]_). Parameters ---------- @@ -34,35 +29,47 @@ def get_priestley_taylor_pet(t_min: np.ndarray, t_max: np.ndarray, s_rad: np.nda ------- np.ndarray Array containing PET estimates in mm/day + + References + ---------- + .. [#] A. J. Newman, M. P. Clark, K. Sampson, A. Wood, L. E. Hay, A. Bock, R. J. Viger, D. Blodgett, + L. Brekke, J. R. Arnold, T. Hopson, and Q. Duan: Development of a large-sample watershed-scale + hydrometeorological dataset for the contiguous USA: dataset characteristics and assessment of regional + variability in hydrologic model performance. Hydrol. Earth Syst. Sci., 19, 209-223, + doi:10.5194/hess-19-209-2015, 2015 + .. [#] Allen, R. G., Pereira, L. S., Raes, D., & Smith, M. (1998). Crop evapotranspiration-Guidelines for computing + crop water requirements-FAO Irrigation and drainage paper 56. Fao, Rome, 300(9), D05109. """ - lat = lat * (PI / 180) # degree to rad + lat = lat * (np.pi / 180) # degree to rad # Slope of saturation vapour pressure curve t_mean = 0.5 * (t_min + t_max) - slope_svp = get_slope_svp_curve(t_mean) + slope_svp = _get_slope_svp_curve(t_mean) # incoming netto short-wave radiation s_rad = s_rad * 0.0864 # conversion Wm-2 -> MJm-2day-1 - in_sw_rad = get_net_sw_srad(s_rad) + in_sw_rad = _get_net_sw_srad(s_rad) # outgoginng netto long-wave radiation - sol_dec = get_sol_decl(doy) - sha = get_sunset_hour_angle(lat, sol_dec) - ird = get_ird_earth_sun(doy) - et_rad = get_extraterra_rad(lat, sol_dec, sha, ird) - cs_rad = get_clear_sky_rad(elev, et_rad) - a_vp = get_avp_tmin(t_min) - out_lw_rad = get_net_outgoing_lw_rad(t_min, t_max, s_rad, cs_rad, a_vp) + sol_dec = _get_sol_decl(doy) + sha = _get_sunset_hour_angle(lat, sol_dec) + ird = _get_ird_earth_sun(doy) + et_rad = _get_extraterra_rad(lat, sol_dec, sha, ird) + cs_rad = _get_clear_sky_rad(elev, et_rad) + a_vp = _get_avp_tmin(t_min) + out_lw_rad = _get_net_outgoing_lw_rad(t_min, t_max, s_rad, cs_rad, a_vp) # net radiation - net_rad = get_net_rad(in_sw_rad, out_lw_rad) + net_rad = _get_net_rad(in_sw_rad, out_lw_rad) # gamma - atm_pressure = get_atmos_pressure(elev) - gamma = get_psy_const(atm_pressure) + atm_pressure = _get_atmos_pressure(elev) + gamma = _get_psy_const(atm_pressure) # PET MJm-2day-1 - pet = (ALPHA / LAMBDA) * (slope_svp * net_rad) / (slope_svp + gamma) + alpha = 1.26 # Calibrated in CAMELS, here static + _lambda = 2.45 # Kept constant, MJkg-1 + pet = (alpha / _lambda) * (slope_svp * net_rad) / (slope_svp + gamma) # convert energy to evap pet = pet * 0.408 @@ -71,7 +78,7 @@ def get_priestley_taylor_pet(t_min: np.ndarray, t_max: np.ndarray, s_rad: np.nda @njit -def get_slope_svp_curve(t_mean: np.ndarray) -> np.ndarray: +def _get_slope_svp_curve(t_mean: np.ndarray) -> np.ndarray: """Slope of saturation vapour pressure curve Equation 13 FAO-56 Allen et al. (1998) @@ -91,7 +98,7 @@ def get_slope_svp_curve(t_mean: np.ndarray) -> np.ndarray: @njit -def get_net_sw_srad(s_rad: np.ndarray, albedo: float = 0.23) -> np.ndarray: +def _get_net_sw_srad(s_rad: np.ndarray, albedo: float = 0.23) -> np.ndarray: """Calculate net shortwave radiation Equation 38 FAO-56 Allen et al. (1998) @@ -113,7 +120,7 @@ def get_net_sw_srad(s_rad: np.ndarray, albedo: float = 0.23) -> np.ndarray: @njit -def get_sol_decl(doy: np.ndarray) -> np.ndarray: +def _get_sol_decl(doy: np.ndarray) -> np.ndarray: """Get solar declination Equation 24 FAO-56 Allen et al. (1998) @@ -134,7 +141,7 @@ def get_sol_decl(doy: np.ndarray) -> np.ndarray: @njit -def get_sunset_hour_angle(lat: float, sol_dec: np.ndarray) -> np.ndarray: +def _get_sunset_hour_angle(lat: float, sol_dec: np.ndarray) -> np.ndarray: """Sunset hour angle @@ -159,7 +166,7 @@ def get_sunset_hour_angle(lat: float, sol_dec: np.ndarray) -> np.ndarray: @njit -def get_ird_earth_sun(doy: np.ndarray) -> np.ndarray: +def _get_ird_earth_sun(doy: np.ndarray) -> np.ndarray: """Inverse relative distance between Earth and Sun Equation 23 FAO-56 Allen et al. (1998) @@ -174,13 +181,12 @@ def get_ird_earth_sun(doy: np.ndarray) -> np.ndarray: np.ndarray Inverse relative distance between Earth and Sun """ - ird = 1 + 0.033 * np.cos((2 * PI) / 365 * doy) + ird = 1 + 0.033 * np.cos((2 * np.pi) / 365 * doy) return ird @njit -def get_extraterra_rad(lat: float, sol_dec: np.ndarray, sha: np.ndarray, - ird: np.ndarray) -> np.ndarray: +def _get_extraterra_rad(lat: float, sol_dec: np.ndarray, sha: np.ndarray, ird: np.ndarray) -> np.ndarray: """Extraterrestrial Radiation Equation 21 FAO-56 Allen et al. (1998) @@ -201,14 +207,14 @@ def get_extraterra_rad(lat: float, sol_dec: np.ndarray, sha: np.ndarray, np.ndarray Extraterrestrial radiation MJm-2day-1 """ - term1 = (24 * 60) / PI * 0.082 * ird + term1 = (24 * 60) / np.pi * 0.082 * ird term2 = sha * np.sin(lat) * np.sin(sol_dec) + np.cos(lat) * np.cos(sol_dec) * np.sin(sha) et_rad = term1 * term2 return et_rad @njit -def get_clear_sky_rad(elev: float, et_rad: np.ndarray) -> np.ndarray: +def _get_clear_sky_rad(elev: float, et_rad: np.ndarray) -> np.ndarray: """Clear sky radiation Equation 37 FAO-56 Allen et al. (1998) @@ -230,7 +236,7 @@ def get_clear_sky_rad(elev: float, et_rad: np.ndarray) -> np.ndarray: @njit -def get_avp_tmin(t_min: np.ndarray) -> np.ndarray: +def _get_avp_tmin(t_min: np.ndarray) -> np.ndarray: """Actual vapor pressure estimated using min temperature Equation 48 FAO-56 Allen et al. (1998) @@ -250,8 +256,8 @@ def get_avp_tmin(t_min: np.ndarray) -> np.ndarray: @njit -def get_net_outgoing_lw_rad(t_min: np.ndarray, t_max: np.ndarray, s_rad: np.ndarray, - cs_rad: np.ndarray, a_vp: np.ndarray) -> np.ndarray: +def _get_net_outgoing_lw_rad(t_min: np.ndarray, t_max: np.ndarray, s_rad: np.ndarray, cs_rad: np.ndarray, + a_vp: np.ndarray) -> np.ndarray: """Net outgoing longwave radiation Expects temperatures in degree and does the conversion in kelvin in the function. @@ -279,12 +285,13 @@ def get_net_outgoing_lw_rad(t_min: np.ndarray, t_max: np.ndarray, s_rad: np.ndar term1 = ((t_max + 273.16)**4 + (t_min + 273.16)**4) / 2 # conversion in K in equation term2 = 0.34 - 0.14 * np.sqrt(a_vp) term3 = 1.35 * s_rad / cs_rad - 0.35 - net_lw = STEFAN_BOLTZMAN * term1 * term2 * term3 + stefan_boltzman = 4.903e-09 + net_lw = stefan_boltzman * term1 * term2 * term3 return net_lw @njit -def get_net_rad(sw_rad: np.ndarray, lw_rad: np.ndarray) -> np.ndarray: +def _get_net_rad(sw_rad: np.ndarray, lw_rad: np.ndarray) -> np.ndarray: """Net radiation Equation 40 FAO-56 Allen et al. (1998) @@ -305,7 +312,7 @@ def get_net_rad(sw_rad: np.ndarray, lw_rad: np.ndarray) -> np.ndarray: @njit -def get_atmos_pressure(elev: float) -> float: +def _get_atmos_pressure(elev: float) -> float: """Atmospheric pressure Equation 7 FAO-56 Allen et al. (1998) @@ -325,7 +332,7 @@ def get_atmos_pressure(elev: float) -> float: @njit -def get_psy_const(atm_pressure: float) -> float: +def _get_psy_const(atm_pressure: float) -> float: """Psychometric constant Parameters @@ -342,7 +349,8 @@ def get_psy_const(atm_pressure: float) -> float: @njit -def srad_from_t(et_rad, cs_rad, t_min, t_max, coastal=False): +def _srad_from_t(et_rad, cs_rad, t_min, t_max, coastal=False): + """Estimate solar radiation from temperature""" # equation 50 if coastal: adj = 0.19 diff --git a/neuralhydrology/modelzoo/basemodel.py b/neuralhydrology/modelzoo/basemodel.py index 212112d0..984fe277 100644 --- a/neuralhydrology/modelzoo/basemodel.py +++ b/neuralhydrology/modelzoo/basemodel.py @@ -7,6 +7,18 @@ class BaseModel(nn.Module): + """Abstract base model class, don't use this class for model training. + + Use subclasses of this class for training/evaluating different models, e.g. use `CudaLSTM` for training a standard + LSTM model or `EA-LSTM` for training an Entity-Aware-LSTM. Refer to + `Documentation/Modelzoo `_ for a full list of + available models and how to integrate a new model. + + Parameters + ---------- + cfg : Config + The run configuration. + """ def __init__(self, cfg: Config): super(BaseModel, self).__init__() @@ -15,13 +27,44 @@ def __init__(self, cfg: Config): self.output_size = len(cfg.target_variables) def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """Perform a forward pass. + + Parameters + ---------- + data : Dict[str, torch.Tensor] + Dictionary, containing input features as key-value pairs. + + Returns + ------- + Dict[str, torch.Tensor] + Model output and potentially any intermediate states and activations as a dictionary. + """ raise NotImplementedError def sample(self, data: Dict[str, torch.Tensor], n_samples: int) -> torch.Tensor: + """Sample model predictions, e.g., for MC-Dropout. + + This function does `n_samples` forward passes for each sample in the batch. Only useful for models with dropout, + to perform MC-Dropout sampling. Make sure to set the model to train mode before calling this function + (`model.train()`), otherwise dropout won't be active. + + Parameters + ---------- + data : Dict[str, torch.Tensor] + Dictionary, containing input features as key-value pairs. + n_samples : int + Number of samples to generate for each input sample. + + Returns + ------- + torch.Tensor + Sampled model outputs for the `predict_last_n` (config argument) time steps of each sequence. The shape of + the output is ``[batch size, predict_last_n, n_samples]``. + """ predict_last_n = self.cfg.predict_last_n samples = torch.zeros(data['x_d'].shape[0], predict_last_n, n_samples) for i in range(n_samples): prediction = self.forward(data) samples[:, -predict_last_n:, i] = prediction['y_hat'][:, -predict_last_n:, 0] - return samples \ No newline at end of file + return samples diff --git a/neuralhydrology/modelzoo/cudalstm.py b/neuralhydrology/modelzoo/cudalstm.py index 47818f6e..6dfc83f1 100644 --- a/neuralhydrology/modelzoo/cudalstm.py +++ b/neuralhydrology/modelzoo/cudalstm.py @@ -12,6 +12,22 @@ class CudaLSTM(BaseModel): + """LSTM model class, which relies on PyTorch's CUDA LSTM class. + + This class implements the standard LSTM combined with a model head, as specified in the config. All features + (time series and static) are concatenated and passed to the LSTM directly. If you want to embedd the static features + prior to the concatenation, use the `EmbCudaLSTM` class. + To control the initial forget gate bias, use the config argument `initial_forget_bias`. Often it is useful to set + this value to a positive value at the start of the model training, to keep the forget gate closed and to facilitate + the gradient flow. + The `CudaLSTM` class does only support single timescale predictions. Use `MTSLSTM` to train a model and get + predictions on multiple temporal resolutions at the same time. + + Parameters + ---------- + cfg : Config + The run configuration. + """ def __init__(self, cfg: Config): super(CudaLSTM, self).__init__(cfg=cfg) @@ -32,13 +48,29 @@ def __init__(self, cfg: Config): self.head = get_head(cfg=cfg, n_in=cfg.hidden_size, n_out=self.output_size) - self.reset_parameters() + self._reset_parameters() - def reset_parameters(self): + def _reset_parameters(self): + """Special initialization of certain model weights.""" if self.cfg.initial_forget_bias is not None: self.lstm.bias_hh_l0.data[self.cfg.hidden_size:2 * self.cfg.hidden_size] = self.cfg.initial_forget_bias def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """Perform a forward pass on the CudaLSTM model. + + Parameters + ---------- + data : Dict[str, torch.Tensor] + Dictionary, containing input features as key-value pairs. + + Returns + ------- + Dict[str, torch.Tensor] + Model outputs and intermediate states as a dictionary. + - `y_hat`: model predictions of shape [batch size, sequence length, number of target variables]. + - `h_n`: hidden state at the last time step of the sequence of shape [1, batch size, hidden size]. + - `c_n`: cell state at the last time step of the sequence of shape [1, batch size, hidden size]. + """ # transpose to [seq_length, batch_size, n_features] x_d = data['x_d'].transpose(0, 1) @@ -58,7 +90,7 @@ def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: lstm_output, (h_n, c_n) = self.lstm(input=x_d) - # reshape to [batch_size, seq_length, n_hiddens] + # reshape to [1 , batch_size, n_hiddens] h_n = h_n.transpose(0, 1) c_n = c_n.transpose(0, 1) diff --git a/neuralhydrology/modelzoo/ealstm.py b/neuralhydrology/modelzoo/ealstm.py index 64c0e4fc..dba26e08 100644 --- a/neuralhydrology/modelzoo/ealstm.py +++ b/neuralhydrology/modelzoo/ealstm.py @@ -10,6 +10,28 @@ class EALSTM(BaseModel): + """Entity-Aware LSTM (EA-LSTM) model class. + + This model has been proposed by Kratzert et al. [#]_ as a variant of the standard LSTM. The main difference is that + the input gate of the EA-LSTM is modulated using only the static inputs, while the dynamic (time series) inputs are + used in all other parts of the model (i.e. forget gate, cell update gate and output gate). + To control the initial forget gate bias, use the config argument `initial_forget_bias`. Often it is useful to set + this value to a positive value at the start of the model training, to keep the forget gate closed and to facilitate + the gradient flow. + The `EALSTM` class does only support single timescale predictions. Use `MTSLSTM` to train an LSTM-based model and + get predictions on multiple temporal resolutions at the same time. + + Parameters + ---------- + cfg : Config + The run configuration. + + References + ---------- + .. [#] Kratzert, F., Klotz, D., Shalev, G., Klambauer, G., Hochreiter, S., and Nearing, G.: Towards learning + universal, regional, and local hydrological behaviors via machine learning applied to large-sample datasets, + Hydrol. Earth Syst. Sci., 23, 5089–5110, https://doi.org/10.5194/hess-23-5089-2019, 2019. + """ def __init__(self, cfg: Config): super(EALSTM, self).__init__(cfg=cfg) @@ -36,9 +58,10 @@ def __init__(self, cfg: Config): self.head = get_head(cfg=cfg, n_in=cfg.hidden_size, n_out=self.output_size) # initialize parameters - self.reset_parameters() + self._reset_parameters() - def reset_parameters(self): + def _reset_parameters(self): + """Special initialization of certain model weights.""" nn.init.orthogonal_(self.weight_ih.data) weight_hh_data = torch.eye(self.cfg.hidden_size) @@ -51,7 +74,23 @@ def reset_parameters(self): self.bias.data[:self.cfg.hidden_size] = self.cfg.initial_forget_bias def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: - + """Perform a forward pass on the EA-LSTM model. + + Parameters + ---------- + data : Dict[str, torch.Tensor] + Dictionary, containing input features as key-value pairs. + + Returns + ------- + Dict[str, torch.Tensor] + Model outputs and intermediate states as a dictionary. + - `y_hat`: model predictions of shape [batch size, sequence length, number of target variables]. + - `h_n`: hidden state at the last time step of the sequence of shape + [batch size, sequence length, number of target variables]. + - `c_n`: cell state at the last time step of the sequence of shape + [batch size, sequence length, number of target variables]. + """ if 'x_s' in data and 'x_one_hot' in data: x_s = torch.cat([data['x_s'], data['x_one_hot']], dim=-1) elif 'x_s' in data: diff --git a/neuralhydrology/modelzoo/embcudalstm.py b/neuralhydrology/modelzoo/embcudalstm.py index e098daf2..38ec694e 100644 --- a/neuralhydrology/modelzoo/embcudalstm.py +++ b/neuralhydrology/modelzoo/embcudalstm.py @@ -10,6 +10,23 @@ class EmbCudaLSTM(BaseModel): + """EmbCudaLSTM model class, which adds embedding networks for static inputs to the standard LSTM. + + This class extends the standard `CudaLSTM` class to preprocess the static inputs by an embedding network, prior + to concatenating those values to the dynamic (time series) inputs. Use the config argument `embedding_hiddens` to + specify the architecture of the fully-connected embedding network. No activation function is applied to the outputs + of the embedding network. + To control the initial forget gate bias, use the config argument `initial_forget_bias`. Often it is useful to set + this value to a positive value at the start of the model training, to keep the forget gate closed and to facilitate + the gradient flow. + The `EmbCudaLSTM` class does only support single timescale predictions. Use `MTSLSTM` to train a model and get + predictions on multiple temporal resolutions at the same time. + + Parameters + ---------- + cfg : Config + The run configuration. + """ def __init__(self, cfg: Config): super(EmbCudaLSTM, self).__init__(cfg=cfg) @@ -27,14 +44,29 @@ def __init__(self, cfg: Config): self.head = get_head(cfg=cfg, n_in=cfg.hidden_size, n_out=self.output_size) - self.reset_parameters() + self._reset_parameters() - def reset_parameters(self): + def _reset_parameters(self): + """Special initialization of certain model weights.""" if self.cfg.initial_forget_bias is not None: self.lstm.bias_hh_l0.data[self.cfg.hidden_size:2 * self.cfg.hidden_size] = self.cfg.initial_forget_bias def forward(self, data: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: - + """Perform a forward pass on the EmbCudaLSTM model. + + Parameters + ---------- + data : Dict[str, torch.Tensor] + Dictionary, containing input features as key-value pairs. + + Returns + ------- + Dict[str, torch.Tensor] + Model outputs and intermediate states as a dictionary. + - `y_hat`: model predictions of shape [batch size, sequence length, number of target variables]. + - `h_n`: hidden state at the last time step of the sequence of shape [1, batch size, hidden size]. + - `c_n`: cell state at the last time step of the sequence of shape [1, batch size, hidden size]. + """ x_d = data['x_d'].transpose(0, 1) if 'x_s' in data and 'x_one_hot' in data: diff --git a/neuralhydrology/modelzoo/fc.py b/neuralhydrology/modelzoo/fc.py index d024b0e4..fb5bb175 100644 --- a/neuralhydrology/modelzoo/fc.py +++ b/neuralhydrology/modelzoo/fc.py @@ -6,6 +6,25 @@ class FC(nn.Module): + """Auxiliary class to build (multi-layer) fully-connected networks. + + This class is used in different places of the codebase to build fully-connected networks. E.g., the `EA-LSTM` and + `EmbCudaLSTM` use this class to create embedding networks for the static inputs. + Use the config argument `embedding_hiddens` to specify the hidden neurons of the embedding network. If only one + number is specified the embedding network consists of a single linear layer that maps the input into the specified + dimension. + Use the config argument `embedding_activation` to specify the activation function for intermediate layers. Currently + supported are 'tanh' and 'sigmoid'. + Use the config argument `embedding_dropout` to specify the dropout rate in intermediate layers. + + Parameters + ---------- + cfg : Config + The run configuration. + input_size : int, optional + Number of input features. If not specified, the number of input features is the sum of all static inputs (i.e., + catchment attributes, one-hot-encoding, etc.) + """ def __init__(self, cfg: Config, input_size: int = None): super(FC, self).__init__() @@ -44,7 +63,7 @@ def __init__(self, cfg: Config, input_size: int = None): layers.append(nn.Linear(input_size, output_size)) self.net = nn.Sequential(*layers) - self.reset_parameters() + self._reset_parameters() def _get_activation(self, name: str) -> nn.Module: if name.lower() == "tanh": @@ -55,7 +74,8 @@ def _get_activation(self, name: str) -> nn.Module: raise NotImplementedError(f"{name} currently not supported as activation in this class") return activation - def reset_parameters(self): + def _reset_parameters(self): + """Special initialization of certain model weights.""" for layer in self.net: if isinstance(layer, nn.modules.linear.Linear): n_in = layer.weight.shape[1] @@ -64,4 +84,17 @@ def reset_parameters(self): nn.init.constant_(layer.bias, val=0) def forward(self, x: torch.Tensor) -> torch.Tensor: + """Perform a forward pass on the FC model. + + Parameters + ---------- + x : torch.Tensor + Input data of shape [any, any, input size] + + Returns + ------- + torch.Tensor + Embedded inputs of shape [any, any, output_size], where 'output_size' is the last number specified in the + `embedding_hiddens` config argument. + """ return self.net(x) diff --git a/neuralhydrology/modelzoo/head.py b/neuralhydrology/modelzoo/head.py index cc112de4..7739a8c3 100644 --- a/neuralhydrology/modelzoo/head.py +++ b/neuralhydrology/modelzoo/head.py @@ -1,4 +1,5 @@ import logging +from typing import Dict import torch import torch.nn as nn @@ -34,13 +35,24 @@ def get_head(cfg: Config, n_in: int, n_out: int) -> nn.Module: class Regression(nn.Module): - """ - Regression head with different output activations. + """Single-layer regression head with different output activations. + + Parameters + ---------- + n_in : int + Number of input neurons. + n_out : int + Number of output neurons. + activation: str, optional + Output activation function. Can be specified in the config using the `output_activation` argument. Supported + are {'linear', 'relu', 'softplus'}. If not specified (or an unsupported activation function is specified), will + default to 'linear' activation. """ def __init__(self, n_in: int, n_out: int, activation: str = "linear"): super(Regression, self).__init__() + # TODO: Add multi-layer support layers = [nn.Linear(n_in, n_out)] if activation != "linear": if activation.lower() == "relu": @@ -51,5 +63,16 @@ def __init__(self, n_in: int, n_out: int, activation: str = "linear"): LOGGER.warning(f"## WARNING: Ignored output activation {activation} and used 'linear' instead.") self.net = nn.Sequential(*layers) - def forward(self, x: torch.Tensor): - return {'y_hat': self.net(x)} \ No newline at end of file + def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]: + """Perform a forward pass on the Regression head. + + Parameters + ---------- + x : torch.Tensor + + Returns + ------- + Dict[str, torch.Tensor] + Dictionary, containing the model predictions in the 'y_hat' key. + """ + return {'y_hat': self.net(x)} diff --git a/neuralhydrology/modelzoo/lstm.py b/neuralhydrology/modelzoo/lstm.py index edc91d7a..aa478506 100644 --- a/neuralhydrology/modelzoo/lstm.py +++ b/neuralhydrology/modelzoo/lstm.py @@ -17,6 +17,8 @@ class LSTM(BaseModel): The idea of this model is to be able to train an LSTM using the nn.LSTM layer, which uses the optimized CuDNN implementation, and later to copy the weights into this model for a more in-depth network analysis. + Can be used with trained models. For instance, from the `CudaLSTM` or `EmbCudaLSTM` classes, the CudNN LSTM layer can be + accessed as `model.lstm`, where `model` is a `CudaLSTM` or `EmbCudaLSTM` instance. Note: Currently only supports one-layer CuDNN LSTMs @@ -50,11 +52,9 @@ def __init__(self, cfg: Config): self.head = get_head(cfg=cfg, n_in=self._hidden_size, n_out=self.output_size) - def forward(self, - data: Dict[str, torch.Tensor], - h_0: torch.Tensor = None, + def forward(self, data: Dict[str, torch.Tensor], h_0: torch.Tensor = None, c_0: torch.Tensor = None) -> Dict[str, torch.Tensor]: - """Forward pass through the LSTM network + """Perform a forward pass on the LSTM model. Parameters ---------- @@ -141,9 +141,10 @@ def __init__(self, input_size: int, hidden_size: int, initial_forget_bias: float self.b_hh = nn.Parameter(torch.FloatTensor(4 * hidden_size)) self.b_ih = nn.Parameter(torch.FloatTensor(4 * hidden_size)) - self.reset_parameters() + self._reset_parameters() - def reset_parameters(self): + def _reset_parameters(self): + """Special initialization of certain model weights.""" stdv = math.sqrt(3 / self.hidden_size) for weight in self.parameters(): if len(weight.shape) > 1: diff --git a/neuralhydrology/modelzoo/mtslstm.py b/neuralhydrology/modelzoo/mtslstm.py index f16f3566..68e650da 100644 --- a/neuralhydrology/modelzoo/mtslstm.py +++ b/neuralhydrology/modelzoo/mtslstm.py @@ -22,11 +22,11 @@ class MTSLSTM(BaseModel): Each branch processes the inputs at its respective timescale. Finally, one prediction head per timescale generates the predictions for that timescale based on the LSTM output. Optionally, one can specify: - - a different hidden size for each LSTM branch (use a dict in the ``hidden_size`` config argument) - - different dynamic input variables for each timescale (use a dict in the ``dynamic_inputs`` config argument) - - the strategy to transfer states from the initial shared low-resolution LSTM to the per-timescale - higher-resolution LSTMs. By default, this is a linear transfer layer, but you can specify 'identity' to use an - identity operation or 'None' to turn off any transfer (via the ``transfer_mtlstm_states`` config argument). + - a different hidden size for each LSTM branch (use a dict in the ``hidden_size`` config argument) + - different dynamic input variables for each timescale (use a dict in the ``dynamic_inputs`` config argument) + - the strategy to transfer states from the initial shared low-resolution LSTM to the per-timescale + higher-resolution LSTMs. By default, this is a linear transfer layer, but you can specify 'identity' to use an + identity operation or 'None' to turn off any transfer (via the ``transfer_mtlstm_states`` config argument). The sMTS-LSTM variant has the same overall architecture, but the weights of the per-timescale branches (including From 2714f25c7013b5a363e618089a6a3838ba9cd8cc Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Wed, 7 Oct 2020 08:38:52 +0200 Subject: [PATCH 11/15] Data module restructuring (#13) * split data module into datasetzoo and datautils #5 * moved dataset loading funcs to class files, changed attribute sanity check --- .../api/neuralhydrology.data.caravan.rst | 7 - docs/source/api/neuralhydrology.data.rst | 20 - ...euralhydrology.datasetzoo.basedataset.rst} | 2 +- ...> neuralhydrology.datasetzoo.camelsgb.rst} | 2 +- ...> neuralhydrology.datasetzoo.camelsus.rst} | 2 +- ...alhydrology.datasetzoo.hourlycamelsus.rst} | 2 +- .../source/api/neuralhydrology.datasetzoo.rst | 15 + ...ralhydrology.datautils.climateindices.rst} | 2 +- ...ralhydrology.datautils.dischargeinput.rst} | 2 +- ....rst => neuralhydrology.datautils.pet.rst} | 2 +- docs/source/api/neuralhydrology.datautils.rst | 15 + ...st => neuralhydrology.datautils.utils.rst} | 2 +- docs/source/api/neuralhydrology.rst | 3 +- neuralhydrology/data/camelsus.py | 106 ---- neuralhydrology/data/utils.py | 543 ------------------ .../{data => datasetzoo}/__init__.py | 10 +- .../{data => datasetzoo}/basedataset.py | 14 +- .../{data => datasetzoo}/camelsgb.py | 102 +++- neuralhydrology/datasetzoo/camelsus.py | 240 ++++++++ .../{data => datasetzoo}/hourlycamelsus.py | 137 ++++- neuralhydrology/datautils/__init__.py | 0 .../{data => datautils}/climateindices.py | 2 +- .../{data => datautils}/dischargeinput.py | 2 +- neuralhydrology/{data => datautils}/pet.py | 0 neuralhydrology/datautils/utils.py | 167 ++++++ neuralhydrology/evaluation/metrics.py | 2 +- neuralhydrology/evaluation/signatures.py | 2 +- neuralhydrology/evaluation/tester.py | 6 +- neuralhydrology/modelzoo/mtslstm.py | 2 +- neuralhydrology/modelzoo/odelstm.py | 2 +- neuralhydrology/training/basetrainer.py | 6 +- neuralhydrology/training/regularization.py | 2 +- neuralhydrology/utils/nh_results_ensemble.py | 2 +- test/test_config_runs.py | 8 +- 34 files changed, 698 insertions(+), 733 deletions(-) delete mode 100644 docs/source/api/neuralhydrology.data.caravan.rst delete mode 100644 docs/source/api/neuralhydrology.data.rst rename docs/source/api/{neuralhydrology.data.basedataset.rst => neuralhydrology.datasetzoo.basedataset.rst} (58%) rename docs/source/api/{neuralhydrology.data.camelsgb.rst => neuralhydrology.datasetzoo.camelsgb.rst} (58%) rename docs/source/api/{neuralhydrology.data.camelsus.rst => neuralhydrology.datasetzoo.camelsus.rst} (58%) rename docs/source/api/{neuralhydrology.data.hourlycamelsus.rst => neuralhydrology.datasetzoo.hourlycamelsus.rst} (59%) create mode 100644 docs/source/api/neuralhydrology.datasetzoo.rst rename docs/source/api/{neuralhydrology.data.climateindices.rst => neuralhydrology.datautils.climateindices.rst} (59%) rename docs/source/api/{neuralhydrology.data.dischargeinput.rst => neuralhydrology.datautils.dischargeinput.rst} (59%) rename docs/source/api/{neuralhydrology.data.pet.rst => neuralhydrology.datautils.pet.rst} (57%) create mode 100644 docs/source/api/neuralhydrology.datautils.rst rename docs/source/api/{neuralhydrology.data.utils.rst => neuralhydrology.datautils.utils.rst} (58%) delete mode 100644 neuralhydrology/data/camelsus.py delete mode 100644 neuralhydrology/data/utils.py rename neuralhydrology/{data => datasetzoo}/__init__.py (92%) rename neuralhydrology/{data => datasetzoo}/basedataset.py (98%) rename neuralhydrology/{data => datasetzoo}/camelsgb.py (51%) create mode 100644 neuralhydrology/datasetzoo/camelsus.py rename neuralhydrology/{data => datasetzoo}/hourlycamelsus.py (54%) create mode 100644 neuralhydrology/datautils/__init__.py rename neuralhydrology/{data => datautils}/climateindices.py (99%) rename neuralhydrology/{data => datautils}/dischargeinput.py (98%) rename neuralhydrology/{data => datautils}/pet.py (100%) create mode 100644 neuralhydrology/datautils/utils.py diff --git a/docs/source/api/neuralhydrology.data.caravan.rst b/docs/source/api/neuralhydrology.data.caravan.rst deleted file mode 100644 index ef3bab55..00000000 --- a/docs/source/api/neuralhydrology.data.caravan.rst +++ /dev/null @@ -1,7 +0,0 @@ -Caravan -======= - -.. automodule:: neuralhydrology.data.caravan - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/neuralhydrology.data.rst b/docs/source/api/neuralhydrology.data.rst deleted file mode 100644 index 8eed95f4..00000000 --- a/docs/source/api/neuralhydrology.data.rst +++ /dev/null @@ -1,20 +0,0 @@ -nh.data -======= - -.. automodule:: neuralhydrology.data - :members: - :undoc-members: - :show-inheritance: - -.. toctree:: - :maxdepth: 4 - - neuralhydrology.data.basedataset - neuralhydrology.data.camelsus - neuralhydrology.data.hourlycamelsus - neuralhydrology.data.camelsgb - neuralhydrology.data.caravan - neuralhydrology.data.climateindices - neuralhydrology.data.dischargeinput - neuralhydrology.data.pet - neuralhydrology.data.utils diff --git a/docs/source/api/neuralhydrology.data.basedataset.rst b/docs/source/api/neuralhydrology.datasetzoo.basedataset.rst similarity index 58% rename from docs/source/api/neuralhydrology.data.basedataset.rst rename to docs/source/api/neuralhydrology.datasetzoo.basedataset.rst index 8aaf33f1..bc60db2a 100644 --- a/docs/source/api/neuralhydrology.data.basedataset.rst +++ b/docs/source/api/neuralhydrology.datasetzoo.basedataset.rst @@ -1,7 +1,7 @@ BaseDataset =========== -.. automodule:: neuralhydrology.data.basedataset +.. automodule:: neuralhydrology.datasetzoo.basedataset :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/source/api/neuralhydrology.data.camelsgb.rst b/docs/source/api/neuralhydrology.datasetzoo.camelsgb.rst similarity index 58% rename from docs/source/api/neuralhydrology.data.camelsgb.rst rename to docs/source/api/neuralhydrology.datasetzoo.camelsgb.rst index dffefa3c..5c2bc844 100644 --- a/docs/source/api/neuralhydrology.data.camelsgb.rst +++ b/docs/source/api/neuralhydrology.datasetzoo.camelsgb.rst @@ -1,7 +1,7 @@ CamelsGB ======== -.. automodule:: neuralhydrology.data.camelsgb +.. automodule:: neuralhydrology.datasetzoo.camelsgb :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/source/api/neuralhydrology.data.camelsus.rst b/docs/source/api/neuralhydrology.datasetzoo.camelsus.rst similarity index 58% rename from docs/source/api/neuralhydrology.data.camelsus.rst rename to docs/source/api/neuralhydrology.datasetzoo.camelsus.rst index b3540506..8cd08366 100644 --- a/docs/source/api/neuralhydrology.data.camelsus.rst +++ b/docs/source/api/neuralhydrology.datasetzoo.camelsus.rst @@ -1,7 +1,7 @@ CamelsUS ======== -.. automodule:: neuralhydrology.data.camelsus +.. automodule:: neuralhydrology.datasetzoo.camelsus :members: :undoc-members: :show-inheritance: diff --git a/docs/source/api/neuralhydrology.data.hourlycamelsus.rst b/docs/source/api/neuralhydrology.datasetzoo.hourlycamelsus.rst similarity index 59% rename from docs/source/api/neuralhydrology.data.hourlycamelsus.rst rename to docs/source/api/neuralhydrology.datasetzoo.hourlycamelsus.rst index 7876745f..b1c3a527 100644 --- a/docs/source/api/neuralhydrology.data.hourlycamelsus.rst +++ b/docs/source/api/neuralhydrology.datasetzoo.hourlycamelsus.rst @@ -1,7 +1,7 @@ HourlyCamelsUS ============== -.. automodule:: neuralhydrology.data.hourlycamelsus +.. automodule:: neuralhydrology.datasetzoo.hourlycamelsus :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/source/api/neuralhydrology.datasetzoo.rst b/docs/source/api/neuralhydrology.datasetzoo.rst new file mode 100644 index 00000000..f8f49b58 --- /dev/null +++ b/docs/source/api/neuralhydrology.datasetzoo.rst @@ -0,0 +1,15 @@ +nh.datasetzoo +============= + +.. automodule:: neuralhydrology.datasetzoo + :members: + :undoc-members: + :show-inheritance: + +.. toctree:: + :maxdepth: 4 + + neuralhydrology.datasetzoo.basedataset + neuralhydrology.datasetzoo.camelsus + neuralhydrology.datasetzoo.hourlycamelsus + neuralhydrology.datasetzoo.camelsgb diff --git a/docs/source/api/neuralhydrology.data.climateindices.rst b/docs/source/api/neuralhydrology.datautils.climateindices.rst similarity index 59% rename from docs/source/api/neuralhydrology.data.climateindices.rst rename to docs/source/api/neuralhydrology.datautils.climateindices.rst index 253ed06c..2d6dd2cf 100644 --- a/docs/source/api/neuralhydrology.data.climateindices.rst +++ b/docs/source/api/neuralhydrology.datautils.climateindices.rst @@ -1,7 +1,7 @@ climateindices ============== -.. automodule:: neuralhydrology.data.climateindices +.. automodule:: neuralhydrology.datautils.climateindices :members: :undoc-members: :show-inheritance: diff --git a/docs/source/api/neuralhydrology.data.dischargeinput.rst b/docs/source/api/neuralhydrology.datautils.dischargeinput.rst similarity index 59% rename from docs/source/api/neuralhydrology.data.dischargeinput.rst rename to docs/source/api/neuralhydrology.datautils.dischargeinput.rst index 4f470eeb..894ee906 100644 --- a/docs/source/api/neuralhydrology.data.dischargeinput.rst +++ b/docs/source/api/neuralhydrology.datautils.dischargeinput.rst @@ -1,7 +1,7 @@ dischargeinput ============== -.. automodule:: neuralhydrology.data.dischargeinput +.. automodule:: neuralhydrology.datautils.dischargeinput :members: :undoc-members: :show-inheritance: diff --git a/docs/source/api/neuralhydrology.data.pet.rst b/docs/source/api/neuralhydrology.datautils.pet.rst similarity index 57% rename from docs/source/api/neuralhydrology.data.pet.rst rename to docs/source/api/neuralhydrology.datautils.pet.rst index 7c05d758..65cb03f1 100644 --- a/docs/source/api/neuralhydrology.data.pet.rst +++ b/docs/source/api/neuralhydrology.datautils.pet.rst @@ -1,7 +1,7 @@ pet === -.. automodule:: neuralhydrology.data.pet +.. automodule:: neuralhydrology.datautils.pet :members: :undoc-members: :show-inheritance: diff --git a/docs/source/api/neuralhydrology.datautils.rst b/docs/source/api/neuralhydrology.datautils.rst new file mode 100644 index 00000000..be25b0b2 --- /dev/null +++ b/docs/source/api/neuralhydrology.datautils.rst @@ -0,0 +1,15 @@ +nh.datautils +============ + +.. automodule:: neuralhydrology.datautils + :members: + :undoc-members: + :show-inheritance: + +.. toctree:: + :maxdepth: 4 + + neuralhydrology.datautils.climateindices + neuralhydrology.datautils.dischargeinput + neuralhydrology.datautils.pet + neuralhydrology.datautils.utils diff --git a/docs/source/api/neuralhydrology.data.utils.rst b/docs/source/api/neuralhydrology.datautils.utils.rst similarity index 58% rename from docs/source/api/neuralhydrology.data.utils.rst rename to docs/source/api/neuralhydrology.datautils.utils.rst index 258676ee..f9a5cfe4 100644 --- a/docs/source/api/neuralhydrology.data.utils.rst +++ b/docs/source/api/neuralhydrology.datautils.utils.rst @@ -1,7 +1,7 @@ utils ===== -.. automodule:: neuralhydrology.data.utils +.. automodule:: neuralhydrology.datautils.utils :members: :undoc-members: :show-inheritance: diff --git a/docs/source/api/neuralhydrology.rst b/docs/source/api/neuralhydrology.rst index eddeb281..111ddea4 100644 --- a/docs/source/api/neuralhydrology.rst +++ b/docs/source/api/neuralhydrology.rst @@ -10,7 +10,8 @@ neuralhydrology API .. toctree:: :maxdepth: 4 - neuralhydrology.data + neuralhydrology.datasetzoo + neuralhydrology.datautils neuralhydrology.evaluation neuralhydrology.modelzoo neuralhydrology.training diff --git a/neuralhydrology/data/camelsus.py b/neuralhydrology/data/camelsus.py deleted file mode 100644 index 05a461a6..00000000 --- a/neuralhydrology/data/camelsus.py +++ /dev/null @@ -1,106 +0,0 @@ -from typing import Dict, List, Union - -import numpy as np -import pandas as pd -import xarray - -from neuralhydrology.data.basedataset import BaseDataset -from neuralhydrology.data import utils -from neuralhydrology.utils.config import Config - - -class CamelsUS(BaseDataset): - """Data set class for the CAMELS US data set by [#]_ and [#]_. - - Parameters - ---------- - cfg : Config - The run configuration. - is_train : bool - Defines if the dataset is used for training or evaluating. If True (training), means/stds for each feature - are computed and stored to the run directory. If one-hot encoding is used, the mapping for the one-hot encoding - is created and also stored to disk. If False, a `scaler` input is expected and similarly the `id_to_int` input - if one-hot encoding is used. - period : {'train', 'validation', 'test'} - Defines the period for which the data will be loaded - basin : str, optional - If passed, the data for only this basin will be loaded. Otherwise the basin(s) are read from the appropriate - basin file, corresponding to the `period`. - additional_features : List[Dict[str, pd.DataFrame]], optional - List of dictionaries, mapping from a basin id to a pandas DataFrame. This DataFrame will be added to the data - loaded from the dataset and all columns are available as 'dynamic_inputs', 'static_inputs' and - 'target_variables' - id_to_int : Dict[str, int], optional - If the config argument 'use_basin_id_encoding' is True in the config and period is either 'validation' or - 'test', this input is required. It is a dictionary, mapping from basin id to an integer (the one-hot encoding). - scaler : Dict[str, Union[pd.Series, xarray.DataArray]], optional - If period is either 'validation' or 'test', this input is required. It contains the means and standard - deviations for each feature and is stored to the run directory during training (train_data/train_data_scaler.p) - - References - ---------- - .. [#] A. J. Newman, M. P. Clark, K. Sampson, A. Wood, L. E. Hay, A. Bock, R. J. Viger, D. Blodgett, - L. Brekke, J. R. Arnold, T. Hopson, and Q. Duan: Development of a large-sample watershed-scale - hydrometeorological dataset for the contiguous USA: dataset characteristics and assessment of regional - variability in hydrologic model performance. Hydrol. Earth Syst. Sci., 19, 209-223, - doi:10.5194/hess-19-209-2015, 2015 - .. [#] Addor, N., Newman, A. J., Mizukami, N. and Clark, M. P.: The CAMELS data set: catchment attributes and - meteorology for large-sample studies, Hydrol. Earth Syst. Sci., 21, 5293-5313, doi:10.5194/hess-21-5293-2017, - 2017. - """ - - def __init__(self, - cfg: Config, - is_train: bool, - period: str, - basin: str = None, - additional_features: List[Dict[str, pd.DataFrame]] = [], - id_to_int: Dict[str, int] = {}, - scaler: Dict[str, Union[pd.Series, xarray.DataArray]] = {}): - super(CamelsUS, self).__init__(cfg=cfg, - is_train=is_train, - period=period, - basin=basin, - additional_features=additional_features, - id_to_int=id_to_int, - scaler=scaler) - - def _load_basin_data(self, basin: str) -> pd.DataFrame: - """Load input and output data from text files.""" - # get forcings - dfs = [] - for forcing in self.cfg.forcings: - df, area = utils.load_camels_us_forcings(self.cfg.data_dir, basin, forcing) - - # rename columns - if len(self.cfg.forcings) > 1: - df = df.rename(columns={col: f"{col}_{forcing}" for col in df.columns}) - dfs.append(df) - df = pd.concat(dfs, axis=1) - - # add discharge - df['QObs(mm/d)'] = utils.load_camels_us_discharge(self.cfg.data_dir, basin, area) - - # replace invalid discharge values by NaNs - qobs_cols = [col for col in df.columns if "qobs" in col.lower()] - for col in qobs_cols: - df.loc[df[col] < 0, col] = np.nan - - return df - - def _load_attributes(self) -> pd.DataFrame: - if self.cfg.camels_attributes: - if self.is_train: - # sanity check attributes for NaN in per-feature standard deviation - utils.attributes_sanity_check(data_dir=self.cfg.data_dir, - attribute_set=self.cfg.dataset, - basins=self.basins, - attribute_list=self.cfg.camels_attributes) - - df = utils.load_camels_us_attributes(self.cfg.data_dir, basins=self.basins) - - # remove all attributes not defined in the config - drop_cols = [c for c in df.columns if c not in self.cfg.camels_attributes] - df = df.drop(drop_cols, axis=1) - - return df diff --git a/neuralhydrology/data/utils.py b/neuralhydrology/data/utils.py deleted file mode 100644 index 9e7e508a..00000000 --- a/neuralhydrology/data/utils.py +++ /dev/null @@ -1,543 +0,0 @@ -from pathlib import Path -from typing import List, Tuple, Union - -import numpy as np -import pandas as pd -import xarray -from xarray.core.dataarray import DataArray -from xarray.core.dataset import Dataset - -######################################################################################################################## -# CAMELS US utility functions # -######################################################################################################################## - - -def load_camels_us_attributes(data_dir: Path, basins: List[str] = []) -> pd.DataFrame: - """Load CAMELS US attributes from the dataset provided by [#]_ - - Parameters - ---------- - data_dir : Path - Path to the CAMELS US directory. This folder must contain a 'camels_attributes_v2.0' folder (the original - data set) containing the corresponding txt files for each attribute group. - basins : List[str], optional - If passed, return only attributes for the basins specified in this list. Otherwise, the attributes of all basins - are returned. - - Returns - ------- - pandas.DataFrame - Basin-indexed DataFrame, containing the attributes as columns. - - References - ---------- - .. [#] Addor, N., Newman, A. J., Mizukami, N. and Clark, M. P.: The CAMELS data set: catchment attributes and - meteorology for large-sample studies, Hydrol. Earth Syst. Sci., 21, 5293-5313, doi:10.5194/hess-21-5293-2017, - 2017. - """ - attributes_path = Path(data_dir) / 'camels_attributes_v2.0' - - if not attributes_path.exists(): - raise RuntimeError(f"Attribute folder not found at {attributes_path}") - - txt_files = attributes_path.glob('camels_*.txt') - - # Read-in attributes into one big dataframe - dfs = [] - for txt_file in txt_files: - df_temp = pd.read_csv(txt_file, sep=';', header=0, dtype={'gauge_id': str}) - df_temp = df_temp.set_index('gauge_id') - - dfs.append(df_temp) - - df = pd.concat(dfs, axis=1) - # convert huc column to double digit strings - df['huc'] = df['huc_02'].apply(lambda x: str(x).zfill(2)) - df = df.drop('huc_02', axis=1) - - if basins: - # drop rows of basins not contained in the passed list - drop_basins = [b for b in df.index if b not in basins] - df = df.drop(drop_basins, axis=0) - - return df - - -def load_camels_us_forcings(data_dir: Path, basin: str, forcings: str) -> Tuple[pd.DataFrame, int]: - """Load the forcing data for a basin of the CAMELS US data set. - - Parameters - ---------- - data_dir : Path - Path to the CAMELS US directory. This folder must contain a 'basin_mean_forcing' folder containing one - subdirectory for each forcing. The forcing directories have to contain 18 subdirectories (for the 18 HUCS) as in - the original CAMELS data set. In each HUC folder are the forcing files (.txt), starting with the 8-digit basin - id. - basin : str - 8-digit USGS identifier of the basin. - forcings : str - Can be e.g. 'daymet' or 'nldas', etc. Must match the folder names in the 'basin_mean_forcing' directory. - - Returns - ------- - pd.DataFrame - Time-indexed DataFrame, containing the forcing data. - int - Catchment area (m2), specified in the header of the forcing file. - """ - forcing_path = data_dir / 'basin_mean_forcing' / forcings - if not forcing_path.is_dir(): - raise OSError(f"{forcing_path} does not exist") - - files = list(forcing_path.glob('**/*_forcing_leap.txt')) - file_path = [f for f in files if f.name[:8] == basin] - if file_path: - file_path = file_path[0] - else: - raise FileNotFoundError(f'No file for Basin {basin} at {file_path}') - - df = pd.read_csv(file_path, sep='\s+', header=3) - df["date"] = pd.to_datetime(df.Year.map(str) + "/" + df.Mnth.map(str) + "/" + df.Day.map(str), format="%Y/%m/%d") - df = df.set_index("date") - - # load area from header - with open(file_path, 'r') as fp: - content = fp.readlines() - area = int(content[2]) - - return df, area - - -def load_camels_us_discharge(data_dir: Path, basin: str, area: int) -> pd.Series: - """Load the discharge data for a basin of the CAMELS US data set. - - Parameters - ---------- - data_dir : Path - Path to the CAMELS US directory. This folder must contain a 'usgs_streamflow' folder with 18 - subdirectories (for the 18 HUCS) as in the original CAMELS data set. In each HUC folder are the discharge files - (.txt), starting with the 8-digit basin id. - basin : str - 8-digit USGS identifier of the basin. - area : int - Catchment area (m2), used to normalize the discharge. - - Returns - ------- - pd.Series - Time-index pandas.Series of the discharge values (mm/day) - """ - - discharge_path = data_dir / 'usgs_streamflow' - files = list(discharge_path.glob('**/*_streamflow_qc.txt')) - file_path = [f for f in files if f.name[:8] == basin] - if file_path: - file_path = file_path[0] - else: - raise FileNotFoundError(f'No file for Basin {basin} at {file_path}') - - col_names = ['basin', 'Year', 'Mnth', 'Day', 'QObs', 'flag'] - df = pd.read_csv(file_path, sep='\s+', header=None, names=col_names) - df["date"] = pd.to_datetime(df.Year.map(str) + "/" + df.Mnth.map(str) + "/" + df.Day.map(str), format="%Y/%m/%d") - df = df.set_index("date") - - # normalize discharge from cubic feed per second to mm per day - df.QObs = 28316846.592 * df.QObs * 86400 / (area * 10**6) - - return df.QObs - - -######################################################################################################################## -# HOURLY CAMELS US utility functions # -######################################################################################################################## - - -def load_hourly_us_forcings(data_dir: Path, basin: str, forcings: str) -> pd.DataFrame: - """Load the hourly forcing data for a basin of the CAMELS US data set. - - The hourly forcings are not included in the original data set by Newman et al. (2017). - - Parameters - ---------- - data_dir : Path - Path to the CAMELS US directory. This folder must contain an 'hourly' folder containing one subdirectory - for each forcing, which contains the forcing files (.csv) for each basin. Files have to contain the 8-digit - basin id. - basin : str - 8-digit USGS identifier of the basin. - forcings : str - Must match the folder names in the 'hourly' directory. E.g. 'nldas_hourly' - - Returns - ------- - pd.DataFrame - Time-indexed DataFrame, containing the forcing data. - """ - forcing_path = data_dir / 'hourly' / forcings - if not forcing_path.is_dir(): - raise OSError(f"{forcing_path} does not exist") - - files = list(forcing_path.glob('*.csv')) - file_path = [f for f in files if basin in f.stem] - if file_path: - file_path = file_path[0] - else: - raise FileNotFoundError(f'No file for Basin {basin} at {forcing_path}') - - return pd.read_csv(file_path, index_col=['date'], parse_dates=['date']) - - -def load_hourly_us_discharge(data_dir: Path, basin: str) -> pd.DataFrame: - """Load the hourly discharge data for a basin of the CAMELS US data set. - - Parameters - ---------- - data_dir : Path - Path to the CAMELS US directory. This folder must contain a folder called 'hourly' with a subdirectory - 'usgs_streamflow' which contains the discharge files (.csv) for each basin. File names must contain the 8-digit - basin id. - basin : str - 8-digit USGS identifier of the basin. - - Returns - ------- - pd.Series - Time-index Series of the discharge values (mm/hour) - """ - discharge_path = data_dir / 'hourly' / 'usgs_streamflow' - files = list(discharge_path.glob('**/*usgs-hourly.csv')) - file_path = [f for f in files if basin in f.stem] - if file_path: - file_path = file_path[0] - else: - raise FileNotFoundError(f'No file for Basin {basin} at {discharge_path}') - - return pd.read_csv(file_path, index_col=['date'], parse_dates=['date']) - - -def load_hourly_us_stage(data_dir: Path, basin: str) -> pd.Series: - """Load the hourly stage data for a basin of the CAMELS US data set. - - Parameters - ---------- - data_dir : Path - Path to the CAMELS US directory. This folder must contain a folder called 'hourly' with a subdirectory - 'usgs_stage' which contains the stage files (.csv) for each basin. File names must contain the 8-digit basin id. - basin : str - 8-digit USGS identifier of the basin. - - Returns - ------- - pd.Series - Time-index Series of the stage values (m) - """ - stage_path = data_dir / 'hourly' / 'usgs_stage' - files = list(stage_path.glob('**/*_utc.csv')) - file_path = [f for f in files if basin in f.stem] - if file_path: - file_path = file_path[0] - else: - raise FileNotFoundError(f'No file for Basin {basin} at {stage_path}') - - df = pd.read_csv(file_path, - sep=',', - index_col=['datetime'], - parse_dates=['datetime'], - usecols=['datetime', 'gauge_height_ft']) - df = df.resample('H').mean() - df["gauge_height_m"] = df["gauge_height_ft"] * 0.3048 - - return df["gauge_height_m"] - - -def load_hourly_us_netcdf(data_dir: Path, forcings: str) -> xarray.Dataset: - """Load hourly forcing and discharge data from preprocessed netCDF file. - - Parameters - ---------- - data_dir : Path - Path to the CAMELS US directory. This folder must contain a folder called 'hourly', containing the netCDF file. - forcings : str - Name of the forcing product. Must match the ending of the netCDF file. E.g. 'nldas_hourly' for - 'usgs-streamflow-nldas_hourly.nc' - - Returns - ------- - xarray.Dataset - Dataset containing the combined discharge and forcing data of all basins (as stored in the netCDF) - """ - netcdf_path = data_dir / 'hourly' / f'usgs-streamflow-{forcings}.nc' - if not netcdf_path.is_file(): - raise FileNotFoundError(f'No NetCDF file for hourly streamflow and {forcings} at {netcdf_path}.') - - return xarray.open_dataset(netcdf_path) - - -######################################################################################################################## -# CAMELS GB utility functions # -######################################################################################################################## - - -def load_camels_gb_attributes(data_dir: Path, basins: List[str] = []) -> pd.DataFrame: - """Load CAMELS GB attributes from the dataset provided by [#]_ - - Parameters - ---------- - data_dir : Path - Path to the CAMELS GB directory. This folder must contain an 'attributes' folder containing the corresponding - csv files for each attribute group (ending with _attributes.csv). - basins : List[str], optional - If passed, return only attributes for the basins specified in this list. Otherwise, the attributes of all basins - are returned. - - Returns - ------- - pd.DataFrame - Basin-indexed DataFrame, containing the attributes as columns. - - References - ---------- - .. [#] Coxon, G., Addor, N., Bloomfield, J. P., Freer, J., Fry, M., Hannaford, J., Howden, N. J. K., Lane, R., - Lewis, M., Robinson, E. L., Wagener, T., and Woods, R.: CAMELS-GB: Hydrometeorological time series and landscape - attributes for 671 catchments in Great Britain, Earth Syst. Sci. Data Discuss., - https://doi.org/10.5194/essd-2020-49, in review, 2020. - """ - attributes_path = Path(data_dir) / 'attributes' - - if not attributes_path.exists(): - raise RuntimeError(f"Attribute folder not found at {attributes_path}") - - txt_files = attributes_path.glob('*_attributes.csv') - - # Read-in attributes into one big dataframe - dfs = [] - for txt_file in txt_files: - df_temp = pd.read_csv(txt_file, sep=',', header=0, dtype={'gauge_id': str}) - df_temp = df_temp.set_index('gauge_id') - - dfs.append(df_temp) - - df = pd.concat(dfs, axis=1) - - if basins: - # drop rows of basins not contained in the passed list - drop_basins = [b for b in df.index if b not in basins] - df = df.drop(drop_basins, axis=0) - - return df - - -def load_camels_gb_timeseries(data_dir: Path, basin: str) -> pd.DataFrame: - """Load the time series data for one basin of the CAMELS GB data set. - - Parameters - ---------- - data_dir : Path - Path to the CAMELS GB directory. This folder must contain a folder called 'timeseries' containing the forcing - files for each basin as .csv file. The file names have to start with 'CAMELS_GB_hydromet_timeseries'. - basin : str - Basin identifier number as string. - - Returns - ------- - pd.DataFrame - Time-indexed DataFrame, containing the time series data (forcings + discharge) data. - """ - forcing_path = data_dir / 'timeseries' - if not forcing_path.is_dir(): - raise OSError(f"{forcing_path} does not exist") - - files = list(forcing_path.glob('**/CAMELS_GB_hydromet_timeseries*.csv')) - file_path = [f for f in files if f"_{basin}_" in f.name] - if file_path: - file_path = file_path[0] - else: - raise FileNotFoundError(f'No file for Basin {basin} at {file_path}') - - df = pd.read_csv(file_path, sep=',', header=0, dtype={'date': str}) - df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d") - df = df.set_index("date") - - return df - - -######################################################################################################################## -# General utility functions # -######################################################################################################################## - - -def load_hydroatlas_attributes(data_dir: Path, basins: List[str] = []) -> pd.DataFrame: - """Load HydroATLAS attributes into a pandas DataFrame - - Parameters - ---------- - data_dir : Path - Path to the root directory of the dataset. Must contain a folder called 'hydroatlas_attributes' with a file - called `attributes.csv`. The attributes file is expected to have one column called `basin_id`. - basins : List[str], optional - If passed, return only attributes for the basins specified in this list. Otherwise, the attributes of all basins - are returned. - - Returns - ------- - pd.DataFrame - Basin-indexed DataFrame containing the HydroATLAS attributes. - """ - attribute_file = data_dir / "hydroatlas_attributes" / "attributes.csv" - if not attribute_file.is_file(): - raise FileNotFoundError(attribute_file) - - df = pd.read_csv(attribute_file, dtype={'basin_id': str}) - df = df.set_index('basin_id') - - if basins: - drop_basins = [b for b in df.index if b not in basins] - df = df.drop(drop_basins, axis=0) - - return df - - -def load_basin_file(basin_file: Path) -> List[str]: - """Load list of basins from text file. - - Parameters - ---------- - basin_file : Path - Path to a basin txt file. File has to contain one basin id per row. - - Returns - ------- - List[str] - List of basin ids as strings. - """ - with basin_file.open('r') as fp: - basins = fp.readlines() - basins = sorted(basin.strip() for basin in basins) - return basins - - -def attributes_sanity_check(data_dir: Path, attribute_set: str, basins: List[str], attribute_list: List[str]): - """Utility function to check if the standard deviation of one (or more) attributes is zero. - - This utility function can be used to check if any attribute has a standard deviation of zero. This would lead to - NaN's, when normalizing the features and thus would lead to NaN's when training the model. The function will raise - a `RuntimeError` if one or more zeros have been detected and will print the list of corresponding attribute names - to the console. - - Parameters - ---------- - data_dir : Path - Path to the root directory of the data set - attribute_set : {'hydroatlas', 'camels_us', 'hourly_camels_us', 'camels_gb'} - Name of the attribute set to check. - basins : - List of basins to consider in the check. - attribute_list : - List of attribute names to consider in the check. - - Raises - ------ - ValueError - For an unknown 'attribute_set' - RuntimeError - If one or more attributes have a standard deviation of zero. - """ - if attribute_set == "hydroatlas": - df = load_hydroatlas_attributes(data_dir, basins) - elif attribute_set in ["camels_us", "hourly_camels_us"]: - df = load_camels_us_attributes(data_dir, basins) - elif attribute_set == "camels_gb": - df = load_camels_gb_attributes(data_dir, basins) - else: - raise ValueError(f"Unknown 'attribute_set' {attribute_set}") - drop_cols = [c for c in df.columns if c not in attribute_list] - df = df.drop(drop_cols, axis=1) - attributes = [] - if any(df.std() == 0.0) or any(df.std().isnull()): - for k, v in df.std().iteritems(): - if (v == 0) or (np.isnan(v)): - attributes.append(k) - if attributes: - msg = [ - "The following attributes have a std of zero or NaN, which results in NaN's ", - "when normalizing the features. Remove the attributes from the attribute feature list ", - "and restart the run. \n", f"Attributes: {attributes}" - ] - raise RuntimeError("".join(msg)) - - -def sort_frequencies(frequencies: List[str]) -> List[str]: - """Sort the passed frequencies from low to high frequencies. - - Use `pandas frequency strings - `_ - to define frequencies. Note: The strings need to include values, e.g., '1D' instead of 'D'. - - Parameters - ---------- - frequencies : List[str] - List of pandas frequency identifiers to be sorted. - - Returns - ------- - List[str] - Sorted list of pandas frequency identifiers. - """ - deltas = {freq: pd.to_timedelta(freq) for freq in frequencies} - return sorted(deltas, key=deltas.get)[::-1] - - -def infer_frequency(index: Union[pd.DatetimeIndex, np.ndarray]) -> str: - """Infer the frequency of an index of a pandas DataFrame/Series or xarray DataArray. - - Parameters - ---------- - index : Union[pd.DatetimeIndex, np.ndarray] - DatetimeIndex of a DataFrame/Series or array of datetime values. - - Returns - ------- - str - Frequency of the index as a `pandas frequency string - `_ - - Raises - ------ - ValueError - If the frequency cannot be inferred from the index or is zero. - """ - native_frequency = pd.infer_freq(index) - if native_frequency is None: - raise ValueError(f'Cannot infer a legal frequency from dataset: {native_frequency}.') - if native_frequency[0] not in '0123456789': # add a value to the unit so to_timedelta works - native_frequency = f'1{native_frequency}' - if pd.to_timedelta(native_frequency) == pd.to_timedelta(0): - raise ValueError('Inferred dataset frequency is zero.') - return native_frequency - - -def infer_datetime_coord(xr: Union[DataArray, Dataset]) -> str: - """Checks for coordinate with 'date' in its name and returns the name. - - Parameters - ---------- - xr : Union[DataArray, Dataset] - Array to infer coordinate name of. - - Returns - ------- - str - Name of datetime coordinate name. - - Raises - ------ - RuntimeError - If none or multiple coordinates with 'date' in its name are found. - """ - candidates = [c for c in list(xr.coords) if "date" in c] - if len(candidates) > 1: - raise RuntimeError("Found multiple coordinates with 'date' in its name.") - if not candidates: - raise RuntimeError("Did not find any coordinate with 'date' in its name") - - return candidates[0] diff --git a/neuralhydrology/data/__init__.py b/neuralhydrology/datasetzoo/__init__.py similarity index 92% rename from neuralhydrology/data/__init__.py rename to neuralhydrology/datasetzoo/__init__.py index 15f0d181..3ef0c1ea 100644 --- a/neuralhydrology/data/__init__.py +++ b/neuralhydrology/datasetzoo/__init__.py @@ -1,7 +1,7 @@ -from neuralhydrology.data.basedataset import BaseDataset -from neuralhydrology.data.camelsus import CamelsUS -from neuralhydrology.data.camelsgb import CamelsGB -from neuralhydrology.data.hourlycamelsus import HourlyCamelsUS +from neuralhydrology.datasetzoo.basedataset import BaseDataset +from neuralhydrology.datasetzoo.camelsus import CamelsUS +from neuralhydrology.datasetzoo.camelsgb import CamelsGB +from neuralhydrology.datasetzoo.hourlycamelsus import HourlyCamelsUS from neuralhydrology.utils.config import Config @@ -45,7 +45,7 @@ def get_dataset(cfg: Config, ------- BaseDataset A new data set instance, depending on the run configuration. - + Raises ------ NotImplementedError diff --git a/neuralhydrology/data/basedataset.py b/neuralhydrology/datasetzoo/basedataset.py similarity index 98% rename from neuralhydrology/data/basedataset.py rename to neuralhydrology/datasetzoo/basedataset.py index 651c7ea9..4be9d3a3 100644 --- a/neuralhydrology/data/basedataset.py +++ b/neuralhydrology/datasetzoo/basedataset.py @@ -13,7 +13,7 @@ from torch.utils.data import Dataset from tqdm import tqdm -from neuralhydrology.data import utils +from neuralhydrology.datautils import utils from neuralhydrology.utils.config import Config LOGGER = logging.getLogger(__name__) @@ -383,18 +383,16 @@ def _create_lookup_table(self, xr: xarray.Dataset): self.num_samples = len(self.lookup_table) def _load_hydroatlas_attributes(self): - if self.is_train: - # sanity check attributes for NaN in per-feature standard deviation - utils.attributes_sanity_check(data_dir=self.cfg.data_dir, - attribute_set="hydroatlas", - basins=self.basins, - attribute_list=self.cfg.hydroatlas_attributes) - df = utils.load_hydroatlas_attributes(self.cfg.data_dir, basins=self.basins) # remove all attributes not defined in the config drop_cols = [c for c in df.columns if c not in self.cfg.hydroatlas_attributes] df = df.drop(drop_cols, axis=1) + + if self.is_train: + # sanity check attributes for NaN in per-feature standard deviation + utils.attributes_sanity_check(df=df) + return df def _load_combined_attributes(self): diff --git a/neuralhydrology/data/camelsgb.py b/neuralhydrology/datasetzoo/camelsgb.py similarity index 51% rename from neuralhydrology/data/camelsgb.py rename to neuralhydrology/datasetzoo/camelsgb.py index 613298d5..7200835e 100644 --- a/neuralhydrology/data/camelsgb.py +++ b/neuralhydrology/datasetzoo/camelsgb.py @@ -1,10 +1,11 @@ +from pathlib import Path from typing import Dict, List, Union import pandas as pd import xarray -from neuralhydrology.data.basedataset import BaseDataset -from neuralhydrology.data import utils +from neuralhydrology.datasetzoo.basedataset import BaseDataset +from neuralhydrology.datautils import utils from neuralhydrology.utils.config import Config @@ -62,23 +63,104 @@ def __init__(self, def _load_basin_data(self, basin: str) -> pd.DataFrame: """Load input and output data from text files.""" - df = utils.load_camels_gb_timeseries(data_dir=self.cfg.data_dir, basin=basin) + df = load_camels_gb_timeseries(data_dir=self.cfg.data_dir, basin=basin) return df def _load_attributes(self) -> pd.DataFrame: if self.cfg.camels_attributes: - if self.is_train: - # sanity check attributes for NaN in per-feature standard deviation - utils.attributes_sanity_check(data_dir=self.cfg.data_dir, - attribute_set=self.cfg.dataset, - basins=self.basins, - attribute_list=self.cfg.camels_attributes) - df = utils.load_camels_gb_attributes(self.cfg.data_dir, basins=self.basins) + df = load_camels_gb_attributes(self.cfg.data_dir, basins=self.basins) # remove all attributes not defined in the config drop_cols = [c for c in df.columns if c not in self.cfg.camels_attributes] df = df.drop(drop_cols, axis=1) + if self.is_train: + # sanity check attributes for NaN in per-feature standard deviation + utils.attributes_sanity_check(df=df) + return df + + +def load_camels_gb_attributes(data_dir: Path, basins: List[str] = []) -> pd.DataFrame: + """Load CAMELS GB attributes from the dataset provided by [#]_ + + Parameters + ---------- + data_dir : Path + Path to the CAMELS GB directory. This folder must contain an 'attributes' folder containing the corresponding + csv files for each attribute group (ending with _attributes.csv). + basins : List[str], optional + If passed, return only attributes for the basins specified in this list. Otherwise, the attributes of all basins + are returned. + + Returns + ------- + pd.DataFrame + Basin-indexed DataFrame, containing the attributes as columns. + + References + ---------- + .. [#] Coxon, G., Addor, N., Bloomfield, J. P., Freer, J., Fry, M., Hannaford, J., Howden, N. J. K., Lane, R., + Lewis, M., Robinson, E. L., Wagener, T., and Woods, R.: CAMELS-GB: Hydrometeorological time series and landscape + attributes for 671 catchments in Great Britain, Earth Syst. Sci. Data Discuss., + https://doi.org/10.5194/essd-2020-49, in review, 2020. + """ + attributes_path = Path(data_dir) / 'attributes' + + if not attributes_path.exists(): + raise RuntimeError(f"Attribute folder not found at {attributes_path}") + + txt_files = attributes_path.glob('*_attributes.csv') + + # Read-in attributes into one big dataframe + dfs = [] + for txt_file in txt_files: + df_temp = pd.read_csv(txt_file, sep=',', header=0, dtype={'gauge_id': str}) + df_temp = df_temp.set_index('gauge_id') + + dfs.append(df_temp) + + df = pd.concat(dfs, axis=1) + + if basins: + # drop rows of basins not contained in the passed list + drop_basins = [b for b in df.index if b not in basins] + df = df.drop(drop_basins, axis=0) + + return df + + +def load_camels_gb_timeseries(data_dir: Path, basin: str) -> pd.DataFrame: + """Load the time series data for one basin of the CAMELS GB data set. + + Parameters + ---------- + data_dir : Path + Path to the CAMELS GB directory. This folder must contain a folder called 'timeseries' containing the forcing + files for each basin as .csv file. The file names have to start with 'CAMELS_GB_hydromet_timeseries'. + basin : str + Basin identifier number as string. + + Returns + ------- + pd.DataFrame + Time-indexed DataFrame, containing the time series data (forcings + discharge) data. + """ + forcing_path = data_dir / 'timeseries' + if not forcing_path.is_dir(): + raise OSError(f"{forcing_path} does not exist") + + files = list(forcing_path.glob('**/CAMELS_GB_hydromet_timeseries*.csv')) + file_path = [f for f in files if f"_{basin}_" in f.name] + if file_path: + file_path = file_path[0] + else: + raise FileNotFoundError(f'No file for Basin {basin} at {file_path}') + + df = pd.read_csv(file_path, sep=',', header=0, dtype={'date': str}) + df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d") + df = df.set_index("date") + + return df diff --git a/neuralhydrology/datasetzoo/camelsus.py b/neuralhydrology/datasetzoo/camelsus.py new file mode 100644 index 00000000..44cfe770 --- /dev/null +++ b/neuralhydrology/datasetzoo/camelsus.py @@ -0,0 +1,240 @@ +from pathlib import Path +from typing import Dict, List, Tuple, Union + +import numpy as np +import pandas as pd +import xarray + +from neuralhydrology.datasetzoo.basedataset import BaseDataset +from neuralhydrology.datautils import utils +from neuralhydrology.utils.config import Config + + +class CamelsUS(BaseDataset): + """Data set class for the CAMELS US data set by [#]_ and [#]_. + + Parameters + ---------- + cfg : Config + The run configuration. + is_train : bool + Defines if the dataset is used for training or evaluating. If True (training), means/stds for each feature + are computed and stored to the run directory. If one-hot encoding is used, the mapping for the one-hot encoding + is created and also stored to disk. If False, a `scaler` input is expected and similarly the `id_to_int` input + if one-hot encoding is used. + period : {'train', 'validation', 'test'} + Defines the period for which the data will be loaded + basin : str, optional + If passed, the data for only this basin will be loaded. Otherwise the basin(s) are read from the appropriate + basin file, corresponding to the `period`. + additional_features : List[Dict[str, pd.DataFrame]], optional + List of dictionaries, mapping from a basin id to a pandas DataFrame. This DataFrame will be added to the data + loaded from the dataset and all columns are available as 'dynamic_inputs', 'static_inputs' and + 'target_variables' + id_to_int : Dict[str, int], optional + If the config argument 'use_basin_id_encoding' is True in the config and period is either 'validation' or + 'test', this input is required. It is a dictionary, mapping from basin id to an integer (the one-hot encoding). + scaler : Dict[str, Union[pd.Series, xarray.DataArray]], optional + If period is either 'validation' or 'test', this input is required. It contains the means and standard + deviations for each feature and is stored to the run directory during training (train_data/train_data_scaler.p) + + References + ---------- + .. [#] A. J. Newman, M. P. Clark, K. Sampson, A. Wood, L. E. Hay, A. Bock, R. J. Viger, D. Blodgett, + L. Brekke, J. R. Arnold, T. Hopson, and Q. Duan: Development of a large-sample watershed-scale + hydrometeorological dataset for the contiguous USA: dataset characteristics and assessment of regional + variability in hydrologic model performance. Hydrol. Earth Syst. Sci., 19, 209-223, + doi:10.5194/hess-19-209-2015, 2015 + .. [#] Addor, N., Newman, A. J., Mizukami, N. and Clark, M. P.: The CAMELS data set: catchment attributes and + meteorology for large-sample studies, Hydrol. Earth Syst. Sci., 21, 5293-5313, doi:10.5194/hess-21-5293-2017, + 2017. + """ + + def __init__(self, + cfg: Config, + is_train: bool, + period: str, + basin: str = None, + additional_features: List[Dict[str, pd.DataFrame]] = [], + id_to_int: Dict[str, int] = {}, + scaler: Dict[str, Union[pd.Series, xarray.DataArray]] = {}): + super(CamelsUS, self).__init__(cfg=cfg, + is_train=is_train, + period=period, + basin=basin, + additional_features=additional_features, + id_to_int=id_to_int, + scaler=scaler) + + def _load_basin_data(self, basin: str) -> pd.DataFrame: + """Load input and output data from text files.""" + # get forcings + dfs = [] + for forcing in self.cfg.forcings: + df, area = load_camels_us_forcings(self.cfg.data_dir, basin, forcing) + + # rename columns + if len(self.cfg.forcings) > 1: + df = df.rename(columns={col: f"{col}_{forcing}" for col in df.columns}) + dfs.append(df) + df = pd.concat(dfs, axis=1) + + # add discharge + df['QObs(mm/d)'] = load_camels_us_discharge(self.cfg.data_dir, basin, area) + + # replace invalid discharge values by NaNs + qobs_cols = [col for col in df.columns if "qobs" in col.lower()] + for col in qobs_cols: + df.loc[df[col] < 0, col] = np.nan + + return df + + def _load_attributes(self) -> pd.DataFrame: + if self.cfg.camels_attributes: + + df = load_camels_us_attributes(self.cfg.data_dir, basins=self.basins) + + # remove all attributes not defined in the config + drop_cols = [c for c in df.columns if c not in self.cfg.camels_attributes] + df = df.drop(drop_cols, axis=1) + + if self.is_train: + # sanity check attributes for NaN in per-feature standard deviation + utils.attributes_sanity_check(df=df) + + return df + + +def load_camels_us_attributes(data_dir: Path, basins: List[str] = []) -> pd.DataFrame: + """Load CAMELS US attributes from the dataset provided by [#]_ + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. This folder must contain a 'camels_attributes_v2.0' folder (the original + data set) containing the corresponding txt files for each attribute group. + basins : List[str], optional + If passed, return only attributes for the basins specified in this list. Otherwise, the attributes of all basins + are returned. + + Returns + ------- + pandas.DataFrame + Basin-indexed DataFrame, containing the attributes as columns. + + References + ---------- + .. [#] Addor, N., Newman, A. J., Mizukami, N. and Clark, M. P.: The CAMELS data set: catchment attributes and + meteorology for large-sample studies, Hydrol. Earth Syst. Sci., 21, 5293-5313, doi:10.5194/hess-21-5293-2017, + 2017. + """ + attributes_path = Path(data_dir) / 'camels_attributes_v2.0' + + if not attributes_path.exists(): + raise RuntimeError(f"Attribute folder not found at {attributes_path}") + + txt_files = attributes_path.glob('camels_*.txt') + + # Read-in attributes into one big dataframe + dfs = [] + for txt_file in txt_files: + df_temp = pd.read_csv(txt_file, sep=';', header=0, dtype={'gauge_id': str}) + df_temp = df_temp.set_index('gauge_id') + + dfs.append(df_temp) + + df = pd.concat(dfs, axis=1) + # convert huc column to double digit strings + df['huc'] = df['huc_02'].apply(lambda x: str(x).zfill(2)) + df = df.drop('huc_02', axis=1) + + if basins: + # drop rows of basins not contained in the passed list + drop_basins = [b for b in df.index if b not in basins] + df = df.drop(drop_basins, axis=0) + + return df + + +def load_camels_us_forcings(data_dir: Path, basin: str, forcings: str) -> Tuple[pd.DataFrame, int]: + """Load the forcing data for a basin of the CAMELS US data set. + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. This folder must contain a 'basin_mean_forcing' folder containing one + subdirectory for each forcing. The forcing directories have to contain 18 subdirectories (for the 18 HUCS) as in + the original CAMELS data set. In each HUC folder are the forcing files (.txt), starting with the 8-digit basin + id. + basin : str + 8-digit USGS identifier of the basin. + forcings : str + Can be e.g. 'daymet' or 'nldas', etc. Must match the folder names in the 'basin_mean_forcing' directory. + + Returns + ------- + pd.DataFrame + Time-indexed DataFrame, containing the forcing data. + int + Catchment area (m2), specified in the header of the forcing file. + """ + forcing_path = data_dir / 'basin_mean_forcing' / forcings + if not forcing_path.is_dir(): + raise OSError(f"{forcing_path} does not exist") + + files = list(forcing_path.glob('**/*_forcing_leap.txt')) + file_path = [f for f in files if f.name[:8] == basin] + if file_path: + file_path = file_path[0] + else: + raise FileNotFoundError(f'No file for Basin {basin} at {file_path}') + + df = pd.read_csv(file_path, sep='\s+', header=3) + df["date"] = pd.to_datetime(df.Year.map(str) + "/" + df.Mnth.map(str) + "/" + df.Day.map(str), format="%Y/%m/%d") + df = df.set_index("date") + + # load area from header + with open(file_path, 'r') as fp: + content = fp.readlines() + area = int(content[2]) + + return df, area + + +def load_camels_us_discharge(data_dir: Path, basin: str, area: int) -> pd.Series: + """Load the discharge data for a basin of the CAMELS US data set. + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. This folder must contain a 'usgs_streamflow' folder with 18 + subdirectories (for the 18 HUCS) as in the original CAMELS data set. In each HUC folder are the discharge files + (.txt), starting with the 8-digit basin id. + basin : str + 8-digit USGS identifier of the basin. + area : int + Catchment area (m2), used to normalize the discharge. + + Returns + ------- + pd.Series + Time-index pandas.Series of the discharge values (mm/day) + """ + + discharge_path = data_dir / 'usgs_streamflow' + files = list(discharge_path.glob('**/*_streamflow_qc.txt')) + file_path = [f for f in files if f.name[:8] == basin] + if file_path: + file_path = file_path[0] + else: + raise FileNotFoundError(f'No file for Basin {basin} at {file_path}') + + col_names = ['basin', 'Year', 'Mnth', 'Day', 'QObs', 'flag'] + df = pd.read_csv(file_path, sep='\s+', header=None, names=col_names) + df["date"] = pd.to_datetime(df.Year.map(str) + "/" + df.Mnth.map(str) + "/" + df.Day.map(str), format="%Y/%m/%d") + df = df.set_index("date") + + # normalize discharge from cubic feed per second to mm per day + df.QObs = 28316846.592 * df.QObs * 86400 / (area * 10**6) + + return df.QObs diff --git a/neuralhydrology/data/hourlycamelsus.py b/neuralhydrology/datasetzoo/hourlycamelsus.py similarity index 54% rename from neuralhydrology/data/hourlycamelsus.py rename to neuralhydrology/datasetzoo/hourlycamelsus.py index c02dcfa8..0cd4d8d5 100644 --- a/neuralhydrology/data/hourlycamelsus.py +++ b/neuralhydrology/datasetzoo/hourlycamelsus.py @@ -1,10 +1,12 @@ import logging import pickle +from pathlib import Path import numpy as np import pandas as pd +import xarray -from neuralhydrology.data import utils, CamelsUS +from neuralhydrology.datasetzoo.camelsus import CamelsUS, load_camels_us_forcings, load_camels_us_attributes from neuralhydrology.utils.config import Config LOGGER = logging.getLogger(__name__) @@ -71,7 +73,7 @@ def _load_basin_data(self, basin: str) -> pd.DataFrame: df = self.load_hourly_data(basin, forcing) else: # load daily CAMELS forcings and upsample to hourly - df, _ = utils.load_camels_us_forcings(self.cfg.data_dir, basin, forcing) + df, _ = load_camels_us_forcings(self.cfg.data_dir, basin, forcing) df = df.resample('1H').ffill() if len(self.cfg.forcings) > 1: # rename columns @@ -86,12 +88,12 @@ def _load_basin_data(self, basin: str) -> pd.DataFrame: # add stage, if requested if 'gauge_height_m' in self.cfg.target_variables: - df = df.join(utils.load_hourly_us_stage(self.cfg.data_dir, basin)) + df = df.join(load_hourly_us_stage(self.cfg.data_dir, basin)) df.loc[df['gauge_height_m'] < 0, 'gauge_height_m'] = np.nan # convert discharge to 'synthetic' stage, if requested if 'synthetic_qobs_stage_meters' in self.cfg.target_variables: - attributes = utils.load_camels_us_attributes(data_dir=self.cfg.data_dir, basins=[basin]) + attributes = load_camels_us_attributes(data_dir=self.cfg.data_dir, basins=[basin]) with open(self.cfg.rating_curve_file, 'rb') as f: rating_curves = pickle.load(f) df['synthetic_qobs_stage_meters'] = np.nan @@ -119,7 +121,7 @@ def load_hourly_data(self, basin: str, forcings: str) -> pd.DataFrame: fallback_csv = False try: if self._netcdf_dataset is None: - self._netcdf_dataset = utils.load_hourly_us_netcdf(self.cfg.data_dir, forcings) + self._netcdf_dataset = load_hourly_us_netcdf(self.cfg.data_dir, forcings) df = self._netcdf_dataset.sel(basin=basin).to_dataframe() except FileNotFoundError: fallback_csv = True @@ -130,9 +132,130 @@ def load_hourly_data(self, basin: str, forcings: str) -> pd.DataFrame: fallback_csv = True LOGGER.warning(f'## Warning: NetCDF file does not contain data for {basin}. Trying slower csv files.') if fallback_csv: - df = utils.load_hourly_us_forcings(self.cfg.data_dir, basin, forcings) + df = load_hourly_us_forcings(self.cfg.data_dir, basin, forcings) # add discharge - df = df.join(utils.load_hourly_us_discharge(self.cfg.data_dir, basin)) + df = df.join(load_hourly_us_discharge(self.cfg.data_dir, basin)) return df + + +def load_hourly_us_forcings(data_dir: Path, basin: str, forcings: str) -> pd.DataFrame: + """Load the hourly forcing data for a basin of the CAMELS US data set. + + The hourly forcings are not included in the original data set by Newman et al. (2017). + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. This folder must contain an 'hourly' folder containing one subdirectory + for each forcing, which contains the forcing files (.csv) for each basin. Files have to contain the 8-digit + basin id. + basin : str + 8-digit USGS identifier of the basin. + forcings : str + Must match the folder names in the 'hourly' directory. E.g. 'nldas_hourly' + + Returns + ------- + pd.DataFrame + Time-indexed DataFrame, containing the forcing data. + """ + forcing_path = data_dir / 'hourly' / forcings + if not forcing_path.is_dir(): + raise OSError(f"{forcing_path} does not exist") + + files = list(forcing_path.glob('*.csv')) + file_path = [f for f in files if basin in f.stem] + if file_path: + file_path = file_path[0] + else: + raise FileNotFoundError(f'No file for Basin {basin} at {forcing_path}') + + return pd.read_csv(file_path, index_col=['date'], parse_dates=['date']) + + +def load_hourly_us_discharge(data_dir: Path, basin: str) -> pd.DataFrame: + """Load the hourly discharge data for a basin of the CAMELS US data set. + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. This folder must contain a folder called 'hourly' with a subdirectory + 'usgs_streamflow' which contains the discharge files (.csv) for each basin. File names must contain the 8-digit + basin id. + basin : str + 8-digit USGS identifier of the basin. + + Returns + ------- + pd.Series + Time-index Series of the discharge values (mm/hour) + """ + discharge_path = data_dir / 'hourly' / 'usgs_streamflow' + files = list(discharge_path.glob('**/*usgs-hourly.csv')) + file_path = [f for f in files if basin in f.stem] + if file_path: + file_path = file_path[0] + else: + raise FileNotFoundError(f'No file for Basin {basin} at {discharge_path}') + + return pd.read_csv(file_path, index_col=['date'], parse_dates=['date']) + + +def load_hourly_us_stage(data_dir: Path, basin: str) -> pd.Series: + """Load the hourly stage data for a basin of the CAMELS US data set. + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. This folder must contain a folder called 'hourly' with a subdirectory + 'usgs_stage' which contains the stage files (.csv) for each basin. File names must contain the 8-digit basin id. + basin : str + 8-digit USGS identifier of the basin. + + Returns + ------- + pd.Series + Time-index Series of the stage values (m) + """ + stage_path = data_dir / 'hourly' / 'usgs_stage' + files = list(stage_path.glob('**/*_utc.csv')) + file_path = [f for f in files if basin in f.stem] + if file_path: + file_path = file_path[0] + else: + raise FileNotFoundError(f'No file for Basin {basin} at {stage_path}') + + df = pd.read_csv(file_path, + sep=',', + index_col=['datetime'], + parse_dates=['datetime'], + usecols=['datetime', 'gauge_height_ft']) + df = df.resample('H').mean() + df["gauge_height_m"] = df["gauge_height_ft"] * 0.3048 + + return df["gauge_height_m"] + + +def load_hourly_us_netcdf(data_dir: Path, forcings: str) -> xarray.Dataset: + """Load hourly forcing and discharge data from preprocessed netCDF file. + + Parameters + ---------- + data_dir : Path + Path to the CAMELS US directory. This folder must contain a folder called 'hourly', containing the netCDF file. + forcings : str + Name of the forcing product. Must match the ending of the netCDF file. E.g. 'nldas_hourly' for + 'usgs-streamflow-nldas_hourly.nc' + + Returns + ------- + xarray.Dataset + Dataset containing the combined discharge and forcing data of all basins (as stored in the netCDF) + """ + netcdf_path = data_dir / 'hourly' / f'usgs-streamflow-{forcings}.nc' + if not netcdf_path.is_file(): + raise FileNotFoundError(f'No NetCDF file for hourly streamflow and {forcings} at {netcdf_path}.') + + return xarray.open_dataset(netcdf_path) diff --git a/neuralhydrology/datautils/__init__.py b/neuralhydrology/datautils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/neuralhydrology/data/climateindices.py b/neuralhydrology/datautils/climateindices.py similarity index 99% rename from neuralhydrology/data/climateindices.py rename to neuralhydrology/datautils/climateindices.py index d564c818..05a36b0d 100644 --- a/neuralhydrology/data/climateindices.py +++ b/neuralhydrology/datautils/climateindices.py @@ -9,7 +9,7 @@ from numba import njit from tqdm import tqdm -from neuralhydrology.data import pet, utils +from neuralhydrology.datautils import pet, utils LOGGER = logging.getLogger(__name__) diff --git a/neuralhydrology/data/dischargeinput.py b/neuralhydrology/datautils/dischargeinput.py similarity index 98% rename from neuralhydrology/data/dischargeinput.py rename to neuralhydrology/datautils/dischargeinput.py index f8b97474..e467c995 100644 --- a/neuralhydrology/data/dischargeinput.py +++ b/neuralhydrology/datautils/dischargeinput.py @@ -7,7 +7,7 @@ import pandas as pd from tqdm import tqdm -from neuralhydrology.data import utils +from neuralhydrology.datautils import utils LOGGER = logging.getLogger(__name__) diff --git a/neuralhydrology/data/pet.py b/neuralhydrology/datautils/pet.py similarity index 100% rename from neuralhydrology/data/pet.py rename to neuralhydrology/datautils/pet.py diff --git a/neuralhydrology/datautils/utils.py b/neuralhydrology/datautils/utils.py new file mode 100644 index 00000000..e40134fe --- /dev/null +++ b/neuralhydrology/datautils/utils.py @@ -0,0 +1,167 @@ +from pathlib import Path +from typing import List, Union + +import numpy as np +import pandas as pd +from xarray.core.dataarray import DataArray +from xarray.core.dataset import Dataset + + +def load_hydroatlas_attributes(data_dir: Path, basins: List[str] = []) -> pd.DataFrame: + """Load HydroATLAS attributes into a pandas DataFrame + + Parameters + ---------- + data_dir : Path + Path to the root directory of the dataset. Must contain a folder called 'hydroatlas_attributes' with a file + called `attributes.csv`. The attributes file is expected to have one column called `basin_id`. + basins : List[str], optional + If passed, return only attributes for the basins specified in this list. Otherwise, the attributes of all basins + are returned. + + Returns + ------- + pd.DataFrame + Basin-indexed DataFrame containing the HydroATLAS attributes. + """ + attribute_file = data_dir / "hydroatlas_attributes" / "attributes.csv" + if not attribute_file.is_file(): + raise FileNotFoundError(attribute_file) + + df = pd.read_csv(attribute_file, dtype={'basin_id': str}) + df = df.set_index('basin_id') + + if basins: + drop_basins = [b for b in df.index if b not in basins] + df = df.drop(drop_basins, axis=0) + + return df + + +def load_basin_file(basin_file: Path) -> List[str]: + """Load list of basins from text file. + + Parameters + ---------- + basin_file : Path + Path to a basin txt file. File has to contain one basin id per row. + + Returns + ------- + List[str] + List of basin ids as strings. + """ + with basin_file.open('r') as fp: + basins = fp.readlines() + basins = sorted(basin.strip() for basin in basins) + return basins + + +def attributes_sanity_check(df: pd.DataFrame): + """Utility function to check if the standard deviation of one (or more) attributes is zero. + + This utility function can be used to check if any attribute has a standard deviation of zero. This would lead to + NaN's, when normalizing the features and thus would lead to NaN's when training the model. The function will raise + a `RuntimeError` if one or more zeros have been detected and will print the list of corresponding attribute names + to the console. + + Parameters + ---------- + df : pd.DataFrame + DataFrame of catchment attributes as columns. + + Raises + ------ + RuntimeError + If one or more attributes have a standard deviation of zero. + """ + # Iterate over attributes and check for NaNs + attributes = [] + if any(df.std() == 0.0) or any(df.std().isnull()): + for k, v in df.std().iteritems(): + if (v == 0) or (np.isnan(v)): + attributes.append(k) + if attributes: + msg = [ + "The following attributes have a std of zero or NaN, which results in NaN's ", + "when normalizing the features. Remove the attributes from the attribute feature list ", + "and restart the run. \n", f"Attributes: {attributes}" + ] + raise RuntimeError("".join(msg)) + + +def sort_frequencies(frequencies: List[str]) -> List[str]: + """Sort the passed frequencies from low to high frequencies. + + Use `pandas frequency strings + `_ + to define frequencies. Note: The strings need to include values, e.g., '1D' instead of 'D'. + + Parameters + ---------- + frequencies : List[str] + List of pandas frequency identifiers to be sorted. + + Returns + ------- + List[str] + Sorted list of pandas frequency identifiers. + """ + deltas = {freq: pd.to_timedelta(freq) for freq in frequencies} + return sorted(deltas, key=deltas.get)[::-1] + + +def infer_frequency(index: Union[pd.DatetimeIndex, np.ndarray]) -> str: + """Infer the frequency of an index of a pandas DataFrame/Series or xarray DataArray. + + Parameters + ---------- + index : Union[pd.DatetimeIndex, np.ndarray] + DatetimeIndex of a DataFrame/Series or array of datetime values. + + Returns + ------- + str + Frequency of the index as a `pandas frequency string + `_ + + Raises + ------ + ValueError + If the frequency cannot be inferred from the index or is zero. + """ + native_frequency = pd.infer_freq(index) + if native_frequency is None: + raise ValueError(f'Cannot infer a legal frequency from dataset: {native_frequency}.') + if native_frequency[0] not in '0123456789': # add a value to the unit so to_timedelta works + native_frequency = f'1{native_frequency}' + if pd.to_timedelta(native_frequency) == pd.to_timedelta(0): + raise ValueError('Inferred dataset frequency is zero.') + return native_frequency + + +def infer_datetime_coord(xr: Union[DataArray, Dataset]) -> str: + """Checks for coordinate with 'date' in its name and returns the name. + + Parameters + ---------- + xr : Union[DataArray, Dataset] + Array to infer coordinate name of. + + Returns + ------- + str + Name of datetime coordinate name. + + Raises + ------ + RuntimeError + If none or multiple coordinates with 'date' in its name are found. + """ + candidates = [c for c in list(xr.coords) if "date" in c] + if len(candidates) > 1: + raise RuntimeError("Found multiple coordinates with 'date' in its name.") + if not candidates: + raise RuntimeError("Did not find any coordinate with 'date' in its name") + + return candidates[0] diff --git a/neuralhydrology/evaluation/metrics.py b/neuralhydrology/evaluation/metrics.py index 32e62944..2a7c9e36 100644 --- a/neuralhydrology/evaluation/metrics.py +++ b/neuralhydrology/evaluation/metrics.py @@ -5,7 +5,7 @@ from scipy import stats, signal from xarray.core.dataarray import DataArray -from neuralhydrology.data import utils +from neuralhydrology.datautils import utils def get_available_metrics() -> List[str]: diff --git a/neuralhydrology/evaluation/signatures.py b/neuralhydrology/evaluation/signatures.py index dcc1e726..b282afee 100644 --- a/neuralhydrology/evaluation/signatures.py +++ b/neuralhydrology/evaluation/signatures.py @@ -8,7 +8,7 @@ from numba import njit from xarray.core.dataarray import DataArray -from neuralhydrology.data import utils +from neuralhydrology.datautils import utils def get_available_signatures() -> List[str]: diff --git a/neuralhydrology/evaluation/tester.py b/neuralhydrology/evaluation/tester.py index c6528192..6e25c505 100644 --- a/neuralhydrology/evaluation/tester.py +++ b/neuralhydrology/evaluation/tester.py @@ -14,9 +14,9 @@ from torch.utils.data import DataLoader from tqdm import tqdm -from neuralhydrology.data import get_dataset -from neuralhydrology.data.basedataset import BaseDataset -from neuralhydrology.data.utils import load_basin_file, sort_frequencies +from neuralhydrology.datasetzoo import get_dataset +from neuralhydrology.datasetzoo.basedataset import BaseDataset +from neuralhydrology.datautils.utils import load_basin_file, sort_frequencies from neuralhydrology.evaluation import plots from neuralhydrology.evaluation.metrics import calculate_metrics from neuralhydrology.modelzoo import get_model diff --git a/neuralhydrology/modelzoo/mtslstm.py b/neuralhydrology/modelzoo/mtslstm.py index 68e650da..d2fd9587 100644 --- a/neuralhydrology/modelzoo/mtslstm.py +++ b/neuralhydrology/modelzoo/mtslstm.py @@ -5,7 +5,7 @@ import torch import torch.nn as nn -from neuralhydrology.data.utils import sort_frequencies +from neuralhydrology.datautils.utils import sort_frequencies from neuralhydrology.modelzoo.head import get_head from neuralhydrology.modelzoo.basemodel import BaseModel from neuralhydrology.utils.config import Config diff --git a/neuralhydrology/modelzoo/odelstm.py b/neuralhydrology/modelzoo/odelstm.py index 63bd1979..248dc6be 100644 --- a/neuralhydrology/modelzoo/odelstm.py +++ b/neuralhydrology/modelzoo/odelstm.py @@ -6,7 +6,7 @@ import torch import torch.nn as nn -from neuralhydrology.data.utils import sort_frequencies +from neuralhydrology.datautils.utils import sort_frequencies from neuralhydrology.modelzoo.basemodel import BaseModel from neuralhydrology.modelzoo.head import get_head from neuralhydrology.modelzoo.lstm import _LSTMCell diff --git a/neuralhydrology/training/basetrainer.py b/neuralhydrology/training/basetrainer.py index 6cdb1ee6..053c64c7 100644 --- a/neuralhydrology/training/basetrainer.py +++ b/neuralhydrology/training/basetrainer.py @@ -11,9 +11,9 @@ from tqdm import tqdm import neuralhydrology.training.loss as loss -from neuralhydrology.data import get_dataset -from neuralhydrology.data.basedataset import BaseDataset -from neuralhydrology.data.utils import load_basin_file +from neuralhydrology.datasetzoo import get_dataset +from neuralhydrology.datasetzoo.basedataset import BaseDataset +from neuralhydrology.datautils.utils import load_basin_file from neuralhydrology.evaluation import get_tester from neuralhydrology.evaluation.tester import BaseTester from neuralhydrology.modelzoo import get_model diff --git a/neuralhydrology/training/regularization.py b/neuralhydrology/training/regularization.py index e39485a3..33b7f408 100644 --- a/neuralhydrology/training/regularization.py +++ b/neuralhydrology/training/regularization.py @@ -3,7 +3,7 @@ import pandas as pd import torch -from neuralhydrology.data.utils import sort_frequencies +from neuralhydrology.datautils.utils import sort_frequencies from neuralhydrology.utils.config import Config diff --git a/neuralhydrology/utils/nh_results_ensemble.py b/neuralhydrology/utils/nh_results_ensemble.py index 255a0089..7abd79fa 100644 --- a/neuralhydrology/utils/nh_results_ensemble.py +++ b/neuralhydrology/utils/nh_results_ensemble.py @@ -12,7 +12,7 @@ from tqdm import tqdm sys.path.append(str(Path(__file__).parent.parent.parent)) -from neuralhydrology.data.utils import sort_frequencies +from neuralhydrology.datautils.utils import sort_frequencies from neuralhydrology.evaluation.metrics import calculate_metrics from neuralhydrology.utils.config import Config diff --git a/test/test_config_runs.py b/test/test_config_runs.py index 6eb760fb..12c4aeea 100644 --- a/test/test_config_runs.py +++ b/test/test_config_runs.py @@ -8,7 +8,7 @@ import pytest from pytest import approx -from neuralhydrology.data.utils import load_camels_us_forcings, load_camels_us_discharge, load_hourly_us_netcdf +from neuralhydrology.datasetzoo import camelsus, hourlycamelsus from neuralhydrology.evaluation.evaluate import start_evaluation from neuralhydrology.training.train import start_training from neuralhydrology.utils.config import Config @@ -108,7 +108,7 @@ def test_multi_timescale_regression(get_config: Fixture[Callable[[str], dict]], start_evaluation(cfg=config, run_dir=config.run_dir, epoch=1, period='test') results = _get_basin_results(config.run_dir, 1)[basin] - discharge = load_hourly_us_netcdf(config.data_dir, config.forcings[0]) \ + discharge = hourlycamelsus.load_hourly_us_netcdf(config.data_dir, config.forcings[0]) \ .sel(basin=basin, date=slice(test_start_date, test_end_date))['qobs_mm_per_hour'] hourly_results = results['1H']['xr'].to_dataframe().reset_index() @@ -147,7 +147,7 @@ def _get_basin_results(run_dir: Path, epoch: int) -> Dict: def _get_discharge(config: Config, basin: str) -> pd.Series: if config.dataset == 'camels_us': - _, area = load_camels_us_forcings(config.data_dir, basin, 'daymet') - return load_camels_us_discharge(config.data_dir, basin, area) + _, area = camelsus.load_camels_us_forcings(config.data_dir, basin, 'daymet') + return camelsus.load_camels_us_discharge(config.data_dir, basin, area) else: raise NotImplementedError From ab6f8e8509f3c6b63553683e24b395911b2082dd Mon Sep 17 00:00:00 2001 From: Martin Gauch <15731649+gauchm@users.noreply.github.com> Date: Wed, 7 Oct 2020 13:16:10 +0200 Subject: [PATCH 12/15] Add docs action (#15) Closes #14 --- .github/workflows/docs-ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/docs-ci.yml diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml new file mode 100644 index 00000000..d99540e3 --- /dev/null +++ b/.github/workflows/docs-ci.yml @@ -0,0 +1,25 @@ +name: "docs check" +on: + pull_request: + branches: + - master + - public + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.8' + - name: Install pandoc + run: | + sudo apt-get update -y && sudo apt-get install -y pandoc + - name: Install dependencies + run: | + pip install sphinx sphinx-rtd-theme nbsphinx nbsphinx-link + - name: Build Sphinx docs + working-directory: docs/ + run: | + make html From 9d669add48fa8d8989f2b1d24b7a8f85c3ca4812 Mon Sep 17 00:00:00 2001 From: Frederik Kratzert Date: Wed, 7 Oct 2020 13:23:42 +0200 Subject: [PATCH 13/15] Increment version number --- neuralhydrology/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neuralhydrology/__about__.py b/neuralhydrology/__about__.py index 3f013dc7..bbd0a5bf 100644 --- a/neuralhydrology/__about__.py +++ b/neuralhydrology/__about__.py @@ -1 +1 @@ -__version__ = "0.9.1-beta" +__version__ = "0.9.2-beta" From ff58e290bbabd582d117f3a50f3b1c34b022eff1 Mon Sep 17 00:00:00 2001 From: Martin Gauch <15731649+gauchm@users.noreply.github.com> Date: Wed, 7 Oct 2020 16:17:00 +0200 Subject: [PATCH 14/15] MTS-LSTM: fix shared_mtslstm config argument (#17) * MTS-LSTM: fix shared_mtslstm config argument With the change from per_frequency_lstm, the semantics of the config argument got reversed, but we didn't reflect this in the code. * bump version --- neuralhydrology/__about__.py | 2 +- neuralhydrology/modelzoo/mtslstm.py | 16 ++++++++-------- neuralhydrology/utils/config.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/neuralhydrology/__about__.py b/neuralhydrology/__about__.py index bbd0a5bf..363306ca 100644 --- a/neuralhydrology/__about__.py +++ b/neuralhydrology/__about__.py @@ -1 +1 @@ -__version__ = "0.9.2-beta" +__version__ = "0.9.3-beta" diff --git a/neuralhydrology/modelzoo/mtslstm.py b/neuralhydrology/modelzoo/mtslstm.py index d2fd9587..80b4829b 100644 --- a/neuralhydrology/modelzoo/mtslstm.py +++ b/neuralhydrology/modelzoo/mtslstm.py @@ -64,8 +64,8 @@ def __init__(self, cfg: Config): # start to count the number of inputs input_sizes = len(cfg.camels_attributes + cfg.hydroatlas_attributes + cfg.static_inputs) - # if not is_shared_mtslstm, the LSTM gets an additional frequency flag as input. - if not self._is_shared_mtslstm: + # if is_shared_mtslstm, the LSTM gets an additional frequency flag as input. + if self._is_shared_mtslstm: input_sizes += len(self._frequencies) if cfg.use_basin_id_encoding: @@ -76,8 +76,8 @@ def __init__(self, cfg: Config): if isinstance(cfg.dynamic_inputs, list): input_sizes = {freq: input_sizes + len(cfg.dynamic_inputs) for freq in self._frequencies} else: - if not self._is_shared_mtslstm: - raise ValueError(f'Different inputs not allowed if shared_mtslstm is False.') + if self._is_shared_mtslstm: + raise ValueError(f'Different inputs not allowed if shared_mtslstm is used.') input_sizes = {freq: input_sizes + len(cfg.dynamic_inputs[freq]) for freq in self._frequencies} if not isinstance(cfg.hidden_size, dict): @@ -86,11 +86,11 @@ def __init__(self, cfg: Config): else: self._hidden_size = cfg.hidden_size - if (not self._is_shared_mtslstm + if (self._is_shared_mtslstm or self._transfer_mtslstm_states["h"] == "identity" or self._transfer_mtslstm_states["c"] == "identity") \ and any(size != self._hidden_size[self._frequencies[0]] for size in self._hidden_size.values()): - raise ValueError("All hidden sizes must be equal if shared_mtslstm=False or state transfer=identity.") + raise ValueError("All hidden sizes must be equal if shared_mtslstm is used or state transfer=identity.") # create layer depending on selected frequencies self._init_modules(input_sizes) @@ -107,7 +107,7 @@ def _init_modules(self, input_sizes: Dict[str, int]): for idx, freq in enumerate(self._frequencies): freq_input_size = input_sizes[freq] - if not self._is_shared_mtslstm and idx > 0: + if self._is_shared_mtslstm and idx > 0: self.lstms[freq] = self.lstms[self._frequencies[idx - 1]] # same LSTM for all frequencies. self.heads[freq] = self.heads[self._frequencies[idx - 1]] # same head for all frequencies. else: @@ -162,7 +162,7 @@ def _prepare_inputs(self, data: Dict[str, torch.Tensor], freq: str) -> torch.Ten else: pass - if not self._is_shared_mtslstm: + if self._is_shared_mtslstm: # add frequency one-hot encoding idx = self._frequencies.index(freq) one_hot_freq = torch.zeros(x_d.shape[0], x_d.shape[1], len(self._frequencies)).to(x_d) diff --git a/neuralhydrology/utils/config.py b/neuralhydrology/utils/config.py index 98392af1..591655c7 100644 --- a/neuralhydrology/utils/config.py +++ b/neuralhydrology/utils/config.py @@ -468,7 +468,7 @@ def seq_length(self) -> Union[int, Dict[str, int]]: @property def shared_mtslstm(self) -> bool: - return self._cfg.get("shared_mtslstm", True) + return self._cfg.get("shared_mtslstm", False) @property def static_inputs(self) -> List[str]: From 5e4903a399279d2e1a880ff3abb531cb2c4da783 Mon Sep 17 00:00:00 2001 From: Martin Gauch <15731649+gauchm@users.noreply.github.com> Date: Thu, 8 Oct 2020 13:36:49 +0200 Subject: [PATCH 15/15] Update setup.py module list and metadata (#19) * Update setup.py module list and metadata * add github action to test installation --- .github/workflows/package-install.yml | 20 ++++++++++++++++++++ neuralhydrology/__about__.py | 2 +- setup.py | 16 ++++++++++------ 3 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/package-install.yml diff --git a/.github/workflows/package-install.yml b/.github/workflows/package-install.yml new file mode 100644 index 00000000..7ac0f84c --- /dev/null +++ b/.github/workflows/package-install.yml @@ -0,0 +1,20 @@ +name: test package installation + +on: + pull_request: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v2 + with: + python-version: '3.8' + + - name: Try installing the package + run: pip install --no-deps . \ No newline at end of file diff --git a/neuralhydrology/__about__.py b/neuralhydrology/__about__.py index 363306ca..885a0dd3 100644 --- a/neuralhydrology/__about__.py +++ b/neuralhydrology/__about__.py @@ -1 +1 @@ -__version__ = "0.9.3-beta" +__version__ = "0.9.3-beta.2" diff --git a/setup.py b/setup.py index 2c89b207..505665e5 100644 --- a/setup.py +++ b/setup.py @@ -16,13 +16,16 @@ setup(name='neuralhydrology', version=about["__version__"], packages=[ - 'neuralhydrology', 'neuralhydrology.data', 'neuralhydrology.utils', 'neuralhydrology.modelzoo', - 'neuralhydrology.training', 'neuralhydrology.evaluation' + 'neuralhydrology', 'neuralhydrology.datasetzoo', 'neuralhydrology.datautils', 'neuralhydrology.utils', + 'neuralhydrology.modelzoo', 'neuralhydrology.training', 'neuralhydrology.evaluation' ], - url='neuralhydrology.readthedocs.io', - license='', - author='Frederik Kratzert', - author_email='f.kratzert@gmail.com', + url='https://neuralhydrology.readthedocs.io', + project_urls={ + 'Documentation': 'https://neuralhydrology.readthedocs.io', + 'Source': 'https://github.com/neuralhydrology/neuralhydrology', + 'Research Blog': 'https://neuralhydrology.github.io/' + }, + author='Frederik Kratzert , Daniel Klotz, Martin Gauch', description='Library for training deep learning models with environmental focus', long_description=long_description, long_description_content_type='text/markdown', @@ -50,5 +53,6 @@ 'Operating System :: OS Independent', 'Topic :: Scientific/Engineering :: Artificial Intelligence', 'Topic :: Scientific/Engineering :: Hydrology', + 'License :: OSI Approved :: BSD License', ], keywords='deep learning hydrology lstm neural network streamflow discharge rainfall-runoff')