# (c) Kevin Dunn, 2010-2026. MIT License.
"""A straight limit proposed from settings: the cut that rules out the settings that cannot be run.
Someone who knows which settings their process cannot run (the corner where it is both
hottest and fastest, say) often knows it better than they can write the inequality. Two
functions turn that knowledge into a linear :class:`~process_improve.experiments.Constraint`
in actual units, written for a person to read (``"T + 25 * P <= 250"``):
- :func:`separating_constraint` takes the settings that cannot be run and those that can,
and proposes the one straight cut that rules out the first and keeps the second, as far
from both as it can be;
- :func:`constraint_through_cuts` takes one corner of the factor box and, for each factor,
how far it can go from that corner with the others held there, and writes the limit
through those cut points.
When no straight cut separates the two sets of settings, :func:`separating_constraint`
raises :class:`NotSeparableError`, which names the settings that lie inside the other set.
Only numpy, ``scipy.optimize`` (``linprog`` with HiGHS, ``nnls`` and SLSQP), pandas (to
accept the settings as a DataFrame) and the standard library are used, and every step is
deterministic, so a proposal is the same on every platform, Pyodide included.
"""
from __future__ import annotations
import keyword
import logging
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from decimal import Decimal, localcontext
from fractions import Fraction
from typing import Any, cast
import numpy as np
import pandas as pd
from scipy.optimize import linprog, minimize, nnls
from process_improve.experiments._lm import suggest_identifier
from process_improve.experiments._region_geometry import round_significant
from process_improve.experiments.designs_constrained import rename_factors
from process_improve.experiments.factor import Constraint, Factor, FactorType
logger = logging.getLogger(__name__)
#: Margin of the separability linear program at or below which no straight cut separates the two sets.
_SEPARABLE = 1e-9
#: Weight of the offset ``c`` in the least-distance problem: small, so the plane found is (to within this
#: fraction) the one of maximum margin, which leaves ``c`` free.
_OFFSET_WEIGHT = 1e-4
#: Relative duality gap and constraint violation (in units of the margin) that certify a least-distance solution.
_CERTIFY_GAP = 1e-8
_CERTIFY_SLACK = 1e-3
#: Coefficients smaller than this fraction of the sum of their sizes are set to zero (coded units).
_SNAP = 1e-6
#: Most the rounded coefficients (coded units, scaled to a sum of sizes of 1) may move from the plane found.
_MOVE = 0.01
#: Most significant digits tried for the coefficients: at 15 digits a float is written as it is.
_MAX_DIGITS = 15
#: Significant digits the scaled coefficients are rounded to before fewer are tried. The solver's last-bit noise
#: differs between platforms, so a coefficient exactly halfway between two candidates (22.5, rounded to two digits)
#: would otherwise round up on one and down on another, and two equal coefficients could round apart.
_CLEAN_DIGITS = 10
#: Most significant digits a number of the limit through cut points is written with.
_CUT_DIGITS = 12
#: Distance (coded units) within which a point counts as a corner of the box or as equal to another point.
_TOL = 1e-12
#: ``scipy.optimize.linprog`` status of a problem with a solution.
_SOLVED = 0
[docs]
class NotSeparableError(ValueError):
"""No straight cut rules out the settings that cannot be run and keeps all those that can.
Raised by :func:`separating_constraint`. It names the settings to blame, when some are:
a setting that cannot be run but lies between settings that can (inside their convex
hull), so any straight cut that rules it out rules out one of them too; and a setting
that can be run but lies between settings that cannot, so any straight cut that rules
them out rules it out too. When the two sets overlap without any one setting lying
inside the other (the four corners of a square marked alternately, say), both lists are
empty.
Parameters
----------
message : str
The explanation shown to the reader.
inside_can : Sequence[int]
Indices into ``cannot`` of the settings that lie inside the hull of ``can``.
inside_cannot : Sequence[int]
Indices into ``can`` of the settings that lie inside the hull of ``cannot``.
Attributes
----------
inside_can : list[int]
As the parameter, in increasing order.
inside_cannot : list[int]
As the parameter, in increasing order.
"""
def __init__(self, message: str, inside_can: Sequence[int] = (), inside_cannot: Sequence[int] = ()) -> None:
super().__init__(message)
self.inside_can = sorted(int(i) for i in inside_can)
self.inside_cannot = sorted(int(i) for i in inside_cannot)
def __reduce__(self) -> tuple[Any, ...]:
"""Pickle with the indices, which ``ValueError`` alone would drop."""
return type(self), (str(self), self.inside_can, self.inside_cannot)
@dataclass(frozen=True)
class _Box:
"""The factor box: the names, and the ranges that set the coded units."""
names: list[str]
low: np.ndarray
high: np.ndarray
@property
def centre(self) -> np.ndarray:
return (self.low + self.high) / 2.0
@property
def half(self) -> np.ndarray:
return (self.high - self.low) / 2.0
def coded(self, actual: np.ndarray) -> np.ndarray:
"""Return actual settings in coded units, ``-1`` at each factor's low end and ``+1`` at its high end."""
return (actual - self.centre) / self.half
def _box(factors: Sequence[Factor]) -> _Box:
"""Return the box of ``factors``, which must be continuous factors whose names can be written in a limit."""
if not factors:
raise ValueError("A limit needs at least one factor.")
names = []
for factor in factors:
if factor.type != FactorType.continuous:
raise ValueError(
f"Factor {factor.name!r} is {factor.type.value}: a limit is proposed over continuous factors only."
)
if not factor.name.isidentifier() or keyword.iskeyword(factor.name):
raise ValueError(
f"Factor {factor.name!r} cannot be named in a limit: its name is not a valid identifier. "
f"Try {suggest_identifier(factor.name)!r}."
)
names.append(factor.name)
if len(set(names)) < len(names):
raise ValueError(f"The factors must have different names; got {names}.")
low = np.array([f.low for f in factors], dtype=float)
high = np.array([f.high for f in factors], dtype=float)
return _Box(names, low, high)
def _as_points(
points: np.ndarray | pd.DataFrame | Sequence[Sequence[float]] | Sequence[Mapping[str, float]], box: _Box, label: str
) -> np.ndarray:
"""Return ``points`` as an ``(n, k)`` array in actual units, checked to be finite and inside the box.
``points`` is a 2-D array-like with one column per factor, in factor order, a pandas
DataFrame with a column per factor name, or a sequence of mappings from factor name
to value.
"""
k = len(box.names)
rows: Any = points
if isinstance(points, pd.DataFrame):
missing = [n for n in box.names if n not in points.columns]
if missing:
raise ValueError(f"The settings in {label!r} have no column for the factors {missing}.")
rows = points[box.names].to_numpy(dtype=float)
elif isinstance(points, Sequence) and points and all(isinstance(p, Mapping) for p in points):
settings = cast("Sequence[Mapping[str, float]]", points)
for p in settings:
missing = [n for n in box.names if n not in p]
if missing:
raise ValueError(f"A setting in {label!r} gives no value for the factors {missing}.")
rows = [[p[n] for n in box.names] for p in settings]
array = np.asarray(rows, dtype=float)
if array.size == 0:
return np.empty((0, k))
if array.ndim != 2 or array.shape[1] != k:
raise ValueError(
f"{label!r} must hold one setting per row, each with a value for the {k} factors {box.names}; "
f"got an array of shape {array.shape}."
)
if not np.all(np.isfinite(array)):
raise ValueError(f"Every value in {label!r} must be a finite number.")
margin = 1e-9 * (box.high - box.low)
outside = np.flatnonzero(np.any((array < box.low - margin) | (array > box.high + margin), axis=1))
if len(outside):
where = _setting(array[outside[0]], box)
raise ValueError(f"Setting {int(outside[0])} of {label!r}, {where}, is outside the factors' ranges.")
return np.clip(array, box.low, box.high)
def _setting(point: np.ndarray, box: _Box) -> str:
"""Return a setting as ``"T = 200, P = 3"`` for a message."""
return ", ".join(f"{name} = {float(value):g}" for name, value in zip(box.names, point, strict=True))
# ---------------------------------------------------------------------------
# Step 1: is there a straight cut at all?
# ---------------------------------------------------------------------------
def _separation(cannot: np.ndarray, can: np.ndarray) -> tuple[np.ndarray, float, float]:
"""Return ``(w, c, t)`` maximising ``t`` with ``w @ p - c >= t`` on ``cannot`` and ``<= -t`` on ``can``.
Points are in coded units and ``-1 <= w_j <= 1``, so ``t`` is bounded; the two sets
can be separated by a straight cut exactly when the largest ``t`` is positive.
"""
k = cannot.shape[1]
ones_p, ones_q = np.ones((len(cannot), 1)), np.ones((len(can), 1))
a_ub = np.vstack([np.hstack([-cannot, ones_p, ones_p]), np.hstack([can, -ones_q, ones_q])])
cost = np.zeros(k + 2)
cost[-1] = -1.0
bounds = [(-1.0, 1.0)] * k + [(None, None), (None, None)]
result = linprog(cost, A_ub=a_ub, b_ub=np.zeros(len(a_ub)), bounds=bounds, method="highs")
if result.status != _SOLVED: # pragma: no cover - bounded and feasible (w = 0, c = 0, t = 0) by construction
raise RuntimeError(f"The separability linear program did not solve: {result.message}")
x = np.asarray(result.x)
return x[:k], float(x[k]), float(x[k + 1])
def _in_hull(point: np.ndarray, others: np.ndarray) -> bool:
"""Return whether ``point`` lies in the convex hull of ``others`` (coded units, all inside the box; some given)."""
if np.any(np.all(np.abs(others - point) <= _TOL, axis=1)):
return True
if np.any(point < others.min(axis=0) - _TOL) or np.any(point > others.max(axis=0) + _TOL):
return False
if np.all(np.abs(np.abs(point) - 1.0) <= _TOL): # a corner of the box is in no hull of other points of the box
return False
a_eq = np.vstack([others.T, np.ones((1, len(others)))])
b_eq = np.append(point, 1.0)
result = linprog(np.zeros(len(others)), A_eq=a_eq, b_eq=b_eq, bounds=(0, None), method="highs")
return bool(result.status == _SOLVED)
def _not_separable(cannot: np.ndarray, can: np.ndarray, box: _Box) -> NotSeparableError:
"""Return the error naming the settings of each set that lie inside the other's hull."""
inside_can = [i for i, p in enumerate(cannot) if _in_hull(p, can)]
inside_cannot = [i for i, q in enumerate(can) if _in_hull(q, cannot)]
reasons = []
if inside_can:
reasons.append(
f"cannot[{inside_can[0]}] ({_setting(box.centre + cannot[inside_can[0]] * box.half, box)}) lies between "
"settings that can be run, so a cut that rules it out rules some of them out too"
)
if inside_cannot:
reasons.append(
f"can[{inside_cannot[0]}] ({_setting(box.centre + can[inside_cannot[0]] * box.half, box)}) lies between "
"settings that cannot be run, so a cut that rules them out rules it out too"
)
if not reasons:
reasons.append("the two sets are interleaved, so every straight cut leaves a setting on the wrong side")
message = "No straight cut rules out the settings that cannot be run and keeps those that can: " + "; ".join(
reasons
)
return NotSeparableError(message + ".", inside_can, inside_cannot)
# ---------------------------------------------------------------------------
# Step 2: the cut of maximum margin, which is unique
# ---------------------------------------------------------------------------
def _least_distance(g: np.ndarray, h: np.ndarray) -> np.ndarray | None:
"""Return ``z`` minimising ``||z||`` subject to ``g @ z >= h``, or None when the solution cannot be certified.
The least-distance problem is solved through the non-negative least-squares problem
``min ||E u - f||, u >= 0`` with ``E = [g.T; h]`` and ``f = (0, ..., 0, 1)`` (Lawson and
Hanson, 1974, chapter 23): ``z = g.T u / (1 - h @ u)``. The answer is kept only when it
meets every inequality and its duality gap is zero, which certifies it optimal: on
degenerate problems, such as the corners of a box, the solver's answer can miss the
optimum.
References
----------
Lawson, C. L. and Hanson, R. J. (1974) *Solving Least Squares Problems*. Prentice-Hall.
"""
e = np.vstack([g.T, h[None, :]])
f = np.zeros(g.shape[1] + 1)
f[-1] = 1.0
try:
u, _ = nnls(e, f)
except RuntimeError: # pragma: no cover - an iteration limit, in versions of scipy that raise for it
return None
denominator = 1.0 - float(h @ u)
if denominator <= 1e-12: # the inequalities have no common point
return None
z = g.T @ u / denominator
size = float(z @ z)
gap = size - float(u @ h) / denominator
if not (size > 0.0 and np.min(g @ z - h) >= -_CERTIFY_SLACK and abs(gap) <= _CERTIFY_GAP * size):
return None
return z
def _max_margin_plane(cannot: np.ndarray, can: np.ndarray, start: tuple[np.ndarray, float] | None = None) -> np.ndarray:
"""Return ``w`` of the plane ``w @ x = c`` of maximum margin between two separable sets (coded units).
It minimises ``||w||`` subject to ``w @ p - c >= 1`` on ``cannot`` and ``w @ q - c <= -1``
on ``can``: the support vector machine of hard margin, whose ``w`` is unique. The
problem is a least-distance problem once ``c`` is carried as a variable with the small
weight ``_OFFSET_WEIGHT`` (it is free in the original), and is solved through
:func:`_least_distance`; SLSQP from the separating plane ``start`` takes over when that
answer cannot be certified. Coefficients below ``_SNAP`` of the sum of their sizes are
set to zero, smallest first and each only while the plane still separates the sets, so
a factor the sets do not depend on gets exactly zero.
Parameters
----------
cannot, can : np.ndarray
``(n, k)`` and ``(m, k)`` points in coded units, separable by a straight cut.
start : tuple[np.ndarray, float] or None
A separating plane ``(w, c)`` with ``w @ p - c > 0 > w @ q - c``, from the
separability linear program; found here when not given.
Returns
-------
np.ndarray
``w``, scaled so the margin is 1 on each side.
"""
k = cannot.shape[1]
weight = _OFFSET_WEIGHT
g = np.vstack(
[
np.hstack([cannot, -np.ones((len(cannot), 1)) / weight]),
np.hstack([-can, np.ones((len(can), 1)) / weight]),
]
)
z = _least_distance(g, np.ones(len(g)))
w = None if z is None else z[:k]
if w is None or not _separates(w, cannot, can):
if start is None:
w_lp, c_lp, t = _separation(cannot, can)
start = (w_lp / t, c_lp / t)
w = _slsqp_plane(cannot, can, start)
small = np.flatnonzero(np.abs(w) < _SNAP * np.abs(w).sum())
for j in small[np.argsort(np.abs(w[small]), kind="stable")]: # smallest first, each only if the cut still holds
snapped = w.copy()
snapped[j] = 0.0
if _separates(snapped, cannot, can):
w = snapped
gap = float(np.min(cannot @ w) - np.max(can @ w))
return w * 2.0 / gap
def _separates(w: np.ndarray, cannot: np.ndarray, can: np.ndarray) -> bool:
"""Return whether the direction ``w`` puts every point of ``cannot`` beyond every point of ``can``."""
return bool(np.all(np.isfinite(w)) and np.min(cannot @ w) > np.max(can @ w))
def _slsqp_plane(cannot: np.ndarray, can: np.ndarray, start: tuple[np.ndarray, float]) -> np.ndarray:
"""Return ``w`` of the plane of maximum margin by SLSQP from a separating plane, or that plane's ``w``."""
k = cannot.shape[1]
signed = np.vstack([np.hstack([cannot, -np.ones((len(cannot), 1))]), np.hstack([-can, np.ones((len(can), 1))])])
result = minimize(
lambda z: float(z[:k] @ z[:k]),
np.append(start[0], start[1]),
jac=lambda z: np.append(2.0 * z[:k], 0.0),
constraints=[{"type": "ineq", "fun": lambda z: signed @ z - 1.0, "jac": lambda _z: signed}],
method="SLSQP",
options={"ftol": 1e-15, "maxiter": 1000},
)
w = np.asarray(result.x[:k], dtype=float)
if _separates(w, cannot, can):
return w
logger.warning("The plane of maximum margin was not found; the separating plane of the linear program is used.")
return np.asarray(start[0], dtype=float)
# ---------------------------------------------------------------------------
# Writing the cut for a person: rounding, the right-hand side, the text
# ---------------------------------------------------------------------------
def _exact(value: float) -> Fraction:
"""Return a float as the decimal its shortest representation shows, exactly."""
return Fraction(repr(float(value)))
def _decimal_text(value: Fraction) -> str:
"""Return a terminating decimal in positional notation, a whole number without ``".0"``."""
sign = "-" if value < 0 else ""
value = abs(value)
if value.denominator == 1:
return f"{sign}{value.numerator}"
places = 0
while (value * 10**places).denominator != 1:
places += 1
digits = str((value * 10**places).numerator).rjust(places + 1, "0")
return f"{sign}{digits[:-places]}.{digits[-places:]}"
def _terminates(value: Fraction) -> bool:
"""Return whether a fraction has a finite decimal expansion (its denominator has no prime factor but 2 and 5)."""
denominator = value.denominator
for prime in (2, 5):
while denominator % prime == 0:
denominator //= prime
return denominator == 1
def _digits_written(coefficients: Sequence[Fraction]) -> int:
"""Return the digits it takes to write the coefficients: none for a 1, which is left out, or for a 0."""
count = 0
for c in coefficients:
if c != 0 and abs(c) != 1:
count += sum(ch.isdigit() for ch in _decimal_text(abs(c)))
return count
def _significant(value: Fraction) -> int:
"""Return the significant digits of a terminating decimal."""
return len(_decimal_text(abs(value)).replace(".", "").strip("0")) or 1
def _roundest(low: Fraction, high: Fraction) -> Fraction:
"""Return the roundest number in the middle fifth of ``(low, high)``, the one nearest the middle among equals.
The roundest number is a multiple of the largest step ``1``, ``2`` or ``5`` times a
power of ten that has a multiple there: zero when the interval holds it, then multiples
of 1000 before 500, 200, 100, 50 and so on. Equally near multiples go to the smaller
in size. The arithmetic is exact.
"""
middle = (low + high) / 2
half_width = (high - low) / 10
left, right = middle - half_width, middle + half_width
if left <= 0 <= right:
return Fraction(0)
top = max(abs(left), abs(right))
# The power of ten at or below ``top``, exactly: the digits of its numerator and denominator place it within one.
exponent = len(str(top.numerator)) - len(str(top.denominator))
if Fraction(10) ** exponent > top:
exponent -= 1
while True: # a step no wider than the interval always has a multiple in it, so this ends
for factor in (5, 2, 1):
step = factor * Fraction(10) ** exponent
first, last = math.ceil(left / step), math.floor(right / step)
if first <= last:
near = {min(max(m, first), last) for m in (math.floor(middle / step), math.ceil(middle / step))}
return min((m * step for m in near), key=lambda v: (abs(v - middle), abs(v), v))
exponent -= 1
def _gap(
coefficients: np.ndarray, cannot: np.ndarray, can: np.ndarray, exact: Sequence[Fraction]
) -> tuple[Fraction, Fraction] | None:
"""Return the exact ``(largest over can, smallest over cannot)`` of ``coefficients @ x``, or None if they overlap.
The values are found in floating point, and only the points within rounding error of
each extreme are recomputed exactly, from the decimals their floats show.
"""
over_cannot, over_can = cannot @ coefficients, can @ coefficients
reach = np.abs(coefficients) @ np.max(np.abs(np.vstack([cannot, can])), axis=0)
tol = 1e-9 * max(float(reach), 1e-300)
if over_cannot.min() < over_can.max() - tol:
return None
def dot(point: np.ndarray) -> Fraction:
return sum((c * _exact(x) for c, x in zip(exact, point, strict=True) if c), Fraction(0))
low = max(dot(can[i]) for i in np.flatnonzero(over_can >= over_can.max() - tol))
high = min(dot(cannot[i]) for i in np.flatnonzero(over_cannot <= over_cannot.min() + tol))
return (low, high) if low < high else None
def _rounded(a: np.ndarray, half: np.ndarray, cannot: np.ndarray, can: np.ndarray) -> tuple[list[Fraction], Fraction]:
"""Round the plane ``a @ x = .`` (actual units) for a reader, keeping every point on its side.
Two scalings are tried: the smallest coefficient made 1, and the largest; each is
rounded by :func:`_round_scaling`. The scaling whose coefficients take fewer digits to
write is kept, the smallest made 1 on a tie.
Returns
-------
tuple[list[Fraction], Fraction]
The coefficients and right-hand side, exactly as they are written; the points of
``cannot`` lie above it, those of ``can`` below.
"""
target = a * half / np.abs(a * half).sum()
sizes = np.abs(a[a != 0])
written = [_round_scaling(a / scale, half, target, cannot, can) for scale in (sizes.min(), sizes.max())]
return min(written, key=lambda form: _digits_written(form[0]))
def _round_scaling(
scaled: np.ndarray, half: np.ndarray, target: np.ndarray, cannot: np.ndarray, can: np.ndarray
) -> tuple[list[Fraction], Fraction]:
"""Round the coefficients ``scaled`` to the fewest significant digits that keep the plane where it was.
The digits are the fewest at which the coefficients, in coded units and scaled to a
sum of sizes of 1, move by at most ``_MOVE`` in total from ``target`` and still put
every point of ``cannot`` above every point of ``can``, which is checked exactly (see
:func:`_gap`). The right-hand side is then the roundest number in the middle fifth of
the gap between the two (see :func:`_roundest`), so every point lies strictly on its
side.
Up to ``_CLEAN_DIGITS`` digits, the coefficients rounded are ``scaled`` first rounded to
``_CLEAN_DIGITS`` digits, which removes the solver's last-bit noise: an exact tie then
rounds the same way on every platform (half to even), and equal coefficients round
alike. Only a cut that needs more digits than that is rounded from ``scaled`` itself.
"""
clean = round_significant(scaled, _CLEAN_DIGITS)
for digits in range(1, _MAX_DIGITS + 1):
coefficients = round_significant(clean if digits <= _CLEAN_DIGITS else scaled, digits)
coded = coefficients * half
if np.abs(coded / np.abs(coded).sum() - target).sum() > _MOVE + 1e-12:
continue
exact = [_exact(c) for c in coefficients]
gap = _gap(coefficients, cannot, can, exact)
if gap is not None:
return exact, _roundest(*gap)
raise RuntimeError( # pragma: no cover - at 15 digits the plane is where it was, and separates by its margin
"The proposed limit could not be written without moving a setting to the wrong side."
)
def _expression(names: Sequence[str], coefficients: Sequence[Fraction], rhs: Fraction) -> str:
"""Write ``coefficients @ x <= rhs`` (the excluded side above) as a limit's canonical text.
The factors keep their order; the first term is made positive, turning ``<=`` into
``>=`` when its coefficient was negative; a coefficient of 1 is left out, and whole
numbers are written without ``".0"``. The text is then canonicalised by parsing it and
writing it back (``ast.unparse``), which also checks it is a limit the library reads.
"""
first = next(c for c in coefficients if c != 0)
operator = "<="
if first < 0:
coefficients, rhs, operator = [-c for c in coefficients], -rhs, ">="
terms: list[str] = []
for name, c in zip(names, coefficients, strict=True):
if c == 0:
continue
size = _decimal_text(abs(c))
term = name if size == "1" else f"{size} * {name}"
terms.append(term if not terms else f"{'-' if c < 0 else '+'} {term}")
return rename_factors(f"{' '.join(terms)} {operator} {_decimal_text(rhs)}", {})
# ---------------------------------------------------------------------------
# The two public functions
# ---------------------------------------------------------------------------
[docs]
def separating_constraint(
factors: Sequence[Factor],
cannot: np.ndarray | pd.DataFrame | Sequence[Sequence[float]] | Sequence[Mapping[str, float]],
can: np.ndarray | pd.DataFrame | Sequence[Sequence[float]] | Sequence[Mapping[str, float]],
) -> Constraint:
"""Propose the straight limit that rules out the settings that cannot be run and keeps those that can.
The limit is found in two steps, in coded units (each factor's range mapped to
``[-1, 1]``, so a factor counts by its range, not by its units):
1. Whether any straight cut separates the two sets: a linear program maximises the
margin ``t`` of a plane ``w @ x = c`` with every ``|w_j| <= 1``. When the best margin
is not positive, :class:`NotSeparableError` is raised; one small linear program per
setting finds the settings that lie inside the other set's convex hull, to name them.
2. The cut itself: the plane of maximum margin, which lies as far from the nearest
settings of both sets as it can (the hard-margin support vector machine). It is
unique, so a symmetric pattern gives a symmetric cut, and a factor the pattern does
not depend on gets exactly zero and drops out of the limit.
The plane is then written for a reader, in actual units. Its coefficients are scaled so
the smallest is 1, and also so the largest is 1, and each scaling is rounded to the
fewest significant digits at which the coefficients (coded, scaled to a sum of sizes of
1) move by at most 1% in total and every setting stays on its side; the scaling whose
coefficients take fewer digits to write is kept. The rounding starts from the
coefficients rounded to 10 significant digits, so the solver's last-bit noise, which
differs between platforms, cannot decide which way a coefficient exactly halfway
between two candidates goes: it goes to the even one, and equal coefficients stay
equal. The right-hand side is the roundest number (a multiple of 1, 2 or 5 times a
power of ten, as large as fits) in the middle fifth of the gap between the two sets,
nearest its middle. Every setting is then checked, in exact decimal arithmetic, to be
strictly on its side.
Parameters
----------
factors : Sequence[Factor]
The continuous factors the settings are given for; their ranges set the coded
units, and their names are written into the limit. A categorical factor is
refused: for settings at one level, propose the limit over the continuous factors
and write it with that level, as ``not (K == "B" and T + 25 * P > 250)``.
cannot : np.ndarray, pandas.DataFrame, or a sequence of sequences or of mappings
The settings that cannot be run, in actual units: one row per setting with a value
per factor in factor order (a 2-D array or a list of lists), a pandas DataFrame
with a column per factor, or a list of ``{name: value}`` mappings. Each must lie
within the factors' ranges.
can : np.ndarray, pandas.DataFrame, or a sequence of sequences or of mappings
The settings that can be run, in the same forms. Settings in neither list (another
group of marked corners, say) do not count either way.
Returns
-------
Constraint
A linear constraint in actual units, such as ``"T + 25 * P <= 250"``, under which
every setting of ``cannot`` is ruled out and every setting of ``can`` is allowed,
none of them on the boundary. Its first term is positive.
Raises
------
NotSeparableError
When no straight cut separates the two sets.
ValueError
When either set is empty, a setting is outside the factors' ranges or has the
wrong number of values, or a factor is not continuous or has a name that cannot be
written in a limit.
See Also
--------
constraint_through_cuts : the limit through a cut point on each factor from one corner.
Examples
--------
>>> import itertools
>>> factors = [
... Factor(name="T", low=150, high=200),
... Factor(name="P", low=1, high=3),
... Factor(name="F", low=10, high=20),
... ]
>>> corners = [list(c) for c in itertools.product((150, 200), (1, 3), (10, 20))]
>>> cannot = [c for c in corners if c[0] == 200 and c[1] == 3] # T and P both high
>>> can = [c for c in corners if c not in cannot] + [[175, 2, 15]] # the rest, and the centre
>>> separating_constraint(factors, cannot, can).expression
'T + 25 * P <= 250'
"""
box = _box(factors)
cannot_actual, can_actual = _as_points(cannot, box, "cannot"), _as_points(can, box, "can")
if not len(cannot_actual):
raise ValueError("Give at least one setting that cannot be run: with none, there is nothing to rule out.")
if not len(can_actual):
raise ValueError(
"Give at least one setting that can be run: with none, any cut would do, including one that leaves "
"nothing to run."
)
cannot_coded, can_coded = box.coded(cannot_actual), box.coded(can_actual)
w_lp, c_lp, t = _separation(cannot_coded, can_coded)
if t <= _SEPARABLE:
raise _not_separable(cannot_coded, can_coded, box)
w = _max_margin_plane(cannot_coded, can_coded, start=(w_lp / t, c_lp / t))
coefficients, rhs = _rounded(w / box.half, box.half, cannot_actual, can_actual)
return Constraint(expression=_expression(box.names, coefficients, rhs), type="linear")
[docs]
def constraint_through_cuts(
factors: Sequence[Factor],
corner: Mapping[str, float] | Sequence[float],
cuts: Mapping[str, float | None],
) -> Constraint:
"""Write the straight limit that cuts a corner off the box at given values of the factors.
From the ``corner`` (a setting that cannot be run), each limited factor can go up to
(or down to) its cut value with the other factors held at the corner. The limit is the
plane through those cut points, ``sum((X_j - v_j) / (cut_j - v_j)) >= 1`` over the
limited factors, where ``v`` is the corner: it rules out the corner and the settings
near it, keeps the cut points on its boundary, and does not depend on a factor whose
cut is ``None``. The arithmetic is exact (each value is read as the decimal its float
shows), so round inputs give whole-number coefficients. The limit is scaled so its
smallest coefficient is 1, so its largest is 1, or to whole numbers, keeping whichever
of these writes the coefficients exactly with the fewest digits; it is written as
:func:`separating_constraint` writes it.
Parameters
----------
factors : Sequence[Factor]
The continuous factors.
corner : Mapping[str, float] or Sequence[float]
A corner of the box, in actual units: each factor at its low or its high value, as
``{name: value}`` or a sequence in factor order.
cuts : Mapping[str, float or None]
For each factor, the value it can reach from the corner with the others held there,
or ``None`` (or no entry) when the factor does not limit this. A cut must lie
inside the factor's range and differ from the corner's value.
Returns
-------
Constraint
A linear constraint in actual units, such as ``"T + 20 * P <= 250"``.
Raises
------
ValueError
When a factor is not continuous or its name cannot be written in a limit, the
corner is not a corner of the box, ``cuts`` names a factor that is not given, no
factor is cut, or a cut lies outside its factor's range or at or beyond the corner.
See Also
--------
separating_constraint : the limit proposed from settings that cannot and can be run.
Examples
--------
>>> factors = [Factor(name="T", low=150, high=200), Factor(name="P", low=1, high=3)]
>>> constraint_through_cuts(factors, {"T": 200, "P": 3}, {"T": 190, "P": 2.5}).expression
'T + 20 * P <= 250'
"""
box = _box(factors)
vertex = _corner(corner, box)
unknown = sorted(set(cuts) - set(box.names))
if unknown:
raise ValueError(f"The cuts name {unknown}, which are not among the factors {box.names}.")
alpha: list[Fraction] = []
for name, v, low, high in zip(box.names, vertex, box.low, box.high, strict=True):
cut = cuts.get(name)
if cut is None:
alpha.append(Fraction(0))
continue
cut = float(cut)
if not math.isfinite(cut) or not low <= cut <= high:
raise ValueError(f"The cut of {name!r} at {cut:g} is outside its range, {low:g} to {high:g}.")
if (cut >= v) if v == high else (cut <= v):
raise ValueError(
f"The cut of {name!r} at {cut:g} is at or beyond the corner's {name} = {v:g}: it must lie between "
"the corner and the other end of the range."
)
alpha.append(1 / (_exact(cut) - _exact(v)))
if not any(alpha):
raise ValueError("Give a cut for at least one factor: with none, there is no limit to write.")
# sum(alpha_j (X_j - v_j)) >= 1 keeps the settings away from the corner; written as a @ X <= b, with a = -alpha.
a = [-x for x in alpha]
b = -(1 + sum((x * _exact(v) for x, v in zip(alpha, vertex, strict=True)), Fraction(0)))
coefficients, rhs = _exact_scaling(a, b)
return Constraint(expression=_expression(box.names, coefficients, rhs), type="linear")
def _corner(corner: Mapping[str, float] | Sequence[float], box: _Box) -> list[float]:
"""Return the corner's values in factor order, each checked to be its factor's low or high value."""
if isinstance(corner, Mapping):
missing = [n for n in box.names if n not in corner]
if missing:
raise ValueError(f"The corner gives no value for the factors {missing}.")
values = [corner[n] for n in box.names]
else:
values = list(corner)
if len(values) != len(box.names):
raise ValueError(f"The corner must give {len(box.names)} values, one per factor {box.names}.")
out = []
for name, value, low, high in zip(box.names, values, box.low, box.high, strict=True):
tol = 1e-9 * (high - low)
if abs(float(value) - low) <= tol:
out.append(float(low))
elif abs(float(value) - high) <= tol:
out.append(float(high))
else:
raise ValueError(
f"The corner's {name} = {float(value):g} is not a corner of the box: it must be {low:g} or {high:g}."
)
return out
def _exact_scaling(a: list[Fraction], b: Fraction) -> tuple[list[Fraction], Fraction]:
"""Scale an exact limit ``a @ x <= b`` to the form that writes its coefficients exactly with the fewest digits.
The candidates are the smallest coefficient made 1, the largest made 1, and whole
numbers with no common factor; one counts only when each of its numbers is a
terminating decimal of at most ``_CUT_DIGITS`` significant digits. Ties go to the
candidates in that order. When none counts, the first is rounded to ``_CUT_DIGITS``
significant digits.
"""
sizes = [abs(x) for x in a if x]
candidates = [(1 / min(sizes)), (1 / max(sizes))]
denominators = math.lcm(*(x.denominator for x in [*a, b]))
numerators = math.gcd(*(int(x * denominators) for x in [*a, b] if x))
candidates.append(Fraction(denominators, numerators))
best: tuple[int, list[Fraction], Fraction] | None = None
for scale in candidates:
numbers = [x * scale for x in a], b * scale
every = [*numbers[0], numbers[1]]
if all(_terminates(x) and _significant(x) <= _CUT_DIGITS for x in every if x):
written = _digits_written(numbers[0])
if best is None or written < best[0]:
best = (written, *numbers)
if best is not None:
return best[1], best[2]
scale = candidates[0]
return [_round_fraction(x * scale) for x in a], _round_fraction(b * scale)
def _round_fraction(value: Fraction, digits: int = _CUT_DIGITS) -> Fraction:
"""Return ``value`` rounded to ``digits`` significant digits, as an exact terminating decimal."""
with localcontext() as context:
context.prec = digits
return Fraction(Decimal(value.numerator) / Decimal(value.denominator))