Skip to content

Building Models Examples ​

EasyHybrid.jl allows constructing diverse modeling architectures using the unified HybridModel struct. Previously, users defined bespoke structs (like LinearHM, RespirationRbQ10) for different configurations. Here we demonstrate how those legacy model architectures can be trivially constructed via HybridModel.

Setup ​

First, let's load our required packages:

julia
using EasyHybrid

1. Linear Hybrid Model ​

This is a basic model with one neural network predicting a coefficient α, and an explicit global parameter β. The equation is: ŷ = α * x + β

Process-Based Definition ​

julia
linear_mechanistic(; x, α, β) = (; obs = α .* x .+ β)
linear_mechanistic (generic function with 1 method)

Parameter Setup ​

julia
params_linear = (
    α = (1.0f0, 0.0f0, 2.0f0),
    β = (1.5f0, -1.0f0, 3.0f0),
)
(α = (1.0f0, 0.0f0, 2.0f0), β = (1.5f0, -1.0f0, 3.0f0))

HybridModel Construction ​

We use x as forcing data, predict α with a neural network based on predictors a and b, and leave β as a globally optimized constant parameter.

We can construct this model either with the declarative @hybrid macro:

julia
lhm = @hybrid begin
    mechanistic = linear_mechanistic
    targets = :obs
    forcing = :x
    neural = [:a, :b] => :α
    parameters = params_linear
    hidden_layers = [4, 4]
    activation = tanh
end
Hybrid Model (Single NN)
Neural Network: 
  Chain(
      layer_1 = WrappedFunction(identity),
      layer_2 = Dense(2 => 4, tanh),                # 12 parameters
      layer_3 = Dense(4 => 4, tanh),                # 20 parameters
      layer_4 = Dense(4 => 1),                      # 5 parameters
  )         # Total: 37 parameters,
            #        plus 0 states.
Configuration:
  mechanistic_model = linear_mechanistic
  targets = [:obs]
  forcing = [:x]
  neural_param_names = [:α]
  predictors = [:a, :b]
  global_param_names = [:β]
  fixed_param_names = Symbol[]
  scale_nn_outputs = false
  start_from_default = true
  config = (; hidden_layers = [4, 4], activation = tanh, scale_nn_outputs = false, input_batchnorm = false, start_from_default = true,)

Parameters:
  ┌───┬────────┬───────┬───────┬────────┐
  │   │  value │ lower │ upper │  scale │
  ├───┼────────┼───────┼───────┼────────┤
  │ α │     NN │   0.0 │   2.0 │ linear │
  │ β │ Global │  -1.0 │   3.0 │ linear │
  └───┴────────┴───────┴───────┴────────┘
  value: number = fixed; NN/Global = estimated; Forcing = overridden by forcing data

Or with the functional constructHybridModel following the physics-first argument order:

julia
lhm = constructHybridModel(
    linear_mechanistic, # mechanistic model
    :obs,               # targets
    :x,                 # forcing variable
    [:a, :b] => :α,     # neural mapping (predictors => predicted parameter)
    params_linear;      # parameter container (global parameter β is auto-inferred)
    hidden_layers = [4, 4],
    activation = tanh
)
Hybrid Model (Single NN)
Neural Network: 
  Chain(
      layer_1 = WrappedFunction(identity),
      layer_2 = Dense(2 => 4, tanh),                # 12 parameters
      layer_3 = Dense(4 => 4, tanh),                # 20 parameters
      layer_4 = Dense(4 => 1),                      # 5 parameters
  )         # Total: 37 parameters,
            #        plus 0 states.
Configuration:
  mechanistic_model = linear_mechanistic
  targets = [:obs]
  forcing = [:x]
  neural_param_names = [:α]
  predictors = [:a, :b]
  global_param_names = [:β]
  fixed_param_names = Symbol[]
  scale_nn_outputs = false
  start_from_default = true
  config = (; hidden_layers = [4, 4], activation = tanh, scale_nn_outputs = false, input_batchnorm = false, start_from_default = true,)

