Skip to content

EasyHybrid.jl

Documentation for EasyHybrid.jl.

EasyHybrid.EasyHybrid Module
julia
EasyHybrid.jl

EasyHybrid is a Julia package for hybrid machine learning models, combining neural networks and traditional statistical methods. It provides tools for data preprocessing, model training, and evaluation, making it easier to build and deploy hybrid models.

The hybrid model combines a neural network h(x; θ), with inputs x and learnable parameters θ, together with a mechanistic model M(·, z; ϕ) driven by forcings z and parameterized by ϕ, where ϕ may be known, learned from data, or fixed.

source
EasyHybrid.DataConfig Type

Configuration for data preparation and loading.

Controls array types, observation shuffling, data splitting, cross-validation, and sequence construction for time-series training.

source
EasyHybrid.HybridModel Type
julia
HybridModel{T, P} <: LuxCore.AbstractLuxContainerLayer{(:NNs,)}

A unified hybrid model struct that handles both single and multi neural network architectures. It combines predictive neural networks (NNs) with a mechanistic_model to form a differentiable hybrid model.

source
EasyHybrid.HybridModel Method
julia
(m::HybridModel)(ds_k::Tuple, ps, st)

Forward pass of the hybrid model. Evaluates the neural networks to predict parameters, merges them with scaled global parameters and fixed parameters, and executes the mechanistic model. Returns a tuple (out, st_new).

source
EasyHybrid.InputBatchNorm Type
julia
InputBatchNorm(chs; kwargs...)

A wrapper around BatchNorm that handles 3D sequence input (features, timesteps, batch).

Lux's BatchNorm expects channels in the penultimate dimension, which works for 2D input (features, batch) but fails for 3D input (features, timesteps, batch) where features are in dim 1. This wrapper reshapes 3D input to 2D (features, timesteps * batch) before normalization, then reshapes back. For 2D input, delegates directly to BatchNorm.

source
EasyHybrid.LoggingLoss Type
julia
LoggingLoss

A structure to define a logging loss function for hybrid models.

Arguments

  • loss_types: A vector of loss specifications (Symbol, Function or Tuple)

    • Symbol: predefined loss, e.g. :mse

    • Function: custom loss function, e.g. custom_loss

    • Tuple: function with args/kwargs:

      • (f, args): positional args, e.g. (weighted_loss, (0.5,))

      • (f, kwargs): keyword args, e.g. (scaled_loss, (scale=2.0,))

      • (f, args, kwargs): both, e.g. (complex_loss, (0.5,), (scale=2.0,))

  • training_loss: The loss specification to use during training (same format as above)

  • extra_loss: Optional function (ŷ, ps; kwargs...) -> NamedTuple (or splattable collection) added to training loss (default: nothing)

  • agg: Function to aggregate losses across targets, e.g. sum or mean

  • train_mode: If true, uses training_loss; otherwise uses loss_types.

Examples

julia
# Simple predefined loss
logging = LoggingLoss(
    loss_types=[:mse, :mae],
    training_loss=:mse
)

# Custom loss function
custom_loss(ŷ, y) = mean(abs2, ŷ .- y)
logging = LoggingLoss(
    loss_types=[:mse, custom_loss],
    training_loss=custom_loss
)

# With arguments/kwargs
weighted_loss(ŷ, y, w) = w * mean(abs2, ŷ .- y)
scaled_loss(ŷ, y; scale=1.0) = scale * mean(abs2, ŷ .- y)
logging = LoggingLoss(
    loss_types=[:mse, (weighted_loss, (0.5,)), (scaled_loss, (scale=2.0,))],
    training_loss=(weighted_loss, (0.5,))
)
source
EasyHybrid.ParameterContainer Type
julia
ParameterContainer{NT <: NamedTuple, T}

A container for holding the parameter definitions of a model, including their default values, lower bounds, and upper bounds.

source
EasyHybrid.PerTarget Type
julia
PerTarget(losses)

A wrapper to indicate that a tuple of losses should be applied on a per-target basis.

source
EasyHybrid.RecurrenceOutputDense Type
julia
RecurrenceOutputDense(in_dims => out_dims, [activation])

A layer that wraps a Dense layer to handle sequence outputs from Recurrence layers.

When a Recurrence layer has return_sequence=true, it outputs a tuple/vector of arrays (one per timestep). This layer broadcasts the Dense operation over each timestep and reshapes the result to (features, timesteps, batch) format.

Arguments

  • in_dims::Int: Input dimension (should match Recurrence output dimension)

  • out_dims::Int: Output dimension

  • activation: Activation function (default: identity)

Example

julia
# Instead of manually creating:
broadcast_layer = @compact(; layer = Dense(15 => 15)) do x
    y = map(layer, x)
    @return permutedims(stack(y; dims = 3), (1, 3, 2))
end

# Simply use:
Chain(
    Recurrence(LSTMCell(15 => 15), return_sequence = true),
    RecurrenceOutputDense(15 => 15)
)
source
EasyHybrid.TrainConfig Type

Configuration for training a hybrid model.

Controls all aspects of the training process including optimization, loss computation, data handling, output, and visualization.

source
EasyHybrid.TrainResults Type

Output of train, containing the full training history, model state, and diagnostics.

source
EasyHybrid.TrainingPaths Type

