API

This section documents the public ApplicationDrivenLearning API.

Functions and types whose name starts with an underscore are internal implementation details: they are not part of the public API, are not listed here, and may change without a breaking release.

Constructors

ApplicationDrivenLearning.Model — Type
Model <: JuMP.AbstractModel

Create an empty ApplicationDrivenLearning.Model with empty plan and assess models, missing forecast model and default settings.

Besides the Policy and Forecast variable pairs, the model keeps the plan-side and assess-side variables split into their own concretely typed vectors. Those are what the per-sample cost evaluation actually iterates over, so they are maintained as variables are declared rather than rebuilt on every access.

source
ApplicationDrivenLearning.ForecastModel — Type
ForecastModel(; inputs = nothing, architecture, outputs)

One forecast model: an architecture, the input columns it reads and the Forecast variables it predicts.

A forecast is described by a vector of these, handed to set_forecast_model, which assembles them into the FullForecastModel stored on the Model. Everything in a unit is about the same one architecture, so there are no parallel vectors to keep aligned and no map iteration order to know.

Arguments

  • inputs: the input columns this architecture reads, as column positions ([1, 3]) or column names ([:temp, :hour]). A scalar is accepted for a one-input architecture. nothing means "the whole input row", which is only resolvable when the row width is known — either because this is the only unit, or because another unit selects by name and so fixes the schema. Writing the names here is what declares the input schema; there is no separate place to declare it.

  • architecture: anything callable on a (features x samples) matrix — a Flux.Dense, a Flux.Chain, or your own layer. It is not copied here, so that reusing one object across units can be reported as an error; it is deep-copied when the units are assembled, so training never touches the caller's object.

  • outputs: the Forecast variables this architecture predicts, in the order it produces them. Say where a variable's realized values live in Y by writing variable => :column for a named container, or variable => position for a matrix; either way the answer sits next to its variable, so there is no second ordering to line up. Left unsaid, a variable reads the Y column carrying its own declared name.

    A name and a position are two spellings of one thing, so all units of a forecast use one or the other. Positions are the only way to pin the layout of a matrix Y: without them its columns are read in the order the Forecast variables were declared on the Model, which nothing states and nothing checks.

All units of one forecast must select their inputs the same way — all by position or all by name — since a mix leaves the input schema undefined.

Examples

One network reading two named columns:

model = ApplicationDrivenLearning.Model()
@variable(model, demand, ApplicationDrivenLearning.Forecast)

set_forecast_model(
    model,
    ForecastModel(
        inputs = [:temp, :hour],
        architecture = Flux.Dense(2 => 1),
        outputs = [demand],
    ),
)

One network per forecast variable, each with its own parameters, naming the Y columns because the variables are called load[1] and load[2]:

@variable(model, load[1:2], ApplicationDrivenLearning.Forecast)

set_forecast_model(
    model,
    [
        ForecastModel(
            inputs = :temp,
            architecture = Flux.Dense(1 => 1),
            outputs = [load[1] => :load_a],
        ),
        ForecastModel(
            inputs = :wind,
            architecture = Flux.Dense(1 => 1),
            outputs = [load[2] => :load_b],
        ),
    ],
)

To reuse one architecture for several forecasts, give each unit its own copy — deepcopy(nn), or a function returning a fresh network. Passing the same object to two units is an error rather than a silent choice between "same shape" and "same weights".

source
ApplicationDrivenLearning.FullForecastModel — Type
FullForecastModel(units, output_variables, input_size, output_size; input_names, output_columns, sample_key)

The assembled forecast of a Model: every architecture, the input columns each reads, the Forecast variables each predicts, and the schema of the data containers.

Built by set_forecast_model from a vector of ForecastModel units, which is how it should be constructed — this explicit form exists because the fields are what the prediction loop, the optimizers and the parallel backends work on, and because Functors rebuilds the struct positionally.

