Example Fitting a Gaussian Process to the profit curve (using AbstractGPs.jl)

This a example on how to use AbstractGPs.jl to fit a Gaussian Process (GP) to the profit curve of a company operating in a defined market (through the OptimalBids API).

  • For this example, we will use a market of type PowerModelsMarkets which represents an energy spot market.
  • Case instance is IEEE 118 Bus Case
  • To ilustrate a situation where the company defines the distribution of volume across it's assets in a predefined way (and reduce the decision dimentionality), the only controlable variable to maximize the company profit is a multiplicative factor of all offers.
  • We will use Optim.jl for the MLE of the tested kernels' hyperparameters.

Include Needed Packages

using OptimalBids
using OptimalBids.PowerModelsMarkets
using HiGHS # Market Clearing Solver
using JuMP: optimizer_with_attributes

using AbstractGPs
using KernelFunctions

using Optim
using ParameterHandling
using Zygote
using ParameterHandling: flatten

using Plots # For some evaluation plots at the end
using Plots.PlotMeasures
using Downloads # To download Test Cases
┌ Warning: Module DistributionsTestExt with build ID ffffffff-ffff-ffff-0000-00698dc5e021 is missing from the cache.
│ This may mean DistributionsTestExt [ffbe0ea5-a612-5ff7-aaf5-cac02eef3019] does not support precompilation but is imported by a module that does.
└ @ Base loading.jl:1948
┌ Error: Error during loading of extension DistributionsTestExt of Distributions, use `Base.retry_load_extensions()` to retry.
│   exception =
│    1-element ExceptionStack:
│    Declaring __precompile__(false) is not allowed in files that are being precompiled.
│    Stacktrace:
│      [1] _require(pkg::Base.PkgId, env::Nothing)
│        @ Base ./loading.jl:1952
│      [2] __require_prelocked(uuidkey::Base.PkgId, env::Nothing)
│        @ Base ./loading.jl:1812
│      [3] #invoke_in_world#3
│        @ ./essentials.jl:926 [inlined]
│      [4] invoke_in_world
│        @ ./essentials.jl:923 [inlined]
│      [5] _require_prelocked
│        @ ./loading.jl:1803 [inlined]
│      [6] _require_prelocked
│        @ ./loading.jl:1802 [inlined]
│      [7] run_extension_callbacks(extid::Base.ExtensionId)
│        @ Base ./loading.jl:1295
│      [8] run_extension_callbacks(pkgid::Base.PkgId)
│        @ Base ./loading.jl:1330
│      [9] run_package_callbacks(modkey::Base.PkgId)
│        @ Base ./loading.jl:1164
│     [10] __require_prelocked(uuidkey::Base.PkgId, env::String)
│        @ Base ./loading.jl:1819
│     [11] #invoke_in_world#3
│        @ ./essentials.jl:926 [inlined]
│     [12] invoke_in_world
│        @ ./essentials.jl:923 [inlined]
│     [13] _require_prelocked(uuidkey::Base.PkgId, env::String)
│        @ Base ./loading.jl:1803
│     [14] macro expansion
│        @ ./loading.jl:1790 [inlined]
│     [15] macro expansion
│        @ ./lock.jl:267 [inlined]
│     [16] __require(into::Module, mod::Symbol)
│        @ Base ./loading.jl:1753
│     [17] #invoke_in_world#3
│        @ ./essentials.jl:926 [inlined]
│     [18] invoke_in_world
│        @ ./essentials.jl:923 [inlined]
│     [19] require(into::Module, mod::Symbol)
│        @ Base ./loading.jl:1746
│     [20] include
│        @ ./Base.jl:495 [inlined]
│     [21] include_package_for_output(pkg::Base.PkgId, input::String, depot_path::Vector{String}, dl_load_path::Vector{String}, load_path::Vector{String}, concrete_deps::Vector{Pair{Base.PkgId, UInt128}}, source::String)
│        @ Base ./loading.jl:2222
│     [22] top-level scope
│        @ stdin:3
│     [23] eval
│        @ ./boot.jl:385 [inlined]
│     [24] include_string(mapexpr::typeof(identity), mod::Module, code::String, filename::String)
│        @ Base ./loading.jl:2076
│     [25] include_string
│        @ ./loading.jl:2086 [inlined]
│     [26] exec_options(opts::Base.JLOptions)
│        @ Base ./client.jl:316
│     [27] _start()
│        @ Base ./client.jl:552
└ @ Base loading.jl:1301