Paths to all output files produced during a training run.

source
EasyHybrid.WrappedTuples Type
julia
WrappedTuples(vec::Vector{<:NamedTuple})

Wraps a vector of named tuples to allow dot-access to each field as a vector.

source
EasyHybrid._apply_loss Function
julia
_apply_loss(ŷ, y, y_nan, loss_spec)

Helper function to apply the appropriate loss function based on the specification type.

Arguments

  • ŷ: Predictions for a single target

  • y: Target values for a single target

  • y_nan: NaN mask for a single target

  • loss_spec: Loss specification (Symbol, Function, or Tuple)

Returns

  • Computed loss value
source
EasyHybrid._build_optim_loss Method

Build the scalar loss closure consumed by Optimization.jl, called as loss_fn(p, data). data is an already device-placed / Array-converted batch (shape ((x, forcings), (y, mask))), produced once per (mini)batch by collect_dim_data in the caller — not inside this closure. Keeping the data prep out of the loss is important: L-BFGS line searches call the objective many times per iteration, so re-running collect_dim_data (NamedTuple rebuilds, Array copies, gdev transfers) on every evaluation was a major slowdown, especially on the minibatch path. It also keeps the closure trivially Zygote-differentiable (no pairs(...)/∇map in the AD tape).

source
EasyHybrid._compute_loss Function
julia
_compute_loss(ŷ, y, y_nan, targets, loss_spec, agg::Function)
_compute_loss(ŷ, y, y_nan, targets, loss_types::Vector, agg::Function)

Compute the loss for the given predictions and targets using the specified training loss (or vector of losses) type and aggregation function.

Arguments:

  • : Predicted values.

  • y: Target values.

  • y_nan: Mask for NaN values.

  • targets: The targets for which the loss is computed.

  • loss_spec: The loss type to use during training, e.g., :mse.

  • loss_types::Vector: A vector of loss types to compute, e.g., [:mse, :mae].

  • agg::Function: The aggregation function to apply to the computed losses, e.g., sum or mean.

Returns a single loss value if loss_spec is provided, or a NamedTuple of losses for each type in loss_types.

source
EasyHybrid._get_target_nan Function
julia
_get_target_nan(y_nan, target)

Helper function to extract target-specific values from y_nan. Supports NamedTuple, KeyedArray, AbstractDimArray, and callables (functions).

source
EasyHybrid._get_target_y Function
julia
_get_target_y(y, target)

Helper function to extract target-specific values from y, handling cases where y can be a tuple of (y_obs, y_sigma). Supports NamedTuple, KeyedArray, AbstractDimArray, Tuple, and callables (functions).

source
EasyHybrid._init_nn_params Method
julia
_init_nn_params(rng, m::HybridModel{<:Any, <:NamedTuple})

Initialize parameters for a multi-neural network architecture. Returns a NamedTuple containing the initialized parameters for each sub-network.

source
EasyHybrid._init_nn_params Method
julia
_init_nn_params(rng, m::HybridModel{<:Any, <:Vector})

Initialize parameters for a single-neural network architecture. Returns a NamedTuple containing a single ps field with the network's parameters.

source
EasyHybrid._init_nn_states Method
julia
_init_nn_states(rng, m::HybridModel{<:Any, <:NamedTuple})

Initialize states for a multi-neural network architecture. Returns a NamedTuple containing the initialized states for each sub-network.

source
EasyHybrid._init_nn_states Method
julia
_init_nn_states(rng, m::HybridModel{<:Any, <:Vector})

Initialize states for a single-neural network architecture. Returns a NamedTuple containing a single st_nn field with the network's states.

source
EasyHybrid._is_named_leaf Method
julia
_is_named_leaf(name, child, key) -> Bool

