Designs over a Constrained Region#
Factor limits are rarely independent. A reactor may run hot, or run long, but not both, because the heat load would exceed what the jacket can remove. The feasible region is then the factor box with a corner cut off, and a classical design (a factorial, a CCD, a Box-Behnken) places runs in that corner.
generate_design accepts such limits as Constraint
objects. When constraints are given it builds a D-optimal design, choosing every run
from the feasible region.
How the design is chosen#
Candidate set. A grid over the factor box (5 levels per continuous factor, fewer for many factors), plus the points where each constraint boundary crosses a grid edge, located by bisection. Infeasible points are dropped. The metadata reports the counts:
m = result.metadata print(m["constraints_enforced"], m["n_grid_points"], m["n_boundary_points"], m["n_candidates"]) # True 25 7 21
Model matrix. Each candidate is expanded into the columns of the requested
model_type: intercept, main effects, two-factor interactions and, for"quadratic", squared terms. A categorical factor withLlevels contributesL - 1effect-coded (sum-to-zero) columns, the codingevaluate_designuses.Exchange. A Fedorov exchange swaps one design run for one candidate at a time, taking the swap that increases the determinant of
X'Xmost, until no swap improves it. Five random starts guard against a poor local optimum;random_statemakes the result reproducible.
Writing constraints#
Use factor names and actual units:
"3*T + 5*D <= 600".<,<=,>and>=are accepted; a chained comparison such as"400 <= 3*T + 5*D <= 600"gives a lower and an upper limit.Nonlinear limits are allowed, with
+ - * / **andabs,sqrt,exp,log,log10:Constraint(expression="T * D <= 6000", type="nonlinear").Equalities are refused. A region with
A + B == 1has no volume to place runs in; proportions that sum to one are mixture factors.Constraints refer to continuous factors. Categorical factors may appear in the design and are crossed with the constrained continuous region.
The expression is parsed into an arithmetic tree and evaluated with numpy; it is never
passed to eval, so an expression from an untrusted source can only compute a number.
Limits on the Settings shows how to check what a set of limits leaves, propose a limit from
the settings that cannot be run, and draw the region.
Combining with other options#
fixed_runskeeps runs already performed (continuous factors in coded units, categorical as labels) and fills the rest of the budget around them. A fixed run outside the region is kept and a warning is logged.hard_to_change(split-plot) is not available together with constraints; it is ignored and recorded asmetadata["hard_to_change_ignored"].Other design types do not enforce constraints. They log a warning and set
metadata["constraints_enforced"] = False.
Judging the design over its own region#
A constrained design should be judged on the settings it may visit. The average
prediction variance (the I-criterion, lower is better) averages it over the region, and
the G-efficiency is based on its worst case. Over the full box, both are dominated by the cut-off corner, where the
model has to extrapolate. generate_design records the region in
result.metadata["region"], and evaluate_design uses it by default:
from process_improve.experiments import evaluate_design
metrics = ["average_prediction_variance", "g_efficiency"]
inside = evaluate_design(result, model="quadratic", metric=metrics)
cube = evaluate_design(result, model="quadratic", metric=metrics, region="cuboidal")
print(f"{inside['average_prediction_variance']:.2f} {inside['g_efficiency']:.0f}") # 0.50 73
print(f"{cube['average_prediction_variance']:.2f} {cube['g_efficiency']:.1f}") # 1.44 3.5
Inside the region the average prediction variance is a third of its value over the box, and the worst case is about a twentieth. The box figure describes settings the plant cannot run, so it says nothing about the design’s quality for this process.
The region is sampled uniformly by rejection, and its boundary points (the feasible
grid corners and the constraint crossings) are added, since that is where the worst
case of a second-order model sits. Pass region=DesignRegion(factors, constraints)
to evaluate a design held as a plain DataFrame.
Choosing the criterion: D, I or A#
The exchange maximises one number, and the three criteria ask different questions:
D-optimal (
design_type="d_optimal") maximisesdet(X'X): the model coefficients are estimated jointly as precisely as possible.I-optimal (
"i_optimal") minimises the average prediction variance over the region, whichevaluate_designreports asaverage_prediction_variance. It suits a study whose purpose is prediction or optimisation.A-optimal (
"a_optimal") minimises the summed variance of the coefficients.E-optimal (
"e_optimal") maximises the smallest eigenvalue ofX'X, so no combination of the coefficients is left poorly estimated.
On the heat-budget example, with ten runs for a quadratic model, each design is best on its own measure:
Design |
D-efficiency (higher is better) |
Summed coefficient variance (lower is better) |
Smallest eigenvalue (higher is better) |
|---|---|---|---|
D-optimal |
29.6 |
5.67 |
0.272 |
A-optimal |
27.8 |
4.95 |
0.322 |
E-optimal |
26.0 |
5.09 |
0.325 |
For the I-optimal design the average is taken over the constrained region itself,
sampled uniformly, so the design is chosen for the same region evaluate_design
uses to judge it:
for design_type in ["d_optimal", "i_optimal"]:
r = generate_design(factors, design_type=design_type, budget=10, constraints=[heat], model_type="quadratic")
m = evaluate_design(r, model="quadratic", metric=["d_efficiency", "average_prediction_variance", "g_efficiency"])
print(design_type, {k: round(m[k], 3) for k in ("d_efficiency", "average_prediction_variance", "g_efficiency")})
# d_optimal {'d_efficiency': 29.57, 'average_prediction_variance': 0.499, 'g_efficiency': 73.341}
# i_optimal {'d_efficiency': 27.351, 'average_prediction_variance': 0.356, 'g_efficiency': 62.677}
Each design is best on its own criterion. The I-optimal design places more runs in the interior, so its average prediction variance is lower by a factor of 1.4, at the cost of a slightly less precise joint estimate of the coefficients and a larger worst case at the boundary.
These designs come from the built-in exchange, which is the default for every optimal
design, so the same call gives the same design whether or not the optional pyoptex
package is installed. pyoptex is used for split-plot designs (hard_to_change), or
when asked for with backend="pyoptex".
Choosing runs from a list: candidate sets#
Sometimes the region is not a formula but a list: the operating points in the plant
records, the discrete settings a piece of equipment offers, or the blends that can be
made up from stock. Pass them as candidates and the optimal design is chosen from
those rows only:
import numpy as np
import pandas as pd
rng = np.random.default_rng(0)
history = pd.DataFrame(
{"T": rng.uniform(100, 150, 30).round(1), "D": rng.uniform(20, 60, 30).round(1)},
index=[f"batch_{i:02d}" for i in range(30)],
)
result = generate_design(
factors, budget=8, candidates=history, constraints=[heat], model_type="interactions"
)
m = result.metadata
print(m["n_candidates_supplied"], m["n_candidates_infeasible"], m["n_candidates"]) # 30 13 17
print(m["selected_candidates"])
# {'batch_02': 2, 'batch_20': 2, 'batch_29': 2, 'batch_05': 1, 'batch_06': 1}
The 13 records that break the heat budget are dropped, and the eight runs are chosen
from the remaining 17. selected_candidates names the chosen rows by their index
label, so the design can be read as “rerun batches 2, 20 and 29 twice, and batches 5
and 6 once”.
The candidates are given in actual units, one column per factor (labels for
categorical factors, proportions for mixtures). A candidate set replaces the generated
grid, so for an I-optimal design it is also the region the average prediction variance
is taken over. Without design_type, a candidate set selects "d_optimal".
Candidates outside the factors’ low/high range are kept with a warning, since
the coding extrapolates beyond plus or minus one.
Running the design in blocks#
When the runs cannot all be made under the same conditions (on two days, from two lots
of raw material), pass n_blocks. The analysis then fits a level for each block, so
what the design has to estimate well is the factor effects once the differences
between blocks are taken out. The D-, A- and I-optimal designs choose their runs for
exactly that model: each block gets its own copy of the candidates, a run is only ever
exchanged for a candidate of its own block, and the criterion is that of the factor
effects adjusted for the blocks.
result = generate_design(factors, budget=12, n_blocks=2, constraints=[heat], model_type="quadratic")
print(result.metadata["blocking"]["method"]) # optimal_exchange
print(result.design_actual.sort_values(["Block", "T", "D"]).to_string(index=False))
RunOrder T D Block
4 100.0 20.0 1
6 100.0 40.0 1
5 100.0 60.0 1
2 125.0 20.0 1
3 125.0 45.0 1
1 150.0 30.0 1
10 100.0 20.0 2
11 100.0 40.0 2
8 100.0 60.0 2
12 125.0 20.0 2
7 125.0 45.0 2
9 150.0 20.0 2
Each day’s six runs cover the region’s vertices and edge midpoints, so each block is nearly a quadratic design on its own, and a shift from one day to the next moves both blocks’ averages without biasing the factor effects. The run sheet takes the blocks in turn, shuffled within each.
The most useful case is a follow-up experiment. Pass the runs already made as
fixed_runs: with n_blocks they are the first block, kept first and in their
order, and the new runs fill the other blocks, chosen knowing that the new day may
differ from the old one.
first_day = generate_design(factors, budget=6, constraints=[heat], model_type="interactions").design
follow_up = generate_design(
factors,
budget=12,
n_blocks=2,
fixed_runs=first_day[["T", "D"]],
constraints=[heat],
model_type="quadratic",
)
print(follow_up.design["Block"].tolist()) # [1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2]
For the analysis with a day effect, this follow-up is 3.7% more D-efficient than the same augmentation chosen as if both days were one (13% for a 2x2 factorial followed by five runs). Splitting an optimal design into blocks after choosing it, the only option before, was within 1% of the joint design for equal blocks over a regular region; the gain is in follow-ups, in many small blocks (2 to 5% for blocks of two runs), and it cannot hurt.
metadata["log_det_information"] (or trace_criterion) is then the value for the
factor effects adjusted for the blocks, and metadata["blocking"]["method"] is
"optimal_exchange". E-, G- and K-optimal designs, mixtures, split-plot designs and
replicated designs are still split into blocks after they are chosen ("exchange"),
and cannot be combined with fixed_runs. Each block needs at least two new runs, and
the budget is raised, with a warning, when the block effects leave too few.
Optimising inside the region#
An optimum is only useful if the process can run it. Passing the same region to
optimize_responses turns each constraint into an SLSQP inequality constraint and
starts the search from feasible points. Here, a fitted quadratic model whose maximum
over the box is the forbidden corner:
from process_improve.experiments import DesignRegion, optimize_responses
model = {
"response_name": "y",
"factor_names": ["T", "D"],
"coefficients": [
{"term": "Intercept", "coefficient": 60.0},
{"term": "T", "coefficient": 8.0},
{"term": "D", "coefficient": 6.0},
{"term": "I(T ** 2)", "coefficient": -2.0},
{"term": "I(D ** 2)", "coefficient": -3.0},
],
}
goal = [{"response": "y", "goal": "maximize", "low": 40, "high": 75}]
ranges = {"T": {"low": 100, "high": 150}, "D": {"low": 20, "high": 60}}
region = DesignRegion.from_dict(result.metadata["region"])
best = optimize_responses([model], goal, factor_ranges=ranges, region=region)["desirability"]
print(f"{best['optimal_actual']['T']:.1f} {best['optimal_actual']['D']:.1f}", best["within_region"])
# 140.7 35.6 True: on the heat line 3*T + 5*D = 600
Without region the same call returns (150 degC, 60 min), with a heat load of 750.
method="pareto_front" accepts region too; the other methods ignore it and log
a warning.
Constrained mixtures#
In a formulation the components are proportions that sum to one, and each is usually bounded: at least 10% polymer, at most 30% filler. Bounds and linear constraints cut the simplex down to a polygon (a polytope in more than three components), and the design is built from its geometry.
factors = [
Factor(name="polymer", type="mixture", low=0.10, high=0.50),
Factor(name="solvent", type="mixture", low=0.10, high=0.70),
Factor(name="filler", type="mixture", low=0.05, high=0.30),
]
cap = Constraint(expression="polymer + solvent <= 0.85")
ev = generate_design(factors, constraints=[cap])
print(ev.metadata["method"], ev.n_runs, ev.metadata["n_vertices"]) # extreme_vertices 11 5
dopt = generate_design(factors, budget=12, constraints=[cap])
print(dopt.metadata["method"]) # d_optimal_extreme_vertices
Extreme vertices. In
qcomponents, a vertex is a blend whereq - 1constraints hold with equality. Constraints implied by the others are dropped, every choice ofq - 1of the rest is solved as one linear system, in batches, and the feasible solutions are the vertices.Without a budget the vertices and the centroid are returned, plus the edge midpoints for a quadratic model and the centroids of the 2-dimensional faces for a special cubic one. Those faces are the constrained counterpart of ternary blends, which the
x_i x_j x_kterms need. When that is more than three runs per model term, as it soon is with many bounded components, a D-optimal design with five runs more than the model has terms is returned instead.With a budget a D-optimal subset is chosen from the vertices, edge midpoints, 2-face and facet centroids, centroid and axial check blends, by the same exchange as above. Blends can repeat, which gives replicates for a pure-error estimate.
Mixture constraints must be linear in the proportions, since the vertex enumeration
relies on flat faces. On the full simplex (no bounds, no constraints) the design is
sized to the model: the pure components and the overall centroid for a linear model,
plus the binary 50:50 blends for a quadratic one (the {q, 2} simplex lattice), plus
the ternary blends for a special cubic one. With three components that is the 7-run
simplex centroid. A budget below that size gives a D-optimal subset instead.
Analyse the runs with a Scheffé model. It has no intercept, because the proportions sum to one:
import numpy as np
from process_improve.experiments import analyze_experiment
blends = ev.design_actual[["polymer", "solvent", "filler"]]
# A simulated response stands in for the measurements here.
rng = np.random.default_rng(1)
blends["y"] = (
10 * blends["polymer"] + 6 * blends["solvent"] + 3 * blends["filler"]
+ 8 * blends["polymer"] * blends["solvent"] + rng.normal(0, 0.1, len(blends))
)
fit = analyze_experiment(
blends, response_column="y", model="scheffe_quadratic", analysis_type=["coefficients", "anova"]
)
print(fit["model_summary"]["formula"]) # y ~ -1 + (polymer + solvent + filler) ** 2
"scheffe_linear", "scheffe_quadratic" and "scheffe_special_cubic" are
accepted by analyze_experiment and evaluate_design. statsmodels recognises
that the proportions carry an implicit intercept, so R-squared is centred and the
model degrees of freedom are one less than the number of terms, as for a model with an
intercept.
A blending coefficient tested against zero asks whether a pure component gives a
response of zero, which is rarely the question. So for a Scheffé model the ANOVA tests
the linear blending terms jointly (do the components blend differently at all?) and
each non-linear blending term, such as polymer:solvent, on its own. Effects are
reported in the Cox direction (each linear coefficient minus the mean of the others),
and Lenth’s method is not applied.
evaluate_design defaults to the Scheffé quadratic model for a mixture
design and samples its constrained simplex. optimize_responses with the mixture
region searches the polytope and returns a blend that sums to one.