Case Definition

# Read market data from IEEE 118 bus case
case_name = "pglib_opf_case118_ieee.m"
DATA_DIR = mktempdir()
case_file_path = joinpath(DATA_DIR, case_name)
Downloads.download("https://raw.githubusercontent.com/power-grid-lib/pglib-opf/01681386d084d8bd03b429abcd1ee6966f68b9a3/" * case_name, case_file_path)
network_data = PowerModels.parse_file(case_file_path)

# Measure maximum load
max_load = sum(load["pd"] for load in values(network_data["load"])) * network_data["baseMVA"]

# Pretend we are a company constructing a new set of generators in the grid.
# Choose a percentage of the total number of buses to install the new generators:
percentage_buses = 0.09

# We need the keys PowerModels uses to reference the appropriate buses in it's network data dictionary.
# First, find out all available keys:
bus_indexes = collect(keys(network_data["bus"]))
# Then, calculate number of buses that consitute the chose percent (`percentage_buses`):
num_buses = length(bus_indexes)
num_strategic_buses = ceil(Int, percentage_buses * num_buses)
# To avoid any biases let's grab some generators in the middle:
bus_indexes = bus_indexes[21:(21 + num_strategic_buses - 1)]
# Finally, add new generators to the network grid data and collect their reference keys.
generator_indexes = [
    add_generator(network_data, parse(Int, bus_idx)) for bus_idx in bus_indexes
]
11-element Vector{String}:
 "55"
 "56"
 "57"
 "58"
 "59"
 "60"
 "61"
 "62"
 "63"
 "64"
 "65"

OptimalBids API


# Define market
market = build_market(
    PowerModelsMarket,
    network_data,
    generator_indexes,
    optimizer_with_attributes(HiGHS.Optimizer, "LogLevel" => 0),
)

# Relative distribution of offers are sometimes predefined and cannot be changed at bidding time.
using Random
rng = MersenneTwister(0)
offer_weights = rand(rng, num_strategic_buses)
offer_weights = offer_weights/ sum(offer_weights)

# However, the decision maker is allowed to increase all bids evenly:
min_total_volume = 0.0
max_total_volume = 655.0
range_mul_factor = min_total_volume:1.0:max_total_volume
bid_range = [offer_weights .* [i] for i in range_mul_factor]
p_curve = profit_curve!(market, bid_range)

Optim API

function bfgs(objective, initial_params; verbose=false)
   training_results = Optim.optimize(
        objective,
        θ -> only(Zygote.gradient(objective, θ)),
        initial_params,
        LBFGS(
            alphaguess = Optim.LineSearches.InitialStatic(scaled=true),
            linesearch = Optim.LineSearches.BackTracking(),
        ),
        Optim.Options(show_trace = verbose);
        inplace=false,
    )

   return (training_results.minimum, training_results.minimizer)
end

function multi_start_hyperopt(target, algorithm, num_iterations, initial_initial_params; update_initials = (θ_last, θ_best, obj_diff) -> rand(length(θ_last)), verbose=false, kwargs...)
    θ_initials = Array{Float64, 2}(undef, num_iterations, length(initial_initial_params));
    vistited_best_fs = Array{Float64}(undef, num_iterations);
    best_θ = fill(1/length(initial_initial_params), length(initial_initial_params))
    best_f = target(best_θ)
    last_θ = best_θ
    last_f = best_f
    for i = 1:num_iterations
        try
            (last_f, last_θ) = algorithm(target, update_initials(last_θ, best_θ, last_f-best_f); verbose=verbose, kwargs...)
        catch
            println("SHIT")
        end
        θ_initials[i,:] .= last_θ
        if last_f < best_f
            best_f = last_f
            best_θ = last_θ
        end
        vistited_best_fs[i] = best_f
    end
    return best_f, best_θ, θ_initials, vistited_best_fs
end
multi_start_hyperopt (generic function with 1 method)

AbstractGPs Interface

Data


# Randomly choose observed data
number_samples = 9
idx = [1; rand(2:size(range_mul_factor,1), number_samples)]
x = collect(range_mul_factor)[idx]
y = p_curve[idx]

plt_comp = plot(collect(range_mul_factor) * 100 / max_load, p_curve,
    label="Range Evaluation",
    ylabel="Profit (USD)",
    size=(900, 600), 
    title="Kernel Comparison", 
    xlabel="Bid Volume (% Market Share)",
    legend=:outertopright,
    color="black",
    width=3,
    left_margin=10mm,
    bottom_margin=10mm,
);
scatter!(plt_comp, x .* 100 ./ max_load, y; label="Data");