Parameters:
  ┌───┬────────┬───────┬───────┬────────┐
  │   │  value │ lower │ upper │  scale │
  ├───┼────────┼───────┼───────┼────────┤
  │ α │     NN │   0.0 │   2.0 │ linear │
  │ β │ Global │  -1.0 │   3.0 │ linear │
  └───┴────────┴───────┴───────┴────────┘
  value: number = fixed; NN/Global = estimated; Forcing = overridden by forcing data

2. Respiration Rb Q10 ​

A single NN predicting Rb for a Q10 temperature-sensitive respiration formulation. The equation is: R_soil = Rb * Q10^(0.1 * (Temp - 15))

Process-Based Definition ​

julia
function mRbQ10(; Temp, Rb, Q10)
    R_soil = @. Rb * Q10^(0.1f0 * (Temp - 15.0f0))
    return (; R_soil)
end
mRbQ10 (generic function with 1 method)

Parameter Setup ​

julia
params_rbq10 = (
    Rb = (1.0f0, 0.0f0, 5.0f0),
    Q10 = (1.5f0, 1.0f0, 3.0f0),
)
(Rb = (1.0f0, 0.0f0, 5.0f0), Q10 = (1.5f0, 1.0f0, 3.0f0))

HybridModel Construction ​

julia
m_rbq10 = @hybrid begin
    mechanistic = mRbQ10
    targets = :R_soil
    forcing = [:Temp]
    neural = [:SWC, :TA] => :Rb
    parameters = params_rbq10
    hidden_layers = [8, 8]
end
Hybrid Model (Single NN)
Neural Network: 
  Chain(
      layer_1 = WrappedFunction(identity),
      layer_2 = Dense(2 => 8, tanh),                # 24 parameters
      layer_3 = Dense(8 => 8, tanh),                # 72 parameters
      layer_4 = Dense(8 => 1),                      # 9 parameters
  )         # Total: 105 parameters,
            #        plus 0 states.
Configuration:
  mechanistic_model = mRbQ10
  targets = [:R_soil]
  forcing = [:Temp]
  neural_param_names = [:Rb]
  predictors = [:SWC, :TA]
  global_param_names = [:Q10]
  fixed_param_names = Symbol[]
  scale_nn_outputs = false
  start_from_default = true
  config = (; hidden_layers = [8, 8], activation = tanh, scale_nn_outputs = false, input_batchnorm = false, start_from_default = true,)

Parameters:
  ┌─────┬────────┬───────┬───────┬────────┐
  │     │  value │ lower │ upper │  scale │
  ├─────┼────────┼───────┼───────┼────────┤
  │  Rb │     NN │   0.0 │   5.0 │ linear │
  │ Q10 │ Global │   1.0 │   3.0 │ linear │
  └─────┴────────┴───────┴───────┴────────┘
  value: number = fixed; NN/Global = estimated; Forcing = overridden by forcing data

3. Respiration Components ​

A single NN outputting 3 distinct parameters (Rb_het, Rb_root, Rb_myc).

Process-Based Definition ​

julia
function rs_comp(; Temp, Rb_het, Rb_root, Rb_myc, Q10_het, Q10_root, Q10_myc)
    R_het = @. Rb_het * Q10_het^(0.1f0 * (Temp - 15.0f0))
    R_root = @. Rb_root * Q10_root^(0.1f0 * (Temp - 15.0f0))
    R_myc = @. Rb_myc * Q10_myc^(0.1f0 * (Temp - 15.0f0))
    R_soil = R_het .+ R_root .+ R_myc
    return (; R_soil, R_het, R_root, R_myc)
end
rs_comp (generic function with 1 method)

Parameter Setup ​