Arguments

  • units: the ResolvedUnits, one per ForecastModel the forecast was assembled from. Each carries its own architecture, the input columns it reads and the prediction rows it writes, so there are no parallel vectors to keep in step. The architectures are deep-copied when the units are assembled, so the caller's objects are left untouched.
  • output_variables: the forecast variables in the order the model produces them, i.e. the row order of a prediction. Every row is written by exactly one unit, which is checked here.
  • input_size::Int: number of columns the input has.
  • output_size::Int: number of forecast variables.
  • input_names::Union{Vector{Symbol},Nothing}: name of each input column, in the order the model expects them. This is the model's declared input schema: it is what lets a table input be matched by name instead of by column order. nothing means the model declares no schema, and a table input is then rejected rather than guessed at.
  • output_columns::Union{Vector{Symbol},Vector{Int},Nothing}: where in Y each variable of output_variables is, in that same order — as table column names, or as column positions in a matrix. nothing means the declared names of the Forecast variables themselves are used. Assembled from the variable => :column and variable => position pairs of the units, which is also why only one of the two spellings can be present.
  • sample_key::Union{Symbol,Nothing}: name of the column that identifies a row, the way input_names identifies a column — a timestamp or an id. When both X and Y are tables carrying it, the realized values are looked up by key for each row of X instead of being trusted to arrive in the same order. nothing means rows are matched by position.
source
ApplicationDrivenLearning.Plan — Function
Plan(model::ApplicationDrivenLearning.Model)

Return the inner JuMP.Model used for planning, i.e. the problem solved with the predictive model output in place of the Forecast variables. Use it as the target of the @variable, @constraint and @objective macros.

source
ApplicationDrivenLearning.Assess — Function
Assess(model::ApplicationDrivenLearning.Model)

Return the inner JuMP.Model used for assessment, i.e. the problem solved with the realized values in place of the Forecast variables and the Policy variables fixed to the plan decision. Use it as the target of the @variable, @constraint and @objective macros.

source

JuMP variable types

Structs

ApplicationDrivenLearning.Options — Type
Options(mode; params...)

Options struct to hold optimization mode and mode parameters.

mode must be a subtype of AbstractOptimizationMode (the type itself, not an instance); the accepted keyword arguments are documented on each mode.

...

Example

options = Options(
    GradientMode;
    rule = Flux.RMSProp(0.01),
    epochs = 100,
    batch_size = 10,
)

...

source
ApplicationDrivenLearning.Solution — Type
Solution{C<:Real,P<:Real}

Result of a train! call.

The cost and the parameters are stored with their own concrete element types, which usually differ: costs come from the solver and are Float64, while parameters follow the precision of the Flux model (often Float32).

...

Fields

  • cost::C: best assessed cost found during training, averaged over the training samples.
  • params::Vector{P}: predictive model parameters that achieve cost, flattened in the order used by extract_params. The model itself is also updated in place with these values. ...
source

Modes

ApplicationDrivenLearning.OptimMode — Type
OptimMode <: AbstractOptimizationMode

Used to solve the application driven learning training problem with any algorithm from the Optim.jl package.

The objective handed to Optim is the assessed cost as a function of the flat parameter vector of the predictive model. Derivative-free algorithms need nothing further; gradient-based ones are given dC/dθ automatically, obtained by carrying the same reverse pass the GradientMode loop uses one step further, from the forecasts to the parameters.

...

Parameters

  • algorithm is the Optim.jl algorithm object to use. Defaults to Optim.NelderMead(). Anything Optim provides works, for example Optim.ParticleSwarm(), Optim.SimulatedAnnealing(), Optim.LBFGS(), Optim.BFGS() or Optim.ConjugateGradient().
  • parallel is the parallel backend used for the per-sample cost and gradient evaluations. Defaults to SerialBackend(); pass MPIBackend() to distribute them across MPI processes, or DistributedBackend() across Distributed worker processes. See AbstractParallelBackend.
  • Any other parameter accepted by Optim.Options, such as iterations, time_limit, show_trace or g_abstol. ...

Example

options =
    Options(OptimMode; algorithm = Optim.ParticleSwarm(), iterations = 500)
source
ApplicationDrivenLearning.NLoptMode — Type
NLoptMode <: AbstractOptimizationMode

Used to solve the application driven learning training problem with an algorithm from the NLopt.jl package.

Note

Provided by a package extension: using NLopt is required before training with this mode. The mode type itself always exists, so that a missing using produces a readable error rather than an UndefVarError.

...

Parameters

  • algorithm::Symbol is the NLopt algorithm to use. Defaults to :LN_NELDERMEAD. The derivative-free families are :LN_* (local, e.g. :LN_BOBYQA, :LN_COBYLA) and :GN_* (global, e.g. :GN_DIRECT); the :LD_* family is gradient-based and is given dC/dθ automatically.
  • Any other parameter is set on the NLopt Opt object, so the stopping criteria are named as NLopt names them: xtol_rel, xtol_abs, ftol_rel, ftol_abs, maxeval, maxtime, stopval. Note this differs from OptimMode, where such keywords go to Optim.Options instead.
  • parallel is the parallel backend used for the per-sample cost and gradient evaluations. Defaults to SerialBackend(); pass MPIBackend() to distribute them across MPI processes, or DistributedBackend() across Distributed worker processes. See AbstractParallelBackend.

