Solver
Each model and some filters are configurable with a set of parameters. For example, the pattern prediction parameters encode the learned patterns of glucose level change. As yet another example, in-silico patient model has parameters, which describes initial quantities and rates of metabolic quantities. To personalize such parameters to fit a particular patient, we need to find them using a combination of a solver and one or more error metrics. To the solver, the parameters are just a set of opaque data. Metrics provide fitness functions. Hence, it is up to the solver to generate and test new set of parameters based on the fitness function calls.
A solver is, in fact, not a SmartCGMS entity, but rather a service provided by an implementing library. The solver itself thus does not need to define an interoperable class.
Descriptor
A solver is described by the scgms::TSolver_Descriptor
structure:
struct TSolver_Descriptor {
const GUID id;
const wchar_t *description;
const BOOL specialized; //if false, can be applied to any model
const size_t specialized_count; //can be zero with specialized == false, usually 1
const GUID *specialized_models; //array of models, whose parameters the solver can solve
};
id
is an unique GUID assigned to this solver. The solver is invoked using this identifier.description
is a string representing a name of the solver; it must not benullptr
and must contain a valid zero-terminated stringspecialized
is a flag, marking that the solver is able to optimize just a selected subset of models; if this is set toTRUE
, the following two fields apply:specialized_count
is a size of the following array of modelsspecialized_models
is an array of sizespecialized_count
, containing the GUIDs of models, that the solver is able to optimize
A shared object exporting a solver must:
- export a
do_get_solver_descriptors
function- this function returns a continuous array of solver descriptors identified by its first and one-after-last element
- export a
do_solve_generic
function- this function calls the solver service
An example of do_get_solver_descriptors
may be as follows:
const std::array solver_descriptors = { { descriptor_1, descriptor_2 } };
HRESULT IfaceCalling do_get_solver_descriptors(scgms::TSolver_Descriptor **begin, scgms::TSolver_Descriptor **end) {
*begin = solver_descriptors.data();
*end = solver_descriptors.data() + solver_descriptors.size();
return S_OK;
}
An example of do_solve_generic
may be as follows:
HRESULT IfaceCalling do_solve_generic(const GUID *solver_id, solver::TSolver_Setup *setup, solver::TSolver_Progress *progress) {
if (*solver_id == descriptor_1.id) {
CExample_Solver solver(*setup);
return solver.Solve(*progress);
}
return E_NOTIMPL;
}
Solver progress
The solver::TSolver_Progress
container defines the following fields:
current_progress
(size_t
) - a field containing the current progress value (e.g. a number of past generations)max_progress
(size_t
) - a field containing the maximum progress value (e.g. a maximum number of generations)best_metric
(TFitness
) - an array of best fitnesses; the SmartCGMS supports multi-criterial optimalization with maximum ofsolver::Maximum_Objectives_Count
criterias (10 at the moment of writing of this document)cancelled
(BOOL
) - by setting this field toTRUE
(or essentially any other value thanFALSE
(zero)), the solver is cancelled on a next checkpoint (e.g. between the generations)
Solver setup
The solver retains a configuration in a solver::TSolver_Setup
container:
struct TSolver_Setup {
const size_t problem_size;
const size_t objectives_count;
const double *lower_bound, *upper_bound;
const double **hints;
const size_t hint_count;
double * const solution;
const void *data;
const solver::TObjective_Function objective;
const size_t max_generations;
const size_t population_size;
const double tolerance;
};
problem_size
- the size of the optimized problem, i.e.; the count of parameters of a given model (or model set)objectives_count
- count of objectives to optimize bylower_bound
- lower bound of parameter valuesupper_bound
- upper bound of parameter valueshints
- an array of solution hintshint_count
- a size of above array (number of hints)solution
- an array of double precision floating point values with the size ofproblem_size
, which is used to store the best resultdata
- a type-agnostic pointer to data, that are passed to the objective functionobjective
- an objective function pointer, see below for detailsmax_generations
- a maximum number of generations (iterations) requested by the caller; this may apply depending on the solver logicpopulation_size
- population size (swarm size, ...) requested by the caller; this may apply depending on the solver logictolerance
- a maximum tolerance of parameter improvement; this may apply depending on the solver logic
Objective function
The solver needs an objective function to evaluate given solution. The solver::TObjective_Function
type is defined as follows:
using TObjective_Function = BOOL(IfaceCalling*)(const void *data, const size_t count, const double *solution, double* const fitness);
data
retains thedata
field value from thesolver::TSolver_Setup
container - the solver does not need to know the interpretation of the contentscount
- is the number of solutions, which are coalesced in the following fieldsolution
- is an array of parameters, which will get evaluated- when multiple solutions is to be evaluated (
count
is greater than 1), the parameters need to be stored in a continuous memory (coalesced to a single array)
- when multiple solutions is to be evaluated (
fitness
- is the target container, where to store the calculated fitnesses- similar to above - when multiple solutions is to be evaluated, this effectively points to an array of coalesced fitness arrays
SmartCGMS supports up to solver::Maximum_Objectives_Count
objectives, and thus the fitness
array must point to an array of this size.
The multi-criterial selection of best solution is completely dependent on the solver logic. Solvers implemented in the SmartCGMS core uses a simplified Pareto front to determine the best solution. Nonetheless, the simplest approach is to select the dominant error metric (e.g. the fitness at index 0) and ignore the rest.
To obtain the fitness function (and eventually the implicitly constructed objective function), include either the Signal Error
or Fast Signal Error
filter in the chain. Alternatively, it can be any filter implementing the
scgms::ISignal_Error_Inspection
with properly implemented Promise_Metric
method able to be called with parameters segment_id = scgms::All_Segments_Id
and defer_to_dtor = TRUE
. Then, SmartCGMS will use this
method to register storage places for the fitness values. SmartCGMS will execute a filter chain it optimizes with a particular solver. On destroying the chain, the error metric filter has to
store the fitness value to the designated pointer. Then, SmartCGMS picks it up and passes it to the chosen solver.
The SmartCGMS SDK implements a simplifying calls when an optimalization of a filter or model parameters are desired. For such a higher-level wrapper of the solver call, please, refer to the optimizer SDK page.
Example implementation
To demonstrate a simple implementation of solver interface, let us implement a solver, that attempts to randomly change the parameters of a single parameter vector, hoping to obtain a better parameter set. This is, of course, not an ideal approach, should you take this just as a simple example of the interface implementation, code structure and conventions.
Similarly to any other entities, let us create a descriptor header file, despite containing solely the solver GUID:
#include <rtl/guid.h>
namespace example_solver {
constexpr GUID id = { 0x4e24dbc6, 0xb4f4, 0x458c, { 0xba, 0xe3, 0x97, 0x13, 0x77, 0x60, 0xb0, 0x13 } }; // {4E24DBC6-B4F4-458C-BAE3-97137760B013}
}
Although it is not necessarily needed, let us define a solver class. This helps code readability and maintainability. We split the definition and implementation:
#include <rtl/FilterLib.h>
#include <rtl/referencedImpl.h>
#include <rtl/SolverLib.h>
#include <rtl/UILib.h>
class CExample_Solver {
protected:
static constexpr size_t Default_Max_Generations = 1000;
protected:
solver::TSolver_Setup &mSetup;
// NOTE: you may want to store solver::TFitness instead, optionally initialized with solver::Nan_Fitness
double mBest_Metric = std::numeric_limits<double>::quiet_NaN();
void Evaluate_Solution(const double* solution, solver::TSolver_Progress &progress);
public:
CExample_Solver(solver::TSolver_Setup &setup) : mSetup(setup) {
//
}
virtual ~CExample_Solver() = default;
HRESULT Solve(solver::TSolver_Progress &progress);
};
Now we need to implement the Evaluate_Solution
and Solve
methods we just defined. Let us start with the former one, that evaluates the current solution, and if it finds its fitness function value better,
it copies the results to a globally best solution:
void CExample_Solver::Evaluate_Solution(const double* solution, solver::TSolver_Progress &progress) {
// objective function computes a new metric values of given solution
TFitness metrics;
auto result = mSetup.objective(mSetup.data, 1, solution, metrics.data());
// let us select just the first metric for simplicity; you may want to compare fitnesses with a more profound approach
const double metricValue = metrics[0];
// if the new metric value is a valid number and is better than the current best metric, update our current best solution
if (!std::isnan(metricValue) && metricValue < mBest_Metric) {
// for our calculations:
mBest_Metric = metricValue;
// for GUI to display in a visual element:
progress.best_metric = metrics;
// update current best solution in setup
std::copy(solution, solution + mSetup.problem_size, mSetup.solution);
}
}
The Solve
method mainly just retains the values from solver setup and performs computation within a loop:
HRESULT CExample_Solver::Solve(solver::TSolver_Progress &progress) {
// determine generation count
const size_t genCount = mSetup.max_generations ? mSetup.max_generations : Default_Max_Generations;
// set max_progress, so the GUI could display correct progress bar value
progress.max_progress = genCount;
// initialize (pseudo-)random number generator for our example solver
static std::random_device rdev;
std::default_random_engine reng(rdev());
std::uniform_real_distribution<double> rdist(0.0, 1.0);
// prepare vector for our temporary solution
std::vector<double> current_solution(mSetup.problem_size);
// NOTE: your solver should also respect mSetup.population_size parameter
// therefore, there should be a vector of vectors instead of just a single vector;
// for the sake of demonstration, we ignore this parameter
mBest_Metric = std::numeric_limits<double>::max();
// outer code may help you with some initial parameter estimation called "hints"
// there may not be any hints present
for (size_t i = 0; i < mSetup.hint_count; i++) {
Evaluate_Solution(mSetup.hints[i], progress);
}
const double preSolveMetric = mBest_Metric; // currently, we hold the best metric of all hints given
// iterate through all generations
// we must respect the "cancelled" parameter of progress, as outer code and another thread may set it to true - in this case,
// the solver must interrupt as soon as possible and return
for (size_t generation = 0; generation < genCount && !progress.cancelled; generation++) {
// we just "emulate" some kind of a solver, so the following lines serves just as an example of "some work" done by a solver
// of course, one would choose a better strategy, than introduced here
// generate a random number vector - every vector coordinate within given bounds!
for (size_t i = 0; i < mSetup.problem_size; i++) {
current_solution[i] = mSetup.lower_bound[i] + rdist(reng) * (mSetup.upper_bound[i] - mSetup.lower_bound[i]);
}
// evaluate newly "computed" solution - this also sets proper status, etc.
Evaluate_Solution(current_solution.data(), progress);
// pretend we're doing something - just to demonstrate the progress bar function in GUI
// NOTE: obviously you don't want the following line of code to appear in your production code
std::this_thread::sleep_for(std::chrono::milliseconds(10));
// update current progress, so the GUI would update the visual progress bar element
progress.current_progress = generation;
}
// we may return a different code if the solver was cancelled
if (progress.cancelled) {
return S_FALSE;
}
// the solver did all it could, but it has not found a better solution, that is already known - in this case, always return S_FALSE
if (preSolveMetric < mBest_Metric) {
return S_FALSE;
}
// a better solution has been found
return S_OK;
}
To export the solver out of the library, we need to first create the descriptor. This solver is certainly not specialized to any model, so we set the corresponding attributes to false
/zero/nullptr
:
namespace example_solver {
const scgms::TSolver_Descriptor descriptor = {
id,
L"Example randomizing solver",
false,
0,
nullptr
};
}
As a final step, let us define, implement and export the needed library interface:
const std::array<scgms::TSolver_Descriptor, 1> solver_descriptions = { example_solver::descriptor };
HRESULT IfaceCalling do_get_solver_descriptors(scgms::TSolver_Descriptor **begin, scgms::TSolver_Descriptor **end) {
return do_get_descriptors(solver_descriptions, begin, end);
}
HRESULT IfaceCalling do_solve_generic(const GUID *solver_id, solver::TSolver_Setup *setup, solver::TSolver_Progress *progress) {
if (*solver_id == example_solver::descriptor.id) {
CExample_Solver solver(*setup);
return solver.Solve(*progress);
}
return E_NOTIMPL;
}