julia
params_rs_comp = (
    Rb_het = (1.0f0, 0.0f0, 5.0f0),
    Rb_root = (1.0f0, 0.0f0, 5.0f0),
    Rb_myc = (1.0f0, 0.0f0, 5.0f0),
    Q10_het = (1.5f0, 1.0f0, 3.0f0),
    Q10_root = (1.5f0, 1.0f0, 3.0f0),
    Q10_myc = (1.5f0, 1.0f0, 3.0f0),
)
(Rb_het = (1.0f0, 0.0f0, 5.0f0), Rb_root = (1.0f0, 0.0f0, 5.0f0), Rb_myc = (1.0f0, 0.0f0, 5.0f0), Q10_het = (1.5f0, 1.0f0, 3.0f0), Q10_root = (1.5f0, 1.0f0, 3.0f0), Q10_myc = (1.5f0, 1.0f0, 3.0f0))

HybridModel Construction ​

julia
m_rs_comp = @hybrid begin
    mechanistic = rs_comp
    targets = :R_soil
    forcing = [:Temp]
    neural = [:SWC, :TA] => [:Rb_het, :Rb_root, :Rb_myc]
    parameters = params_rs_comp
    hidden_layers = [16, 16]
end
Hybrid Model (Single NN)
Neural Network: 
  Chain(
      layer_1 = WrappedFunction(identity),
      layer_2 = Dense(2 => 16, tanh),               # 48 parameters
      layer_3 = Dense(16 => 16, tanh),              # 272 parameters
      layer_4 = Dense(16 => 3),                     # 51 parameters
  )         # Total: 371 parameters,
            #        plus 0 states.
Configuration:
  mechanistic_model = rs_comp
  targets = [:R_soil]
  forcing = [:Temp]
  neural_param_names = [:Rb_het, :Rb_root, :Rb_myc]
  predictors = [:SWC, :TA]
  global_param_names = [:Q10_het, :Q10_root, :Q10_myc]
  fixed_param_names = Symbol[]
  scale_nn_outputs = false
  start_from_default = true
  config = (; hidden_layers = [16, 16], activation = tanh, scale_nn_outputs = false, input_batchnorm = false, start_from_default = true,)

Parameters:
  ┌──────────┬────────┬───────┬───────┬────────┐
  │          │  value │ lower │ upper │  scale │
  ├──────────┼────────┼───────┼───────┼────────┤
  │   Rb_het │     NN │   0.0 │   5.0 │ linear │
  │  Rb_root │     NN │   0.0 │   5.0 │ linear │
  │   Rb_myc │     NN │   0.0 │   5.0 │ linear │
  │  Q10_het │ Global │   1.0 │   3.0 │ linear │
  │ Q10_root │ Global │   1.0 │   3.0 │ linear │
  │  Q10_myc │ Global │   1.0 │   3.0 │ linear │
  └──────────┴────────┴───────┴───────┴────────┘
  value: number = fixed; NN/Global = estimated; Forcing = overridden by forcing data

4. Flux Partitioning with Multiple NNs ​

A multi-NN architecture predicting RUE (Radiation Use Efficiency) and Rb from different sets of predictors.

Process-Based Definition ​

julia
function flux_part(; SW_IN, TA, RUE, Rb, Q10)
    GPP = @. SW_IN * RUE / 12.011f0
    RECO = @. Rb * Q10^(0.1f0 * (TA - 15.0f0))
    NEE = RECO .- GPP
    return (; NEE, GPP, RECO)
end
flux_part (generic function with 1 method)

Parameter Setup ​

julia
params_flux = (
    RUE = (1.0f0, 0.0f0, 5.0f0),
    Rb = (1.0f0, 0.0f0, 5.0f0),
    Q10 = (1.5f0, 1.0f0, 3.0f0),
)
(RUE = (1.0f0, 0.0f0, 5.0f0), Rb = (1.0f0, 0.0f0, 5.0f0), Q10 = (1.5f0, 1.0f0, 3.0f0))

HybridModel Construction ​

By passing a NamedTuple to neural/predictors, HybridModel automatically provisions an independent Neural Network for each key.