...

Example

using NLopt

options =
    Options(NLoptMode; algorithm = :LN_BOBYQA, xtol_rel = 1e-6, maxeval = 500)
source
ApplicationDrivenLearning.GradientMode — Type
GradientMode <: AbstractOptimizationMode

Used to solve the application driven learning training problem using the gradient optimization method

...

Parameters

  • rule is the Flux/Optimisers rule to be used in the gradient optimization process. Defaults to Flux.Descent().
  • epochs is the number of epochs to be used in the gradient optimization process. Defaults to 100.
  • batch_size is the batch size to be used in the gradient optimization process. Defaults to -1, meaning the full dataset is used at every epoch. When positive, each epoch draws batch_size sample indexes uniformly with replacement, so a batch may repeat samples and an epoch does not sweep the whole dataset.
  • verbose is the flag of whether to print the training process. Defaults to true.
  • compute_cost_every is the epoch frequency for computing the cost and evaluating best solution. Defaults to 1.
  • time_limit is the time limit for the training process, in seconds. Defaults to Inf.
  • g_tol is the tolerance on the infinity norm of the cost gradients with respect to the forecasts, below which training stops. Defaults to 0, which disables the check.
  • parallel is the parallel backend used for the per-sample cost and gradient evaluations. Defaults to SerialBackend(); pass MPIBackend() to distribute them across MPI processes, or DistributedBackend() across Distributed worker processes. See AbstractParallelBackend. ...
source
ApplicationDrivenLearning.BilevelMode — Type
BilevelMode <: AbstractOptimizationMode

Used to solve the application driven learning training problem as a bilevel optimization problem by using the BilevelJuMP.jl package.

...

Parameters

  • optimizer::Function is equivalent to solver in BilevelJuMP.BilevelModel.
  • silent::Bool is equivalent to silent in BilevelJuMP.BilevelModel.
  • mode::Union{Nothing, BilevelJuMP.BilevelMode} is equivalent to mode in BilevelJuMP.BilevelModel. ...
source

Deprecated Modes

ApplicationDrivenLearning.NelderMeadMode — Type
NelderMeadMode <: AbstractOptimizationMode

Used to solve the application driven learning training problem using the Nelder-Mead optimization method implementation from Optim.jl package.

Deprecated

Superseded by OptimMode, which reaches every Optim.jl algorithm rather than just this one. NelderMeadMode behaves exactly as before and is kept so that existing code keeps working; new code should prefer

Options(OptimMode; algorithm = Optim.NelderMead())

Note that initial_simplex and parameters are Nelder-Mead specific, so under OptimMode they move into the algorithm object: Optim.NelderMead(initial_simplex = ...).

...

Parameters

  • initial_simplex is the initial simplex of solutions to be applied.
  • parameters is the parameters to be applied to the Nelder-Mead optimization method. ...
source
ApplicationDrivenLearning.NelderMeadMPIMode — Type
NelderMeadMPIMode <: AbstractOptimizationMode

MPI implementation of NelderMeadMode, which distributes the per-sample cost evaluations across MPI processes.

Deprecated

Parallelism is now an option rather than a mode, so any optimizer can be run over MPI rather than just Nelder-Mead:

Options(OptimMode; algorithm = Optim.NelderMead(), parallel = MPIBackend())

This mode keeps working and forwards to exactly that, translating mpi_finalize onto the backend.

...

Parameters

  • mpi_finalize::Bool controls whether MPI.Finalize() is called at the end of training. Defaults to true.
  • Any other parameter accepted by Optim.Options. ...
source
ApplicationDrivenLearning.GradientMPIMode — Type
GradientMPIMode <: AbstractOptimizationMode

MPI implementation of GradientMode, which distributes the per-sample cost and gradient evaluations across MPI processes.

Deprecated

Parallelism is now an option rather than a mode:

Options(GradientMode; parallel = MPIBackend())

This mode keeps working and forwards to exactly that, translating mpi_finalize onto the backend.

...

Parameters

  • The same parameters as GradientMode.
  • mpi_finalize::Bool controls whether MPI.Finalize() is called at the end of training. Defaults to true. ...