plt_comp

Matern32Kernel


## Define GP prior with Matern32Kernel
flat_initial_params, unflatten = flatten((;
    var_kernel = bounded(10.0, 1.0, 1e8),
    λ = bounded(0.005, 0.0, 0.01),
))

# Construct a function to unpack flattened parameters and pull out the raw values.
unpack = ParameterHandling.value ∘ unflatten
params = unpack(flat_initial_params)

function build_gp(params)
    return GP(params.var_kernel * Matern52Kernel() ∘ ScaleTransform(params.λ))
end

# Define MLE objective
function objective(params)
    f = build_gp(unpack(params))
    return -logpdf(f(x, 0.001), y)
end

# Optimise using Optim
(best_f, best_θ, θ_initials, vistited_best_fs) = multi_start_hyperopt(objective, bfgs, 100, flat_initial_params)

# Extracting the optimal values of the parameters
optimal_params = unpack(best_θ)

# Final GP
f = build_gp(optimal_params)

# Finite projection of `f` at inputs `x`.
# Added Gaussian noise with variance 0.001.
fx = f(x, 0.001)

# Exact posterior given `y`. This is another GP.
p_fx = posterior(fx, y)

# Plot posterior.
plot!(plt_comp, collect(range_mul_factor) * 100 / max_load, p_fx(collect(range_mul_factor)); label="Posterior - " * string(Matern32Kernel))

RationalKernel


## Define GP prior with RationalKernel
flat_initial_params, unflatten = flatten((;
    var_kernel = bounded(10.0, 6.0, 1e8),
    λ = bounded(0.005, 0.0, 0.01),
    α = positive(0.05),
))

# Construct a function to unpack flattened parameters and pull out the raw values.
unpack = ParameterHandling.value ∘ unflatten
params = unpack(flat_initial_params)

function build_gp(params)
    return GP(params.var_kernel * RationalKernel(α=params.α) ∘ ScaleTransform(params.λ))
end

# Define MLE objective
function objective(params)
    f = build_gp(unpack(params))
    return -logpdf(f(x, 0.001), y)
end

# Optimise using Optim
(best_f, best_θ, θ_initials, vistited_best_fs) = multi_start_hyperopt(objective, bfgs, 100, flat_initial_params)

# Extracting the optimal values of the parameters
optimal_params = unpack(best_θ)

# Final GP
f = build_gp(optimal_params)

# Finite projection of `f` at inputs `x`.
# Added Gaussian noise with variance 0.001.
fx = f(x, 0.001)

# Exact posterior given `y`. This is another GP.
p_fx = posterior(fx, y)

# Plot posterior.
plot!(plt_comp, collect(range_mul_factor) * 100 / max_load, p_fx(collect(range_mul_factor)); label="Posterior - " * string(RationalKernel))

FBMKernel


## Define GP prior with FBMKernel
flat_initial_params, unflatten = flatten((;
    var_kernel = bounded(10.0, 6.0, 1e8),
    λ = bounded(0.005, 0.0, 0.01),
    h = bounded(0.05, 0.0, 1.0),
))

# Construct a function to unpack flattened parameters and pull out the raw values.
unpack = ParameterHandling.value ∘ unflatten
params = unpack(flat_initial_params)

function build_gp(params)
    return GP(params.var_kernel * FBMKernel(h=params.h) ∘ ScaleTransform(params.λ))
end

# Define MLE objective
function objective(params)
    f = build_gp(unpack(params))
    return -logpdf(f(x, 0.001), y)
end

# Optimise using Optim
(best_f, best_θ, θ_initials, vistited_best_fs) = multi_start_hyperopt(objective, bfgs, 100, flat_initial_params)

# Extracting the optimal values of the parameters
optimal_params = unpack(best_θ)

# Final GP
f = build_gp(optimal_params)

# Finite projection of `f` at inputs `x`.
# Added Gaussian noise with variance 0.001.
fx = f(x, 0.001)

# Exact posterior given `y`. This is another GP.
p_fx = posterior(fx, y)

# Plot posterior.
plot!(plt_comp, collect(range_mul_factor) * 100 / max_load, p_fx(collect(range_mul_factor)); label="Posterior - " * string(FBMKernel))