Working with Results
ropt exposes the full intermediate and final state of an optimization through
Results objects. This page describes the result
classes and how to inspect them; see Running Optimizations and
Optimization Workflows for how results are produced and
delivered to your code.
One kind of result object
Results is the record every part of ropt
returns: one object per variable vector evaluated, carrying every field
described below. Result handlers receive these, a report callback is given
one per evaluation, evaluate returns one, and
optimize puts the best one on the results field of the
OptimizationResult it returns. Whichever
way you reach a result, it reads the same.
The result hierarchy
During optimization, function and gradient evaluations generate data that is
reported via EnOptEvent objects passed to callbacks.
Each Results object represents the outcome of the
calculation for a single variable vector — that is, the objective and
gradient values computed at one point in variable space. However, the optimizer
may request evaluations at multiple variable vectors in a single batch (for
example, multiple perturbations or multiple candidates in a gradient-free
method). In that case, the event payload contains a sequence of Results
objects, one per variable vector evaluated in that batch.
Two concrete subclasses exist:
FunctionResults— objective and constraint values for a batch.GradientResults— gradient estimates for the objective and constraints.
Each carries nested ResultField objects:
| Result | Fields |
|---|---|
FunctionResults |
variables, target_objective, evaluations (FunctionEvaluations), functions (Functions), realizations (Realizations), constraint_info (ConstraintInfo), scaled (ScaledFunctionResults). |
GradientResults |
variables, perturbed_variables, target_gradient, evaluations (GradientEvaluations), gradients (Gradients), scaled (ScaledGradientResults). |
What each field holds
FunctionResults fields
variables— the variable vector that was evaluated, shape \((n_v,)\).target_objective— the single weighted scalar the optimizer minimizes (0-D array), orNoneif no aggregate could be formed. Always in the domain the optimizer works in; see Scaling of results.evaluations(FunctionEvaluations) — the raw per-realization values returned by the evaluator:objectives: objective values per realization, shape \((n_r, n_o)\).constraints: constraint values per realization, shape \((n_r, n_c)\) (only present when nonlinear constraints are configured).metadata: optional dict of per-realization metadata arrays, each of shape \((n_r,)\).
functions(Functions) — aggregated values derived from the per-realization evaluations (orNoneif all realizations failed):objectives: individual objective values, shape \((n_o,)\).constraints: individual constraint values, shape \((n_c,)\) (if configured).
realizations(Realizations) — ensemble metadata:evaluated_realizations: boolean array indicating which realizations were evaluated, shape \((n_r,)\).objective_weights: per-realization objective weights, shape \((n_o, n_r)\), orNonewhen no realization filter is configured. A filter may change them from one batch to the next.constraint_weights: per-realization constraint weights, shape \((n_c, n_r)\), orNoneunless nonlinear constraints and a realization filter are both configured.
-
constraint_info(ConstraintInfo) — constraint bound information. Present when bounds or constraints are defined. Contains two kinds of data for each constraint type (bound, linear, and nonlinear):- Differences: the signed distance between the current value and each bound. For lower bounds, a negative difference means the value is below the bound (violated). For upper bounds, a positive difference means the value is above the bound (violated).
- Violations: the absolute magnitude of any bound exceedance, or zero when the constraint is satisfied. For example, if a constraint requires \(g(\mathbf{x}) \leq 0\) and the actual value is \(0.5\), the violation is \(0.5\).
See the
ConstraintInforeference for the full list of fields.
GradientResults fields
variables— the unperturbed variable vector, shape \((n_v,)\).perturbed_variables— perturbed variable values, shape \((n_r, n_p, n_v)\).target_gradient— the gradient the optimizer descends, shape \((n_v,)\), orNoneif estimation failed. Always in the domain the optimizer works in.evaluations(GradientEvaluations) — the raw per-perturbation values returned by the evaluator:perturbed_objectives: objective values for each perturbation, shape \((n_r, n_p, n_o)\).perturbed_constraints: constraint values for each perturbation, shape \((n_r, n_p, n_c)\) (if configured).metadata: optional dict of per-realization/perturbation metadata arrays, each of shape \((n_r, n_p)\).
gradients(Gradients) — aggregated gradient values (orNoneif estimation failed):objectives: per-objective gradients, shape \((n_o, n_v)\).constraints: per-constraint gradients, shape \((n_c, n_v)\) (if configured).
realizations(Realizations) — same structure as forFunctionResults(see above).
In the shapes above: \(n_v\) = number of variables, \(n_o\) = number of objectives, \(n_c\) = number of nonlinear constraints, \(n_r\) = number of realizations, \(n_p\) = number of perturbations. All values are NumPy arrays.
Common attributes on all results
Every Results object carries:
batch_id: an integer identifying the evaluation batch (potentially generated by the evaluator).metadata: a dictionary of additional information generated during optimization. Not interpreted byropt— useful for reporting and analysis.names: a mapping from axis name to label tuples. Keys areAxisNamevalues, or the name of a metadata key that defines a user axis. Used to produce labelled multi-index DataFrames when exporting (see Exporting to pandas).
Accessing result data
Common access patterns:
result.variables # variable vector evaluated
result.target_objective # weighted scalar objective
result.functions.objectives # per-objective values (after weighting)
result.functions.constraints # per-constraint values
If functions is None, the result represents a request that produced no
valid values (for example, all realizations failed). target_objective is
None exactly then, so a single guard covers both:
Axes and dimensionality
Much of the data within result objects is multi-dimensional. For example, the
objectives field within
FunctionEvaluations is a 2-D array where
each row is a realization and each column is an objective.
To simplify exporting and reporting, the identity of each dimension is stored as
axis metadata on each field. The ResultField base
class provides a get_axes class method
for retrieving this metadata:
from ropt.results import FunctionEvaluations
FunctionEvaluations.get_axes("objectives")
# (<AxisName.REALIZATION: 'realization'>, <AxisName.OBJECTIVE: 'objective'>)
The AxisName enumeration defines:
| Axis name | Meaning |
|---|---|
VARIABLE |
Index corresponds to the variable number as defined in VariablesConfig. |
OBJECTIVE |
Index corresponds to the objective number (position in the weights array of ObjectiveFunctionsConfig). |
NONLINEAR_CONSTRAINT |
Index corresponds to the nonlinear constraint number as defined in NonlinearConstraintsConfig. |
LINEAR_CONSTRAINT |
Index corresponds to the linear constraint number as defined in LinearConstraintsConfig. |
REALIZATION |
Index corresponds to the realization number in the ensemble. Present whenever results involve multiple realizations. |
PERTURBATION |
Index corresponds to a perturbation used for gradient estimation. Present in GradientEvaluations where objectives and constraints are reported for each perturbed variable set. |
The dimensionality and order of axes are fixed per field. Throughout, \(n_v\) is the number of variables, \(n_o\) of objectives, \(n_c\) of nonlinear constraints, \(n_l\) of linear constraints, \(n_r\) of realizations and \(n_p\) of perturbations.
Function results
variables: the vector of variable values at which the functions were
evaluated.
- Shape \((n_v,)\)
- Axes:
VARIABLE
target_objective: the single weighted value the optimizer minimizes.
- Shape \(()\) — a 0-D array, so it carries no axes.
evaluations.objectives: the objective values for each realization, one
row per realization and one column per objective.
- Shape \((n_r, n_o)\)
- Axes:
REALIZATION,OBJECTIVE
functions.objectives: the same values aggregated over the ensemble.
- Shape \((n_o,)\)
- Axes:
OBJECTIVE
evaluations.constraints: the nonlinear constraint values for each
realization. Only present when nonlinear constraints are configured.
- Shape \((n_r, n_c)\)
- Axes:
REALIZATION,NONLINEAR_CONSTRAINT
functions.constraints: the same values aggregated over the ensemble.
- Shape \((n_c,)\)
- Axes:
NONLINEAR_CONSTRAINT
realizations.evaluated_realizations: which realizations were evaluated.
- Shape \((n_r,)\)
- Axes:
REALIZATION
realizations.objective_weights and realizations.constraint_weights:
per-realization weights, None unless a realization filter is configured.
- Shape \((n_o, n_r)\) and \((n_c, n_r)\)
- Axes:
OBJECTIVEorNONLINEAR_CONSTRAINT, thenREALIZATION
constraint_info.*: the differences and violations for each constraint
type. Each bound_, linear_ and nonlinear_ field has one axis.
- Shapes \((n_v,)\), \((n_l,)\) and \((n_c,)\)
- Axes:
VARIABLE,LINEAR_CONSTRAINTorNONLINEAR_CONSTRAINT
evaluations.metadata: optional per-realization metadata from the
evaluator. Each entry is a one-dimensional array of any type numpy
supports, including objects, so each key may have its own dtype.
- Shape \((n_r,)\)
- Axes:
REALIZATION
An entry whose values are arrays rather than scalars has shape \((n_r, n_k)\) and carries a second, user-defined axis named after its key; see User-defined axes.
Gradient results
variables: the vector of unperturbed variable values.
- Shape \((n_v,)\)
- Axes:
VARIABLE
target_gradient: the gradient the optimizer descends.
- Shape \((n_v,)\)
- Axes:
VARIABLE
perturbed_variables: the perturbed variable values for each realization
and perturbation.
- Shape \((n_r, n_p, n_v)\)
- Axes:
REALIZATION,PERTURBATION,VARIABLE
evaluations.perturbed_objectives: the objective values for each
realization and perturbation.
- Shape \((n_r, n_p, n_o)\)
- Axes:
REALIZATION,PERTURBATION,OBJECTIVE
gradients.objectives: the gradient estimated from them, per objective.
evaluations.perturbed_constraints: the nonlinear constraint values for
each realization and perturbation. Only present when nonlinear constraints
are configured.
- Shape \((n_r, n_p, n_c)\)
- Axes:
REALIZATION,PERTURBATION,NONLINEAR_CONSTRAINT
gradients.constraints: the gradient estimated from them, per constraint.
- Shape \((n_c, n_v)\)
- Axes:
NONLINEAR_CONSTRAINT,VARIABLE
realizations.*: the same fields and shapes as for function results.
evaluations.metadata: optional metadata per realization and
perturbation, each entry a two-dimensional array of any numpy type.
- Shape \((n_r, n_p)\)
- Axes:
REALIZATION,PERTURBATION
An entry of arrays rather than scalars has shape \((n_r, n_p, n_k)\) and a third, user-defined axis named after its key.
Scaled results
ScaledFunctionResults and
ScaledGradientResults repeat a subset of
the fields above in the optimizer's domain, with identical shapes and axes:
variables, functions and constraint_info for the first, and variables,
perturbed_variables and gradients for the second. See
Scaling of results.
User-defined axes
Per-realization metadata is the one place where you can add an axis of your own. If the evaluation function returns an array instead of a scalar for a metadata key, that key gains one extra axis, named after the key itself:
With three realizations and a residual of length three,
result.evaluations.metadata["residual"] has shape \((3, 3)\) and axes
("realization", "residual"). Label the new axis by adding an entry to names
under the same key:
Every realization must return the same number of entries for a key, and a key
may not be named after an AxisName value or
batch_id; both raise a ValueError. Unlabelled user axes fall back to integer
indices, as builtin axes do.
Note
Dimensionality is fixed: even with a single objective, result arrays still
include an OBJECTIVE axis of length one.
Scaling of results
Optimization internally works with scaled values: variables are scaled and
shifted by their
scales and offsets, objective and
nonlinear constraint aggregates have their
offsets subtracted and are divided by
their scales, and objectives marked
maximize are negated once they have
been combined across realizations.
Every result carries both domains at once, and one rule connects them:
Note
scaled.X is the optimizer's version of X, at the same path. Fields
without a scaled counterpart have only one domain.
So result.variables is the variable vector as configured and
result.scaled.variables is the same vector as the optimizer proposed it;
result.functions.objectives and result.scaled.functions.objectives are the
same pair for the aggregates.
Two groups of fields have a single domain:
- The per-realization values in
evaluationsare reported exactly as the evaluator returned them. Scales apply to the quantities the optimizer consumes, and the optimizer never sees a single realization, so there is nothing to undo for those fields. target_objectiveandtarget_gradientexist only in the domain the optimizer works in. Each is a weighted total over objectives that may differ in both scale and direction, so there is no single factor to undo. The gradient is differentiated with respect to the scaled variables. If you need either in configured terms, combine the objectives yourself usingget_objective_scalesand the directions onobjectives.maximize.
Because the direction is undone when reporting, a combined objective agrees in sign with the per-realization values it summarizes, whether it is an average or a measure of dispersion.
The runnable script is
examples/simple/scaling.py,
which runs the same problem unscaled, with auto_scale on the objective, and
with a variable scale, printing both domains each time. It shows that
auto_scale divides the reported objective by a large factor while leaving the
solution where it was, so only functions.objectives and variables can be
compared between runs.
Metadata
Results carry two independent kinds of metadata, neither interpreted by ropt:
- Result metadata — the
metadatadict on everyResultsobject, identical for every result of a run. It is set once when the run starts: pass ametadatadict to the simple-APIoptimize/optimize_many/evaluatefunctions (or to the low-level compute step). Use it to tag or identify a run, for example{"run_id": 7}. - Per-realization metadata — the
metadatadict on theevaluationsfield, with one array entry per realization. It is produced by the objective when it returns anEvaluationFunctionResultwith ametadatafield.
Result metadata is passed to the run and read back from metadata:
result = optimize(config, x0, objective, metadata={"run_id": 7})
result.results.metadata # {'run_id': 7}
Per-realization metadata is returned by the objective and read back from the
evaluations field, with one entry per realization:
def objective(variables, context):
...
return EvaluationFunctionResult(objectives=value, metadata={"shift": shift})
result.results.evaluations.metadata # {'shift': array([...])}
The full runnable script is examples/simple/metadata.py.
Exporting to pandas
ropt can export results to pandas DataFrames for analysis and reporting.
This requires the pandas optional extra (see Installation).
Note
These functions turn results into DataFrames. The
DataFrameHandler uses them to build and
update such tables for you as an optimization runs; see
Result Handlers.
The row index and the unstacked column labels come from the
names mapping in the configuration. If an axis is
not named, its labels fall back to 0-based integer indices. For example,
exporting the objectives of a single result without any names gives plain
numbers for both the realization and the objective axes:
Adding a names entry replaces those numbers with meaningful labels. The
examples below assume the realizations are named "r0"/"r1" and the objectives
"val"/"cost".
Exporting selected fields
The to_pandas method on an individual
result exports a list of fields, each named by a dotted path from the result:
The runnable script for this section is
examples/simple/export.py,
which exports one result stacked, the same result unstacked, and then every
result of the run in one frame. It uses polars by default and pandas with
--pandas, so only one of the two needs to be installed.
A path may name a field of the result itself ("variables",
"target_objective"), a field of one of its sub-objects
("functions.objectives", "scaled.variables"), or an entry of a dict-valued
field ("metadata.run.id"). Each path becomes a column of that name. Paths
whose value is None, and missing dict keys, are skipped; descending into a
field that is None is an error.
Fields are an ordered list
Every export function takes its fields as a sequence, and the columns
follow the order you give. Passing a set raises a TypeError, because a
set has no order to follow, and naming the same path twice raises a
ValueError, because one column cannot appear twice.
By default, every axis of the exported fields becomes a level in a
multi-index. For example, objectives in
FunctionEvaluations has the axes
REALIZATION and OBJECTIVE, so exporting it keeps both in the index — now
with the configured names:
Passing unstack pivots selected axes out of the index and into columns. Here
the OBJECTIVE axis is unstacked:
from ropt.enums import AxisName
df = result.to_pandas(
["evaluations.objectives"],
unstack=[AxisName.OBJECTIVE],
)
(evaluations.objectives, val) (evaluations.objectives, cost)
batch_id realization
1 r0 2.10 0.94
r1 2.35 1.02
The unstacked axis is flattened into the column labels, so each new column is a
tuple of the full field path and the axis label — here
("evaluations.objectives", "val") and ("evaluations.objectives", "cost").
Unstacking more axes adds more elements to these tuples. Unstacking every axis
of every selected field leaves one row per result, which is what makes results
from different batches comparable row by row.
Aggregating multiple results
results_to_pandas builds on to_pandas to
convert a sequence of results into a single DataFrame. It takes no unstack
argument, because an aggregated frame always pivots the same way:
Note
Every axis becomes columns except realization and perturbation,
which stay as row labels next to batch_id.
A field with neither of those axes therefore gives exactly one row per result:
from ropt.results import results_to_pandas
df = results_to_pandas(
all_results,
fields=["variables"],
result_type="functions",
)
(variables, x0) (variables, x1) (variables, x2)
batch_id
1 0.30 0.42 -0.11
2 0.55 0.48 0.02
3 0.61 0.50 0.10
Each column is a (field, label) pair, and here each row is one result
identified by its batch_id; selecting a field that keeps the realization
axis adds a level to the index and one row per realization. Field names use dot
notation for nested sub-fields (for example, variables, target_objective),
and the fields appear in the order you list them. The result_type
argument selects which results to process: "functions" for
FunctionResults only, "gradients" for
GradientResults only.
Metadata columns
Both kinds of metadata are reached by the same dotted paths, since a path may end in one or more dict keys.
The per-realization metadata attached by the evaluator lives on the
evaluations, so it keeps the realization axis. For example, if the objective
attached a per-realization shift:
If the value a realization returns is an array rather than a scalar, the key
also spans a user axis named after the key, which is
treated like a builtin one. Exporting a single result keeps it in the index,
here for a two-element residual labelled "x"/"y":
Passing unstack=["residual"] pivots it into columns, exactly as for a builtin
axis. In an aggregated frame the rule above applies, so the axis is always
unstacked while realization stays a row label:
df = results_to_pandas(
all_results,
fields=["evaluations.metadata.residual"],
result_type="functions",
)
(evaluations.metadata.residual, x) (evaluations.metadata.residual, y)
batch_id realization
1 r0 0.12 -0.04
r1 0.31 0.08
2 r0 0.09 -0.02
r1 0.22 0.05
The run-level result metadata sits directly on the result, so it has no
axes and gives one value per result, which is convenient for pulling in a run
tag. It may be nested to any depth, and is reachable from to_pandas and
results_to_pandas alike:
df = results_to_pandas(
all_results,
fields=["metadata.run_id", "target_objective"],
result_type="functions",
)
Labels and the index
Every axis of an exported field becomes an index level, named after its
AxisName value (for example "variable", "realization",
"objective"), and batch_id is always prepended so results from different
batches stay distinct. The label on each level — and on each unstacked column —
comes from the names mapping in the configuration, a
dict from axis name to a tuple of labels:
An axis without a names entry falls back to 0-based integer indices, as in the
first example of this section.
Exporting to polars
Every pandas export has a polars counterpart:
to_polars and
results_to_polars. They accept the same
arguments and select and unstack exactly the same fields, so everything in the
previous section carries over. This requires the polars optional extra (see
Installation).
Polars has no index and its column names must be strings, which leads to the two differences you need to know about. The frames below are shown without the borders polars draws around them in a terminal.
Index levels become ordinary columns. What pandas puts in the index, polars puts in leading columns of the frame:
batch_id realization objective evaluations.objectives
1 r0 val 2.10
1 r0 cost 0.94
1 r1 val 2.35
1 r1 cost 1.02
Tuple column labels become joined strings. Where pandas produces the column
("evaluations.objectives", "val"), polars produces
"evaluations.objectives,val". The separator is configurable with the sep
argument, which defaults to ",":
from ropt.enums import AxisName
df = result.to_polars(
["evaluations.objectives"],
unstack=[AxisName.OBJECTIVE],
)
batch_id realization evaluations.objectives,val evaluations.objectives,cost
1 r0 2.10 0.94
1 r1 2.35 1.02
Aggregating a sequence of results works the same way:
from ropt.results import results_to_polars
df = results_to_polars(
all_results,
fields=["variables"],
result_type="functions",
)
Metadata behaves exactly as described above, and both kinds are reachable from either function:
df = results_to_polars(
all_results,
fields=["metadata.run_id", "target_objective"],
result_type="functions",
)
Note
Because polars keeps the keys as real columns, it can join fields that vary at different granularities — for example a per-batch gradient with per-perturbation evaluations — by repeating the coarser values across the finer rows. Pandas cannot align such fields and returns them as disjoint blocks of rows padded with missing values instead. Only polars produces a single table that mixes granularities.