source

Parallel backends

ApplicationDrivenLearning.AbstractParallelBackend — Type
AbstractParallelBackend

How the per-sample work of a training run is distributed.

Every setting of a backend lives on the backend object. The one exception is mpi_finalize, which predates this design and is reachable only through the deprecated *MPIMode aliases.

BilevelMode is deliberately outside this: it builds a single MIP over all samples and never evaluates a per-sample cost, so it has nothing to distribute.

Implementations: SerialBackend, MPIBackend, DistributedBackend.

source
ApplicationDrivenLearning.MPIBackend — Type
MPIBackend(; finalize = true) <: AbstractParallelBackend

Distribute the per-sample cost and gradient evaluations across MPI processes with JobQueueMPI.jl.

Every rank runs the same script and so builds its own copy of the model; the controller then drives the optimizer while the workers serve evaluations. Only the controller returns a meaningful Solution.

finalize controls whether MPI.Finalize() is called when training ends. Set it to false when several train! calls share one MPI session.

source
ApplicationDrivenLearning.DistributedBackend — Type
DistributedBackend(; workers = nothing, verify = true) <: AbstractParallelBackend

Distribute the per-sample cost and gradient evaluations across Distributed worker processes.

Unlike MPIBackend, where every rank runs the same script and so builds its own model, here only the driver runs train! — so the workers need a way to obtain a model of their own. That is why this backend must be used with the builder form of train!:

sol = train!(
    build_case,
    X,
    Y,
    Options(GradientMode; parallel = DistributedBackend()),
)

Passing a Model instead is an error. The builder is called once per worker per train!, never per sample, and must be defined on the workers (@everywhere, or in a package they load).

It must also construct the model rather than close over one: an ApplicationDrivenLearning.Model that has had set_optimizer called on it cannot be serialized at all — the attempt is a ReadOnlyMemoryError that takes the process down, since the solver's C pointers travel as raw addresses.

Keyword arguments

  • workers: the worker ids to use. Defaults to Distributed.workers() read when training starts, so calling addprocs after building the Options still works.

  • verify: check at start-up that the workers' models match the driver's. Their sizes are always compared, which is free; verify additionally compares the assessed cost of one sample, which costs one extra pair of solves on the driver.

    Since the driver and the workers now build from the same function, this guards a narrower thing than it once did: a builder that is not deterministic — one closing over mutable global state, or whose structure varies per process. Cheap enough to leave on.

Example

using Distributed
addprocs(4)
@everywhere using ApplicationDrivenLearning, HiGHS, Flux
@everywhere function build_case()
    m = ApplicationDrivenLearning.Model()
    @variable(m, demand, ApplicationDrivenLearning.Forecast)
    # ... policy variables, constraints, objectives ...
    set_optimizer(m, HiGHS.Optimizer)
    ApplicationDrivenLearning.set_forecast_model(
        m,
        ApplicationDrivenLearning.ForecastModel(
            architecture = Dense(1 => 1),
            outputs = [demand],
        ),
    )
    return m
end

sol = train!(
    build_case,
    X,
    Y,
    Options(GradientMode; parallel = DistributedBackend()),
)
source

Attributes getters and setters

ApplicationDrivenLearning.set_forecast_model — Function
set_forecast_model(model::Model, units; sample_key = nothing)

Attach a forecast to model, described by ForecastModel units.

units is one ForecastModel or a vector of them. Between them they must predict every Forecast variable declared on model, each exactly once and none belonging to another model — all three are checked here, naming whatever is wrong.

model = ApplicationDrivenLearning.Model()
@variable(model, demand, ApplicationDrivenLearning.Forecast)

set_forecast_model(
    model,
    ForecastModel(
        inputs = [:temp, :hour],
        architecture = Flux.Dense(2 => 1),
        outputs = [demand],
    ),
)

Everything about the data is declared on the units: writing a unit's inputs as column names is what lets X be matched by name, and writing its outputs as variable => :column names the Y column holding a variable.

sample_key is the one thing that cannot live on a unit, and the only keyword here: it names the column that identifies a row rather than a column, so that realized values are looked up per sample instead of trusted to arrive in the same order as the input.

The units' Forecast variables are reordered to follow model.forecast_vars, so that the rows of a prediction line up with the forecast parameters of the plan model, and their column names follow them.

Returns the stored FullForecastModel.