Return true when child is an array leaf whose parent field is key (e.g. a Dense layer's weight matrix).

source
EasyHybrid._run_minibatch! Method

Explicit "repeated minibatch" driver for the full_batch = false Optimization path (Le et al., 2011, ICML, §4.2). For each of cfg.nepochs outer passes we iterate a reshuffled DataLoader; on every fixed minibatch we run cfg.inner_maxiters optimizer iterations warm-started from the current ps, then resample. A validation EpochSnapshot (history / early-stopping / dashboard / checkpoint) is built once per outer pass, so patience is counted in outer passes — consistent with the Optimisers.jl loop.

maxiters / epochs from solve_kwargs are dropped (the per-minibatch budget is cfg.inner_maxiters and the pass count is cfg.nepochs); any remaining solve_kwargs (e.g. g_abstol, f_reltol) are forwarded to each inner solve.

source
EasyHybrid._run_nn Method
julia
_run_nn(m::HybridModel{<:Any, <:NamedTuple}, ds_k::Tuple, ps, st)

Execute the forward pass for a multi-neural network architecture. Applies each sub-network to its specific predictors, and applies scaling to the outputs if required. Returns scaled parameter values, updated states, and raw network outputs.

source
EasyHybrid._run_nn Method
julia
_run_nn(m::HybridModel{<:Any, <:Vector}, ds_k::Tuple, ps, st)

Execute the forward pass for a single-neural network architecture. Applies the neural network to the given predictors, slices the output for multiple predicted parameters, and scales them if required. Returns scaled parameter values, updated states, and raw network outputs.

source
EasyHybrid._train Method
julia
_train(model, data, train_cfg, data_cfg, solve_kwargs)

Dispatcher used by train(...): routes to the original 4-arg _train body (the Lux.Training / Optimisers.jl loop) when train_cfg.opt isa Optimisers.AbstractRule, or to _train_optimization (which delegates batch iteration to Optimization.jl) otherwise. solve_kwargs are forwarded to solve(...) on the Optimization.jl branch and warned about on the Optimisers.jl branch.

source
EasyHybrid._train_optimization Method
julia
_train_optimization(model, data, train_cfg, data_cfg, solve_kwargs)

Optimization.jl-based driver dispatched from _train whenever train_cfg.opt is not an Optimisers.AbstractRule (e.g. Optim.LBFGS() / Optimization.LBFGS()); see the SciML minibatching tutorial.

Two modes, selected by train_cfg.full_batch:

  • full_batch = true: pass the full training set as a single tuple to one OptimizationProblem and a single solve(...). Batch-method idiom (the recommended L-BFGS setup): the objective is a single consistent function. solve_kwargs (e.g. maxiters, g_abstol, f_reltol) are splatted into solve, and train_cfg.eval_every builds a validation EpochSnapshot every N solver iterations via the callback.

  • full_batch = false: explicit "repeated minibatch" loop grounded in Le et al., 2011 (On Optimization Methods for Deep Learning, ICML, §4.2). For each of train_cfg.nepochs outer passes we iterate a (reshuffled) DataLoader and, on each fixed minibatch, run train_cfg.inner_maxiters optimizer iterations (solve(...; maxiters = inner_maxiters)), warm-starting the next minibatch from the current parameters. Holding the minibatch fixed for a few iterations keeps the objective (and L-BFGS curvature pairs / line search) consistent — naive one-step-per-minibatch L-BFGS does not converge. A validation EpochSnapshot is built once per outer pass. Optimization.jl's own DataLoader iteration is not used here because it only applies to the Optimisers.jl-style solvers, not Optim.jl's L-BFGS.

Both modes honour train_cfg.promote_f64: promote ps to Float64 before optimization (workaround for Lux.jl#1260).

source
EasyHybrid.build_opt_state Method
julia
build_opt_state(opt, ps::NamedTuple; default_rule = Optimisers.Adam())

Build the optimizer state tree consumed by Lux.Training.TrainState / Optimisers.update!. Three forms of opt are accepted:

  1. opt::Optimisers.AbstractRule — single rule applied to the whole parameter tree (delegates to Optimisers.setup(opt, ps)).

  2. opt::NamedTuple of Optimisers.AbstractRules — each rule is wired to the matching top-level branch of ps via Optimisers.setup(rule, ps[name]). Branches missing from opt use default_rule.

  3. opt::NamedTuple of pre-built state trees (already returned by a prior Optimisers.setup) — used as-is. Form 2 and 3 can be mixed in the same NamedTuple.

The returned state tree has the same top-level keys as ps.

Example

julia
ps, _ = LuxCore.setup(rng, hybrid_model)   # (; Rb = NN_ps, RUE = NN_ps, Q10 = [v])

# Form 2 — preferred, lets the framework call `Optimisers.setup`:
opt_state = build_opt_state(
    (; Rb = Adam(1e-3), RUE = Adam(1e-3), Q10 = Descent(1e-2)),
    ps,
)
source
EasyHybrid.build_parameter_matrix Method
julia
build_parameter_matrix(parameter_defaults_and_bounds::NamedTuple)

Build a ComponentArray matrix from a NamedTuple containing parameter defaults and bounds.

This function converts a NamedTuple where each value is a tuple of (default, lower, upper) bounds into a ComponentArray with named axes for easy parameter management in hybrid models.

Arguments

  • parameter_defaults_and_bounds::NamedTuple: A NamedTuple where each key is a parameter name and each value is a tuple of (default, lower, upper) for that parameter.

Returns

  • ComponentArray: A 2D ComponentArray with:
    • Row axis: Parameter names (from the NamedTuple keys)

    • Column axis: Bound types (:default, :lower, :upper)

    • Data: The parameter values organized in a matrix format

Example

julia
# Define parameter defaults and bounds
parameter_defaults_and_bounds = (
    θ_s = (0.464f0, 0.302f0, 0.700f0),     # Saturated water content [cm³/cm³]
    h_r = (1500.0f0, 1500.0f0, 1500.0f0),  # Pressure head at residual water content [cm]
    α   = (log(0.103f0), log(0.01f0), log(7.874f0)),  # Shape parameter [cm⁻¹]
    n   = (log(3.163f0 - 1), log(1.100f0 - 1), log(20.000f0 - 1)),  # Shape parameter [-]
)

# Build the ComponentArray
parameter_matrix = build_parameter_matrix(parameter_defaults_and_bounds)

# Access specific parameter bounds
parameter_matrix.θ_s.default  # Get default value for θ_s
parameter_matrix[:, :lower]   # Get all lower bounds
parameter_matrix[:, :upper]   # Get all upper bounds

Notes

  • The function expects each value in the NamedTuple to be a tuple with exactly 3 elements

  • The order of bounds is always (default, lower, upper)

  • The resulting ComponentArray can be used for parameter optimization and constraint handling

source
EasyHybrid.compute_loss Method
julia
compute_loss(HM, x, (y_t, y_nan), ps, st, logging::LoggingLoss)

Main loss function for hybrid models that handles both training and evaluation modes.

Arguments

  • HM: The hybrid model (AbstractLuxContainerLayer or specific model type)

  • x: Input data for the model

  • (y_t, y_nan): Tuple containing target values and NaN mask functions/arrays

  • ps: Model parameters

  • st: Model state

  • logging: LoggingLoss configuration

Returns

  • In training mode (logging.train_mode = true):

    • (loss_value, st): Single loss value and updated state
  • In evaluation mode (logging.train_mode = false):

    • (loss_values, st, ŷ): NamedTuple of losses, state and predictions
source
EasyHybrid.constructHybridModel Method
julia
constructHybridModel(predictors::NamedTuple, forcing, targets, mechanistic_model, parameters, global_param_names; kwargs...)

Construct a HybridModel with multiple neural network architectures. A separate neural network is built for each key in the predictors NamedTuple.

Arguments:

  • predictors::NamedTuple: A NamedTuple where keys are network names, and values are vectors of predictor variables for that network.

  • forcing: Variables passed directly to the mechanistic model.

  • targets: The target variables to predict.

  • mechanistic_model: A function implementing the process-based model.

  • parameters: A parameter container defining defaults, lowers, and uppers.

  • global_param_names: Names of the parameters to be globally optimized.

  • kwargs: Additional configuration. hidden_layers and activation can also be NamedTuples to configure each network independently.

source
EasyHybrid.constructHybridModel Method
julia
constructHybridModel(predictors::Vector{Symbol}, forcing, targets, mechanistic_model, parameters, neural_param_names, global_param_names; kwargs...)

Construct a HybridModel with a single neural network architecture predicting all neural_param_names from the predictors.

Arguments:

  • predictors::Vector{Symbol}: Variables used as inputs to the neural network.

  • forcing: Variables passed directly to the mechanistic model.

  • targets: The target variables to predict.

  • mechanistic_model: A function implementing the process-based model.

  • parameters: A parameter container defining defaults, lowers, and uppers.

  • neural_param_names: Names of the parameters to be predicted by the neural network.

  • global_param_names: Names of the parameters to be globally optimized.

  • kwargs: Additional configuration like hidden_layers, activation, scale_nn_outputs, etc.

source
EasyHybrid.constructNNModel Method
julia
constructNNModel(predictors, targets; hidden_layers, activation, scale_nn_outputs)

Main constructor: hidden_layers can be either • a Vector{Int} of sizes, or • a Chain of hidden-layer Dense blocks.

source
EasyHybrid.evec Method

evec(nt::NamedTuple)

source
EasyHybrid.extract_weights Method
julia
extract_weights(ps; key=:weight) -> Vector{AbstractArray}

Walk the parameter tree ps (a ComponentArray, NamedTuple, or any nested combination of them) and return all leaf arrays whose immediate parent field name equals key.

Defaults to :weight, so you get the weight matrices of Dense/Conv layers and skip biases, BatchNorm scale/bias, running statistics in st, and any scalar global parameters.

The returned arrays are views/aliases into ps. When ps is the argument the autodiff is differentiating w.r.t., gradients of any function of these views flow back into the trainable weights.

source
EasyHybrid.filter_sequences Method
julia
filter_sequences(x, y) -> (x_filtered, y_filtered)

Drop 3rd-dim samples where any predictor is NaN or all targets are NaN. Accepts x as a 3D array or (x_array, forcings) Tuple.

source
EasyHybrid.get_loss_value Method
julia
get_loss_value(losses, loss_spec, agg)

Extract loss value from losses based on the loss specification type.

Arguments

  • losses: NamedTuple containing loss values

  • loss_spec: Loss specification (Symbol, Function or Tuple)

  • agg: Aggregation function name as Symbol

Returns

  • Loss value for the specified loss function

Examples

julia
# Symbol case
val = get_loss_value(losses, :mse, :sum)

# Function case
custom_loss(ŷ, y) = mean(abs2, ŷ .- y)
val = get_loss_value(losses, custom_loss, :mean)

# Tuple case
weighted_loss(ŷ, y, w) = w * mean(abs2, ŷ .- y)
val = get_loss_value(losses, (weighted_loss, (0.5,)), :sum)
source
EasyHybrid.get_mechanistic_model_config Method
julia
get_mechanistic_model_config(f::Function)

Build an OrderedDict describing a function for YAML output: its name, and for each Method, its source file, starting line, and the full source text extracted from disk by parsing one complete expression starting at that line. The single-method case is flattened so the YAML stays compact.

Used to record mechanistic_model in the saved config so the exact function definition (not just its name) is preserved alongside the run.

source
EasyHybrid.get_parameters_config Method
julia
get_parameters_config(pc::ParameterContainer)

Serialize the parameter table (default, lower, upper per parameter) of a ParameterContainer into a nested OrderedDict suitable for YAML output. Without this, only the compact show of the struct (e.g. ParameterContainer(RUE, Rb, Q10)) would be written, which drops all of the actual default and bound values.

source
EasyHybrid.get_prediction_target_names Method
julia
get_prediction_target_names(hm)

Utility function to extract predictor/forcing and target names from a hybrid model.

Arguments:

  • hm: The Hybrid Model

Returns a tuple of (predictors_forcing, targets) names.

source
EasyHybrid.initialize_plotting_observables Method
julia
initialize_plotting_observables(init_ŷ_train, init_ŷ_val, y_train, y_val, l_init_train, l_init_val, training_loss, agg, monitor_names, target_names)

Initialize plotting observables for training visualization if the Makie extension is loaded.

source
EasyHybrid.is_optimisers_rule Method
julia
is_optimisers_rule(opt) -> Bool

Return true when opt originates from the Optimisers.jl package (e.g. Adam, AdamW, RMSProp, OptimiserChain), or when opt is a NamedTuple describing a per-branch optimizer (see is_per_branch_opt).

The check on single rules is by source package (nameof(parentmodule(typeof(opt))) === :Optimisers) rather than by isa Optimisers.AbstractRule, because in some package combinations Optim.jl optimizers were observed to satisfy the AbstractRule test and get misrouted to the Lux.Training loop.

The Lux.Training-based loop dispatches on this; everything else (including Optim.jl and Optimization.jl optimizers) is routed through the Optimization.jl driver.

source
EasyHybrid.is_per_branch_opt Method
julia
is_per_branch_opt(opt) -> Bool

Return true when opt is a NamedTuple describing a per-branch optimizer specification — i.e. one of:

  • a NamedTuple of Optimisers.AbstractRules (e.g. (; Rb = Adam(1e-3), Q10 = Descent(1e-2))), or

  • a NamedTuple of pre-built optimizer state trees as returned by Optimisers.setup(rule, ps_branch), or

  • a mix of the two.

Branches of the parameter tree not listed in opt fall back to the default rule Adam() (see build_opt_state).

This is detected purely by opt isa NamedTuple; the per-branch dispatch is checked again per-leaf when build_opt_state walks the spec.

source
EasyHybrid.kwargs_to_configs Method
julia
kwargs_to_configs(save_ps, kwargs) -> (TrainConfig, DataConfig, NamedTuple)

Build a fresh (TrainConfig, DataConfig) pair from a flat collection of kwargs. Kwargs are split between the two configs based on fieldnames(TrainConfig) and fieldnames(DataConfig); anything left over is returned as the third element and forwarded to solve(...) on the Optimization.jl path (or warned about on the Optimisers.jl path — see _train).

save_ps is the deprecated positional argument from train(model, data, save_ps; ...); when non-empty it is forwarded as tracked_params on the resulting TrainConfig.

source
EasyHybrid.load_timeseries_netcdf Method
julia
load_timeseries_netcdf(path::AbstractString; timedim::AbstractString = "time") -> DataFrame

Reads a NetCDF file where all data variables are 1D over the specified timedim and returns a tidy DataFrame with one row per time step.

  • Only includes variables whose sole dimension is timedim.

  • Does not attempt to parse or convert time units; all columns are read as-is.

source
EasyHybrid.loss_fn Function
julia
loss_fn(ŷ, y, y_nan, loss_type)

Compute the loss for given predictions and targets using various loss specifications.

Arguments

  • ŷ: Predicted values

  • y: Target values

  • y_nan: Mask for NaN values

  • loss_type: One of the following:

    • Val(:rmse): Root Mean Square Error

    • Val(:mse): Mean Square Error

    • Val(:mae): Mean Absolute Error

    • Val(:pearson): Pearson correlation coefficient

    • Val(:r2): R-squared

    • Val(:pearsonLoss): 1 - Pearson correlation coefficient

    • Val(:nseLoss): 1 - NSE

    • ::Function: Custom loss function with signature f(ŷ, y)

    • ::Tuple{Function, Tuple}: Custom loss with args f(ŷ, y, args...)

    • ::Tuple{Function, NamedTuple}: Custom loss with kwargs f(ŷ, y; kwargs...)

    • ::Tuple{Function, Tuple, NamedTuple}: Custom loss with both f(ŷ, y, args...; kwargs...)

Examples

julia
# Predefined loss
loss = loss_fn(ŷ, y, y_nan, Val(:mse))

# Custom loss function
custom_loss(ŷ, y) = mean(abs2, ŷ .- y)
loss = loss_fn(ŷ, y, y_nan, custom_loss)

# With positional arguments
weighted_loss(ŷ, y, w) = w * mean(abs2, ŷ .- y)
loss = loss_fn(ŷ, y, y_nan, (weighted_loss, (0.5,)))

# With keyword arguments
scaled_loss(ŷ, y; scale=1.0) = scale * mean(abs2, ŷ .- y)
loss = loss_fn(ŷ, y, y_nan, (scaled_loss, (scale=2.0,)))

# With both args and kwargs
complex_loss(ŷ, y, w; scale=1.0) = scale * w * mean(abs2, ŷ .- y)
loss = loss_fn(ŷ, y, y_nan, (complex_loss, (0.5,), (scale=2.0,)))

You can define additional predefined loss functions by adding more methods:

julia
import EasyHybrid: loss_fn
function EasyHybrid.loss_fn(ŷ, y, y_nan, ::Val{:nse})
    return 1 - sum((ŷ[y_nan] .- y[y_nan]).^2) / sum((y[y_nan] .- mean(y[y_nan])).^2)
end
source
EasyHybrid.make_folds Method
julia
make_folds(df::DataFrame; k::Int=5, shuffle=true) -> Vector{Int}

Assigns each observation in the DataFrame df to one of k folds for cross-validation.

Arguments

  • df::DataFrame: The input DataFrame whose rows are to be split into folds.

  • k::Int=5: Number of folds to create.

  • shuffle=true: Whether to shuffle the data before assigning folds.

Returns

  • folds::Vector{Int}: A vector of length nrow(df) where each entry is an integer in 1:k indicating the fold assignment for that observation.
source
EasyHybrid.override_config Method
julia
override_config(cfg, overrides::NamedTuple)

Return a new cfg of the same type with the fields named in overrides replaced. Works with any @kwdef struct (e.g. TrainConfig, DataConfig).

source
EasyHybrid.override_configs Method
julia
override_configs(train_cfg, data_cfg, kwargs) -> (TrainConfig, DataConfig, NamedTuple)

Return (train_cfg′, data_cfg′, solve_kwargs) where any field present in kwargs overrides the corresponding field of train_cfg/data_cfg. Fields not mentioned in kwargs are kept as-is. Anything left over is returned as solve_kwargs and forwarded to solve(...) on the Optimization.jl path (or warned about on the Optimisers.jl path — see _train).

source
EasyHybrid.prepare_data Function
julia
prepare_data(hm, data::DataFrame; array_type=:KeyedArray, drop_missing_rows=true)
prepare_data(hm, data::KeyedArray)
prepare_data(hm, data::AbstractDimArray)
prepare_data(hm, data::Tuple)

Prepare data for training by extracting predictor/forcing and target variables based on the hybrid model's configuration.

Arguments:

  • hm: The Hybrid Model

  • data: The input data, which can be a DataFrame, KeyedArray, or DimensionalData array.

  • array_type: (DataFrame only) Output array type: :KeyedArray (default) or :DimArray.

  • drop_missing_rows: (DataFrame only) If true (default), drop rows where any predictor is NaN or all targets are NaN.

Returns:

  • If data is a DataFrame: a tuple of (predictors_forcing, targets) as KeyedArrays or DimArrays depending on array_type.

  • If data is a KeyedArray: a tuple of (predictors_forcing, targets) as KeyedArrays.

  • If data is an AbstractDimArray: a tuple of (predictors_forcing, targets) as DimArrays.

  • If data is already a Tuple, it is returned as-is.

source
EasyHybrid.prepare_hidden_chain Method
julia
prepare_hidden_chain(hidden_layers, in_dim, out_dim; activation, input_batchnorm=false)

Construct a neural network Chain for use in NN models.

Arguments

  • hidden_layers::Union{Vector{Int}, Chain}:

    • If a Vector{Int}, specifies the sizes of each hidden layer. For example, [32, 16] creates two hidden layers with 32 and 16 units, respectively.

    • If a Chain, the user provides a pre-built chain of hidden layers (excluding input/output layers). If the chain ends with a Recurrence layer, a RecurrenceOutputDense layer is automatically added to handle the sequence output format.

  • in_dim::Int: Number of input features (input dimension).

  • out_dim::Int: Number of output features (output dimension).

  • activation: Activation function to use in hidden layers (default: tanh).

  • input_batchnorm::Bool: If true, applies a BatchNorm layer to the input (default: false).

Returns

  • A Chain object representing the full neural network, with the following structure:
    • Optional input batch normalization (if input_batchnorm=true)

    • Input layer: Dense(in_dim, h₁, activation) where h₁ is the first hidden size

    • Hidden layers: either user-supplied Chain or constructed from hidden_layers

    • If last hidden layer is a Recurrence, a RecurrenceOutputDense is added to handle sequence output

    • Output layer: Dense(hₖ, out_dim) where hₖ is the last hidden size

where h₁ is the first hidden size and hₖ the last.

Example with Recurrence (LSTM)

julia
# User only needs to define:
NN_Memory = Chain(
    Recurrence(LSTMCell(15 => 15), return_sequence = true),
)

# The function automatically adds the RecurrenceOutputDense layer to handle sequence output
model = constructHybridModel(..., hidden_layers = NN_Memory, ...)
source
EasyHybrid.scale_single_param Method
julia
scale_single_param(name, raw_val, parameters)

Scale a single parameter using the sigmoid scaling function.

source
EasyHybrid.scale_single_param_minmax Method
julia
scale_single_param_minmax(name, hm::AbstractHybridModel)

Scale a single parameter using the minmax scaling function.

source
EasyHybrid.select_cols Method

select_cols(df, predictors, x)

source
EasyHybrid.select_cols Method

select_cols(df::KeyedArray, predictors, x)

source
EasyHybrid.select_predictors Method

select_predictors(df, predictors)

source
EasyHybrid.select_predictors Method

select_predictors(dk::KeyedArray, predictors)

source
EasyHybrid.select_variable Method

select_variable(df::KeyedArray, x)

source
EasyHybrid.split_data Function
julia
split_data(data, hybridModel; split_by_id=nothing, shuffleobs=false, split_data_at=0.8, kwargs...)
split_data(data::Union{DataFrame, KeyedArray}, hybridModel; split_by_id=nothing, shuffleobs=false, split_data_at=0.8, folds=nothing, val_fold=nothing, kwargs...)
split_data(data::AbstractDimArray, hybridModel; split_by_id=nothing, shuffleobs=false, split_data_at=0.8, kwargs...)
split_data(data::Tuple, hybridModel; split_by_id=nothing, shuffleobs=false, split_data_at=0.8, kwargs...)
split_data(data::Tuple{Tuple, Tuple}, hybridModel; kwargs...)

Split data into training and validation sets, either randomly, by grouping by ID, or using external fold assignments.

Arguments:

  • data: The data to split, which can be a DataFrame, KeyedArray, AbstractDimArray, or Tuple

  • hybridModel: The hybrid model object used for data preparation

  • split_by_id=nothing: Either nothing for random splitting, a Symbol for column-based splitting, or an AbstractVector for custom ID-based splitting

  • shuffleobs=false: Whether to shuffle observations during splitting

  • split_data_at=0.8: Ratio of data to use for training

  • folds: Vector or column name of fold assignments (1..k), one per sample/column for k-fold cross-validation

  • val_fold: The validation fold to use when folds is provided

  • sequence_kwargs=nothing: NamedTuple of keyword arguments forwarded to split_into_sequences (e.g. (; input_window=10, output_window=1, output_shift=1, lead_time=2)). When set, data is windowed into 3D sequences before splitting.

Behavior:

  • For DataFrame/KeyedArray: Supports random splitting, ID-based splitting, and external fold assignments

  • For AbstractDimArray/Tuple: Random splitting only after data preparation

  • For pre-split Tuple{Tuple, Tuple}: Returns input unchanged

Returns:

  • ((x_train, y_train), (x_val, y_val)): Tuple containing training and validation data pairs
source
EasyHybrid.split_data Method

split_data(df::DataFrame, target, xvars, seqID; f=0.8, batchsize=32, shuffle=true, partial=true)

source
EasyHybrid.split_data Method

split_data(df::DataFrame, target, xvars; f=0.8, batchsize=32, shuffle=true, partial=true)

source
EasyHybrid.split_into_sequences Method
julia
split_into_sequences(x, y; input_window=5, output_window=1, output_shift=1, lead_time=1)

Slide a (input_window + lead_time) window over 2D (feature, time) arrays to produce 3D (feature, time, batch) tensors for sequence-to-sequence training.

Arguments:

  • x: 2D input array (feature, time), or a (x_array, forcings) Tuple from prepare_data.

  • y: 2D target array (target, time), or a NamedTuple of 1D target vectors.

  • input_window: number of input time steps per sample.

  • output_window: number of target time steps per sample.

  • output_shift: stride between consecutive samples.

  • lead_time: gap between end of input window and end of output window.

Returns:

  • (X, Y) as 3D arrays. When x is a Tuple, returns ((X, forcings_windowed), Y).
source
EasyHybrid.toDataFrame Method
julia
toDataFrame(arr, target_names)

Extract specific target variables from a labeled array into a DataFrame with _pred suffix.

Arguments

  • arr: A labeled array or NamedTuple-like object with property access

  • target_names: Vector of target variable names to extract

Returns

  • DataFrame with columns named <target>_pred for each target
source
EasyHybrid.toDataFrame Method
julia
toDataFrame(nt::NamedTuple, target_names, y_ref::NamedTuple)

Extract targets from prediction NamedTuple nt into a DataFrame with _pred suffix. When the prediction has more rows than the reference y_ref (e.g. input_window > output_window), the last output_window rows are selected before flattening.

source
EasyHybrid.toDataFrame Method
julia
toDataFrame(nt::NamedTuple)

Convert a NamedTuple of arrays (vectors or matrices) into a DataFrame. Matrix values are flattened via vec so each key becomes a single column.

source
EasyHybrid.toDataFrame Method
julia
toDataFrame(arr::Union{KeyedArray{T, 2}, AbstractDimArray{T, 2}}, cols_dim=:variable, index_dim=:batch_size; index_col=:index)

Convert a 2D labeled array (KeyedArray or DimArray) to a DataFrame.

Arguments

  • arr: The 2D labeled array to convert

  • cols_dim: Dimension name to use as DataFrame columns (default: :variable)

  • index_dim: Dimension name to use as DataFrame row index (default: :batch_size)

  • index_col: Name for the index column in the result (default: :index)

Returns

  • DataFrame with columns from cols_dim keys and an index column from index_dim keys
source
EasyHybrid.toDataFrame Method
julia
toDataFrame(arr::AbstractLabeledArray{T, 3}, cols_dim=:variable, index_dim=:batch_size; slice_dim=:time, index_col=:index)

Convert a 3D labeled array (KeyedArray or DimArray) to a Dict of DataFrames, one per slice.

Arguments

  • arr: The 3D labeled array to convert

  • cols_dim: Dimension name to use as DataFrame columns (default: :variable)

  • index_dim: Dimension name to use as DataFrame row index (default: :batch_size)

  • slice_dim: Dimension name to slice along (default: :time)

  • index_col: Name for the index column in each result DataFrame (default: :index)

Returns

  • Dict{Any, DataFrame} mapping slice keys to DataFrames
source
EasyHybrid.toNamedTuple Method

toNamedTuple(ka::KeyedArray, variable::Symbol) Extract a single variable from a KeyedArray and return it as a vector.

Arguments:

  • ka: The KeyedArray or DimArray to unpack

  • variable: Symbol representing the variable to extract

Returns:

  • Vector containing the variable data

Example:

julia
# Extract just SW_IN from an array
sw_in = toNamedTuple(ds, :SW_IN)
source
EasyHybrid.toNamedTuple Method
julia
toNamedTuple(ka::Union{KeyedArray, AbstractDimArray}, variables::Vector{Symbol})

Extract specified variables from a KeyedArray or DimArray and return them as a NamedTuple of vectors.

Arguments:

  • ka: The KeyedArray or DimArray to unpack

  • variables: Vector of symbols representing the variables to extract

Returns:

  • NamedTuple with variable names as keys and vectors as values

Example:

julia
# Extract SW_IN and TA from an array
data = toNamedTuple(ds, [:SW_IN, :TA])
sw_in = data.SW_IN
ta = data.TA
source
EasyHybrid.toNamedTuple Method

toNamedTuple(ka::KeyedArray) Extract all variables from a KeyedArray and return them as a NamedTuple of vectors.

Arguments:

  • ka: The KeyedArray to unpack

Returns:

  • NamedTuple with all variable names as keys and vectors as values

Example:

julia
# Extract all variables from an array
data = toNamedTuple(ds)
# Access individual variables
sw_in = data.SW_IN
ta = data.TA
nee = data.NEE
source
EasyHybrid.to_dimArray Method

to_dimArray(df::DataFrame)

source
EasyHybrid.to_keyedArray Function

tokeyedArray(dfg::Union{Vector,GroupedDataFrame{DataFrame}}, vars=All())

source
EasyHybrid.to_keyedArray Method

tokeyedArray(df::DataFrame)

source
EasyHybrid.train Method
julia
train(model, data; train_cfg::TrainConfig = TrainConfig(), data_cfg::DataConfig = DataConfig())
train(model, data; kwargs...)

Train a hybrid model using the provided data.

Two equivalent calling styles are supported:

  1. Typed configs — pass complete TrainConfig / DataConfig objects:
julia
train(model, data;
    train_cfg = TrainConfig(nepochs=100, batchsize=32),
    data_cfg  = DataConfig(split_data_at=0.8),
)
  1. Flat kwargs — pass TrainConfig / DataConfig field names directly:
julia
train(model, data; nepochs=100, batchsize=32, split_data_at=0.8)

The two styles can also be mixed; flat kwargs override the corresponding fields of the supplied train_cfg / data_cfg:

julia
train(model, data; train_cfg = TrainConfig(nepochs=100), nepochs = 10)  # nepochs = 10

Returns nothing if data preparation fails (zero-size dimension in training or validation data).

Arguments

  • model: The hybrid model to train.

  • data: Training data, a single DimArray, a single DataFrame, or a single KeyedArray.

Keyword Arguments

  • train_cfg: Training configuration. See TrainConfig for all options.

  • data_cfg: Data preparation configuration. See DataConfig for all options.

  • Any other kwargs are forwarded as overrides to train_cfg / data_cfg.

Returns

A TrainResults with the following fields:

  • train_losses: Per-epoch training losses.

  • val_losses: Per-epoch validation losses.

  • snapshots: Model parameter snapshots taken during training.

  • train_obs_pred: Observed vs. predicted values on the training set.

  • val_obs_pred: Observed vs. predicted values on the validation set.

  • train_diffs: Additional diagnostic variables computed on the training set.

  • val_diffs: Additional diagnostic variables computed on the validation set.

  • ps: Final (or best) model parameters.

  • st: Final (or best) model state.

  • best_epoch: Epoch at which the best validation loss was achieved.

  • best_loss: Best validation loss recorded during training.

source
EasyHybrid.tune Method
julia
tune(hybrid_model, data, mspec::ModelSpec; kwargs...)
tune(hybrid_model, data; kwargs...)
tune(hybrid_model, data, train_cfg::TrainConfig; data_cfg::DataConfig = DataConfig(), kwargs...)

Construct a new hybrid model from hybrid_model plus hyperparameters, then call train.

Returns a TrainResults (or nothing if data preparation fails, as in train).

source
EasyHybrid.weight_l2 Method
julia
weight_l2(ps; key=:weight, normalize=false) -> Real

Sum of squared Frobenius norms over all parameter arrays in ps whose immediate parent field name is key (default :weight).

With normalize=true, returns the mean squared weight (sum divided by the number of scalar weights), so the value is independent of network width/depth.

Unlike sum(abs2, extract_weights(ps)), this fuses the tree walk with the reduction so it is safe to use inside Zygote-differentiated losses, e.g.:

julia
extra_loss = (ŷ, ps) -> (; l2_Rb = λ * weight_l2(ps.Rb; normalize=true),)

When ps is the loss function argument, gradients flow into the weight arrays.

source
EasyHybrid.@hybrid Macro
julia
@hybrid ModelName α β γ

Macro to define hybrid model structs with arbitrary numbers of physical parameters.

This defines a struct with:

  • Default fields: NN (neural network), predictors, forcing, targets.

  • Additional physical parameters, i.e., α β γ.

Examples

julia
@hybrid MyModel α β γ
@hybrid FluidModel (:viscosity, :density)
@hybrid SimpleModel :a :b
source