julia
m_flux = @hybrid begin
    mechanistic = flux_part
    targets = [:NEE]
    forcing = [:SW_IN, :TA]
    neural = (
        RUE = [:SWC, :TA, :SW_IN],
        Rb = [:SWC, :TA],
    )
    parameters = params_flux
    hidden_layers = (RUE = [8, 8], Rb = [4, 4])
    activation = (RUE = Lux.sigmoid, Rb = tanh)
end
Hybrid Model (Multi NN)
Neural Networks:
  RUE:
    Chain(
        layer_1 = WrappedFunction(identity),
        layer_2 = Dense(3 => 8, σ),                   # 32 parameters
        layer_3 = Dense(8 => 8, σ),                   # 72 parameters
        layer_4 = Dense(8 => 1),                      # 9 parameters
    )         # Total: 113 parameters,
              #        plus 0 states.
  Rb:
    Chain(
        layer_1 = WrappedFunction(identity),
        layer_2 = Dense(2 => 4, tanh),                # 12 parameters
        layer_3 = Dense(4 => 4, tanh),                # 20 parameters
        layer_4 = Dense(4 => 1),                      # 5 parameters
    )         # Total: 37 parameters,
              #        plus 0 states.
Configuration:
  mechanistic_model = flux_part
  targets = [:NEE]
  forcing = [:SW_IN, :TA]
  neural_param_names = [:RUE, :Rb]
  predictors:
    RUE = [:SWC, :TA, :SW_IN]
    Rb = [:SWC, :TA]
  global_param_names = [:Q10]
  fixed_param_names = Symbol[]
  scale_nn_outputs = false
  start_from_default = true
  config = (; hidden_layers = (RUE = [8, 8], Rb = [4, 4]), activation = (RUE = NNlib.σ, Rb = tanh), scale_nn_outputs = false, input_batchnorm = false, start_from_default = true,)

Parameters:
  ┌─────┬────────┬───────┬───────┬────────┐
  │     │  value │ lower │ upper │  scale │
  ├─────┼────────┼───────┼───────┼────────┤
  │ RUE │     NN │   0.0 │   5.0 │ linear │
  │  Rb │     NN │   0.0 │   5.0 │ linear │
  │ Q10 │ Global │   1.0 │   3.0 │ linear │
  └─────┴────────┴───────┴───────┴────────┘
  value: number = fixed; NN/Global = estimated; Forcing = overridden by forcing data

5. Process-Based Model (Zero NNs) ​

A purely process-based configuration where all parameters are optimized globally, and no Neural Networks are built.

Process-Based Definition ​

julia
function mRbQ10_0(; Temp, Rb, Q10)
    R_soil = @. Rb * Q10^(0.1f0 * (Temp - 0.0f0))
    return (; R_soil)
end
mRbQ10_0 (generic function with 1 method)

HybridModel Construction ​

Setting neural = nothing (or passing nothing in constructHybridModel) prevents any Neural Networks from being created.

julia
m_pbm = @hybrid begin
    mechanistic = mRbQ10_0
    targets = [:R_soil]
    forcing = [:Temp]
    neural = nothing
    parameters = params_rbq10
end
Hybrid Model (Zero NN / Process-Based)

Configuration:
  mechanistic_model = mRbQ10_0
  targets = [:R_soil]
  forcing = [:Temp]
  global_param_names = [:Rb, :Q10]
  fixed_param_names = Symbol[]
  start_from_default = true
  config = (; hidden_layers = [32, 32], activation = tanh, scale_nn_outputs = false, input_batchnorm = false, start_from_default = true,)

Parameters:
  ┌─────┬────────┬───────┬───────┬────────┐
  │     │  value │ lower │ upper │  scale │
  ├─────┼────────┼───────┼───────┼────────┤
  │  Rb │ Global │   0.0 │   5.0 │ linear │
  │ Q10 │ Global │   1.0 │   3.0 │ linear │
  └─────┴────────┴───────┴───────┴────────┘
  value: number = fixed; NN/Global = estimated; Forcing = overridden by forcing data

6. Per-Parameter Scaling (:linear, :log, :logit) ​