source
ApplicationDrivenLearning.apply_gradient! — Function
apply_gradient!(model, dCdy, X, opt_state)

Apply per-sample cost gradients to the model parameters.

The optimization layer provides dCdy, the gradient of the assessment cost with respect to the forecasts, for each sample. Because the optimization step itself is not differentiable by the AD backend, these gradients are propagated through the forecast model with a linear surrogate loss whose parameter-gradient equals the chain-rule term (1/T) Σₜ (dC/dŷₜ)·(dŷₜ/dθ).

...

Arguments

  • model::FullForecastModel: model to be updated.
  • dCdy::AbstractMatrix{<:Real}: per-sample cost gradients, size (T, output_size), with row t aligned to sample t (row t of X).
  • X::AbstractMatrix{<:Real}: input data, size (T, input_size).
  • opt_state: Optimisers optimisation state. ...
source

Other functions

ApplicationDrivenLearning.compute_cost — Function
compute_cost(model, X, Y, with_gradients=false)

Compute the cost function (C) based on the model predictions and the true values matrix.

...

Arguments

  • model::ApplicationDrivenLearning.Model: model to evaluate.
  • X: input data of size (T, input_size). A matrix or vector, whose columns are taken in order, or a Tables.jl-compatible table such as a DataFrame, whose columns are selected by the predictive model's input_names.
  • Y: realized values. A (T, output_size) matrix, a vector, a Tables.jl-compatible table, or a Dict mapping each Forecast variable to its series. Table columns are matched to the forecast variables by name, never by position; see set_forecast_model for how those names are declared.
  • with_gradients::Bool=false: flag to compute and return the cost gradients with respect to the forecasts. When set, the second returned value is the per-sample gradient matrix of size (T, output_size).
  • aggregate::Bool=true: when true, the returned cost is averaged over the T samples. Only affects the cost; gradients are always per-sample. ...
source
compute_cost(model, X, Y, with_gradients=false, aggregate=true)

Variant of compute_cost accepting any combination of the supported input containers — matrices, vectors, Tables.jl-compatible tables such as a DataFrame, and a Dict mapping each Forecast variable to its series. Both arguments are normalized to matrices together — which is what lets the rows be matched by sample_key — and the call is forwarded to the matrix method.

X and Y are @nospecialized. Without it this wrapper is specialized on the concrete container types, and specializing it drags the whole compute_cost body — sample loop, solver calls and all — through inference again in the new context: measured at 174 s for the first table-typed call, against 0.001 s once compiled. Nothing here benefits from specialization, since both arguments are immediately converted to matrices.

source
ApplicationDrivenLearning.train! — Function
train!(model::Model, X, Y, options::Options)

Train the predictive model of model so that it minimizes the assessed cost of the application.

...

Arguments

  • model::ApplicationDrivenLearning.Model: model to train. Its forecast model must have been set with set_forecast_model.
  • X: input data of size (T, input_size). A matrix or vector, whose columns are taken in order, or a Tables.jl-compatible table such as a DataFrame, whose columns are selected by the predictive model's input_names.
  • Y: realized values of size (T, output_size). A matrix or vector whose columns follow the predictive model output order, a Tables.jl-compatible table whose columns are matched to the Forecast variables by name, or a dictionary mapping each Forecast variable to its length-T series.

A table is always matched by name and never falls back to column order; see set_forecast_model for how those names are declared.

  • options::Options: training mode and its parameters.

Returns a Solution with the best cost found and the corresponding parameter vector. The predictive model of model is updated in place with those parameters. ...

source
train!(build_model::Function, X, Y, options::Options)

Train a model built by build_model, a zero-argument function returning a ready-to-solve Model — variables, constraints, objectives, set_optimizer and set_forecast_model all done.

Equivalent to building the model and calling train! on it, except that the function itself is available to the parallel backend. That is what DistributedBackend needs: its workers never run your script, and a model that has had set_optimizer called on it cannot be sent to them at all, so a builder is the only way they can obtain one. Passing a Model under that backend is an error.

Using one function for the whole run is also what makes the driver's model and the workers' models the same problem by construction, rather than two descriptions that have to be checked against each other.

Note

The caller keeps no reference to the model this builds. The fitted parameters are in the returned Solution, so a fitted model is recovered with

sol = train!(build_model, X, Y, options)
m = build_model()
ApplicationDrivenLearning.apply_params(m.forecast, sol.params)

Both methods return a Solution; a return type that depended on which one was called would be worse than this.

source