Every optimizable parameter is mapped from an unconstrained value into its bounds [lower, upper] via a monotone warp. By default this warp is :linear (uniform resolution in the value). You can select a different warp per parameter by appending a 4th element to its tuple:

  • :linear — default; good for narrow, well-behaved ranges.

  • :log — uniform resolution in log(value); ideal for strictly-positive quantities spanning several orders of magnitude (rates, turnover times, observation-noise scales). Requires lower > 0.

  • :logit — uniform resolution in the log-odds; ideal for fractions that can approach 0 and/or 1. Requires 0 < lower < upper < 1.

Process-Based Definition ​

A minimal decomposition model: an NN predicts the carbon-use efficiency CUE (a fraction), while a basal rate k and observation-noise scale σ (both spanning orders of magnitude) are optimized globally.

julia
decomp(; Corg, k, CUE, σ = nothing) = (; flux = k .* Corg .* (1.0f0 .- CUE))
decomp (generic function with 1 method)

Parameter Setup ​

Note the optional 4th tuple element selecting the warp. CUE keeps the default :linear (already logit-space for the optimizer over an interior range), k and σ use :log.

julia
params_scaled = (
    k = (0.01f0, 1.0f-4, 1.0f0, :log),    # rate over ~4 orders of magnitude
    CUE = (0.5f0, 0.05f0, 0.65f0),          # interior fraction -> :linear
    σ = (1.0f0, 0.01f0, 100.0f0, :log),   # obs-noise scale, stays > 0
)
(k = (0.01f0, 0.0001f0, 1.0f0, :log), CUE = (0.5f0, 0.05f0, 0.65f0), σ = (1.0f0, 0.01f0, 100.0f0, :log))

HybridModel Construction ​

julia
m_scaled = @hybrid begin
    mechanistic = decomp
    targets = :flux
    forcing = [:Corg]
    neural = [:SWC, :TA] => :CUE
    parameters = params_scaled
    hidden_layers = [8, 8]
    scale_nn_outputs = true
end
Hybrid Model (Single NN)
Neural Network: 
  Chain(
      layer_1 = WrappedFunction(identity),
      layer_2 = Dense(2 => 8, tanh),                # 24 parameters
      layer_3 = Dense(8 => 8, tanh),                # 72 parameters
      layer_4 = Dense(8 => 1),                      # 9 parameters
  )         # Total: 105 parameters,
            #        plus 0 states.
Configuration:
  mechanistic_model = decomp
  targets = [:flux]
  forcing = [:Corg]
  neural_param_names = [:CUE]
  predictors = [:SWC, :TA]
  global_param_names = [:k, :σ]
  fixed_param_names = Symbol[]
  scale_nn_outputs = true
  start_from_default = true
  config = (; hidden_layers = [8, 8], activation = tanh, scale_nn_outputs = true, input_batchnorm = false, start_from_default = true,)

Parameters:
  ┌─────┬────────┬────────┬───────┬────────┐
  │     │  value │  lower │ upper │  scale │
  ├─────┼────────┼────────┼───────┼────────┤
  │   k │ Global │ 0.0001 │   1.0 │    log │
  │ CUE │     NN │   0.05 │  0.65 │ linear │
  │   σ │ Global │   0.01 │ 100.0 │    log │
  └─────┴────────┴────────┴───────┴────────┘
  value: number = fixed; NN/Global = estimated; Forcing = overridden by forcing data

The chosen warp is recorded per parameter and used for both initialization and the forward pass; nothing else in your training code needs to change.

julia
m_scaled.parameters.scales
(k = :log, CUE = :linear, σ = :log)

Summary ​

As demonstrated above, HybridModel provides a highly flexible, unified interface. By simply modifying the predictors argument and your mechanistic function, you can rapidly scale from a purely process-based model, to a single Neural Network hybrid model, all the way up to complex multi-Neural Network architectures! And with the optional per-parameter warp (:linear, :log, :logit), each parameter is optimized on the scale that best matches its physical range.