"""
Classes for defining various constraints that can be applied to the fitting run. Used when incorporating qualitative
data into the fit
"""
from .printing import PybnfError
import pyparsing as pp
import numpy as np
import re
import logging
from math import erf
logger = logging.getLogger(__name__)
def _sigmoid(x):
"""The logistic function ``1 / (1 + e^{-x})``, evaluated in the numerically stable branch
(``e^{x}/(1+e^{x})`` for ``x < 0``) so large ``|x|`` never overflows. Scalar in, scalar out --
the local slope factor of the logit penalty."""
if x >= 0:
return 1.0 / (1.0 + np.exp(-x))
ex = np.exp(x)
return ex / (1.0 + ex)
[docs]
class ConstraintSet:
"""
Represents the set of all constraints provided in one prop file
"""
def __init__(self, base_model, base_suffix):
self.constraints = []
self.base_suffix = base_suffix
self.base_model = base_model
[docs]
def total_penalty(self, sim_data_dict, pset_values=None):
"""
Evaluate the sim_data_dict against all constraints, and return the total penalty
:param sim_data_dict: Dictionary of the form {modelname: {suffix1: Data1}} containing the simulated data objects
:param pset_values: The objective's live ``{name: value}`` map, threaded so a constraint with an
estimated scale parameter reads its current value; ``None`` uses the literal.
:return:
"""
return sum([c.penalty(sim_data_dict, pset_values=pset_values) for c in self.constraints])
[docs]
def number_failed(self, sim_data_dict):
"""
Return the number of failed (unsatisfied) constraints, i.e. those with a
nonzero penalty. (Matches the method name; the body counts 1 per nonzero
penalty.)
:param sim_data_dict:
:return:
"""
return sum([0 if c.penalty(sim_data_dict) == 0 else 1 for c in self.constraints])
[docs]
def output_itemized_eval(self, sim_data_dict, output_dir):
"""
Output a text file in which we evaluate the given simulation data: each line of the text file contains
the penalty associated with the corresponding line in the constraint file.
:param sim_data_dict:
:param output_dir: Directory where the file should be saved
:return:
"""
with open(f'{output_dir}/{self.base_suffix}_constraint_eval.txt', 'w') as out:
for c in self.constraints:
out.write(f'{c.penalty(sim_data_dict)}\n')
[docs]
def load_constraint_file(self, filename, scale=1.0, qualitative_loss='auto', qualitative_scale=None):
"""
Parse the constraint file filename and load them all into my constraint list
:param filename: Path of constraint file
:param scale: Factor by which we multiply all constraint weights
:param qualitative_loss: One of 'auto', 'hinge', 'probit', 'logit'. 'auto' (default) keeps
each constraint's authored penalty family (the keyword-driven per-constraint selection);
any other value coerces every constraint to that family via
:meth:`Constraint.coerce_penalty_model` -- a benchmarking convenience.
:param qualitative_scale: If set, the name of a free parameter every constraint's scale is
tied to (:meth:`Constraint.bind_scale_param`), so a fit estimates the qualitative scale
jointly. Applied after any ``qualitative_loss`` coercion.
"""
logger.info(f'Loading constraints for {self.base_model} suffix {self.base_suffix} from {filename}')
with open(filename) as f:
linenum = 0
for line in f:
linenum += 1
if re.match(r'\s*(#|$)', line): # Blank or comment
continue
# Parse the line
try:
p = self.parse_constraint_line(line)
except pp.ParseBaseException:
raise PybnfError("Unable to parse constraint '%s' at line %i of %s" % (line, linenum, filename))
# Convert all the attributes of the parsed line into args to use to create the constraint
if p.ineq:
# Normal case - for everything except "split at" constraint
try:
quant1 = float(p.ineq[0])
except ValueError:
quant1 = p.ineq[0]
sign = p.ineq[1]
try:
quant2 = float(p.ineq[2])
except ValueError:
quant2 = p.ineq[2]
if p.weight_expr:
weight = float(p.weight_expr.weight)
if p.weight_expr.altpenalty:
altpenalty = p.weight_expr.altpenalty
for i in (0, 2):
try:
altpenalty[i] = float(altpenalty[i])
except ValueError:
pass # If float conversion fails, it's a variable name, so leave it
else:
altpenalty = None
minpenalty = float(p.weight_expr.min) if p.weight_expr.min else 0.
pmin = None
pmax = None
tolerance = None
logit_scale = None
if p.likelihood_expr:
# Should not happen if parsing is working right
raise ValueError('Parsed with both weight_expr and likelihood_expr')
elif p.likelihood_expr:
if p.likelihood_expr.confidence:
confidence = float(p.likelihood_expr.confidence)
pmin = (1. - confidence) / 2.
pmax = 1. - pmin
else:
pmin = float(p.likelihood_expr.pmin)
pmax = float(p.likelihood_expr.pmax)
tolerance = float(p.likelihood_expr.tolerance) if p.likelihood_expr.tolerance else 0.
weight = None
altpenalty = None
minpenalty = None
logit_scale = None
elif p.logit_expr:
# Logit (Miller et al. 2025) softplus penalty. `scale` is the s of ln(1+e^{diff/s}).
# Optional pmin/pmax add probit-style label smoothing (default off = faithful to
# the arXiv derivation).
logit_scale = float(p.logit_expr.scale)
if p.logit_expr.pmin:
pmin = float(p.logit_expr.pmin)
pmax = float(p.logit_expr.pmax)
else:
pmin = None
pmax = None
tolerance = None
weight = None
altpenalty = None
minpenalty = None
else:
weight = 1.
altpenalty = None
minpenalty = 0.
pmin = None
pmax = None
tolerance = None
logit_scale = None
if weight is not None:
weight *= scale # Scale the weight by the specified factor
# Check the constraint type based on the parse object, extract the constraint-type-specific args, and
# make the constraint
if p.split:
# Special case for "split at" combining inequality and enforcement clauses
quant1 = p.split.obs1
quant2 = p.split.obs2
if len(p.split.at1[1]) == 1:
atvar1 = None
atval1 = float(p.split.at1[1][0])
else:
atvar1 = p.split.at1[1][0]
atval1 = float(p.split.at1[1][1])
if len(p.split.at2[1]) == 1:
atvar2 = None
atval2 = float(p.split.at2[1][0])
else:
atvar2 = p.split.at2[1][0]
atval2 = float(p.split.at2[1][1])
sign = p.split.sign
repeat = (len(p.split.at1) >= 3 and p.split.at1[2] == 'everytime') or \
(len(p.split.at2) >= 3 and p.split.at2[2] == 'everytime')
before1 = (len(p.split.at1) >= 3 and p.split.at1[-1] == 'before')
before2 = (len(p.split.at2) >= 3 and p.split.at2[-1] == 'before')
con = SplitAtConstraint(quant1, atvar1, atval1, sign, quant2, atvar2, atval2, self.base_model,
self.base_suffix, weight, altpenalty=altpenalty, minpenalty=minpenalty,
repeat=repeat, before1=before1, before2=before2, pmin=pmin, pmax=pmax,
tolerance=tolerance, scale=logit_scale)
elif p.enforce[0] == 'at':
if len(p.enforce[1]) == 1:
atval = float(p.enforce[1][0])
atvar = None
else:
atvar = p.enforce[1][0]
atval = float(p.enforce[1][1])
repeat = (len(p.enforce) >= 3 and p.enforce[2] == 'everytime')
before = (len(p.enforce) >= 3 and p.enforce[-1] == 'before')
con = AtConstraint(quant1, sign, quant2, self.base_model, self.base_suffix, weight, altpenalty=altpenalty,
minpenalty=minpenalty, atvar=atvar, atval=atval, repeat=repeat, before=before,
pmin=pmin, pmax=pmax, tolerance=tolerance, scale=logit_scale)
elif p.enforce[0] == 'between' or p.enforce[0] == 'once between':
once = (p.enforce[0] == 'once between')
if len(p.enforce[1]) == 1:
startval = float(p.enforce[1][0])
startvar = None
else:
startvar = p.enforce[1][0]
startval = float(p.enforce[1][1])
if len(p.enforce[2]) == 1:
endval = float(p.enforce[2][0])
endvar = None
else:
endvar = p.enforce[2][0]
endval = float(p.enforce[2][1])
con = BetweenConstraint(quant1, sign, quant2, self.base_model, self.base_suffix, weight, altpenalty=altpenalty,
minpenalty=minpenalty, startvar=startvar, startval=startval, endvar=endvar,
endval=endval, pmin=pmin, pmax=pmax, tolerance=tolerance, once=once, scale=logit_scale)
elif p.enforce[0] == 'always':
con = AlwaysConstraint(quant1, sign, quant2, self.base_model, self.base_suffix, weight, altpenalty=altpenalty,
minpenalty=minpenalty, pmin=pmin, pmax=pmax, tolerance=tolerance, scale=logit_scale)
elif p.enforce[0] == 'once':
con = OnceConstraint(quant1, sign, quant2, self.base_model, self.base_suffix, weight, altpenalty,
minpenalty, pmin, pmax, tolerance, scale=logit_scale)
else:
raise RuntimeError(f'Unknown enforcement keyword {p.enforce[0]}')
con.source_line = line.strip()
if qualitative_loss != 'auto':
con.coerce_penalty_model(qualitative_loss)
if qualitative_scale is not None:
con.bind_scale_param(qualitative_scale)
self.constraints.append(con)
logger.info('Loaded %i constraints' % len(self.constraints))
@staticmethod
def parse_constraint_line(line):
obs = pp.Word(pp.alphas, pp.alphanums+'_.')
point = pp.Literal(".")
e = pp.CaselessLiteral("E")
number = pp.Combine(pp.Word("+-" + pp.nums, pp.nums) +
pp.Optional(point + pp.Optional(pp.Word(pp.nums))) +
pp.Optional(e + pp.Word("+-" + pp.nums, pp.nums)))
iop = pp.one_of("< <= > >=")
ineq0 = obs + iop + (obs ^ number)
ineq1 = number + iop + obs
ineq = ineq0 ^ ineq1
equals = pp.Suppress('=')
obs_crit = obs - equals - number
enforce_crit = number | obs_crit
enforce_at = pp.CaselessLiteral('at') - pp.Group(enforce_crit) - pp.Optional(pp.one_of('everytime first', caseless=True)) -\
pp.Optional(pp.CaselessLiteral('before'))
enforce_between = pp.Or([pp.CaselessLiteral('once between'), pp.CaselessLiteral('between')]) - \
pp.Group(enforce_crit) - pp.Suppress(',') - pp.Group(enforce_crit)
enforce_other = pp.one_of('once always', caseless=True)
enforce = enforce_at ^ enforce_between ^ enforce_other
split = obs.set_results_name('obs1') - enforce_at.set_results_name('at1') - iop.set_results_name('sign') - \
obs.set_results_name('obs2') - enforce_at.set_results_name('at2')
min = pp.CaselessLiteral('min') - number.set_results_name('min')
penalty = pp.CaselessLiteral('altpenalty') - pp.Group(ineq).set_results_name('altpenalty')
wt_expr = number.set_results_name('weight') - pp.Optional(penalty) - pp.Optional(min)
weight = pp.CaselessLiteral('weight') - wt_expr
confidence = pp.CaselessLiteral('confidence') - number.set_results_name('confidence')
pmin_pmax = pp.CaselessLiteral('pmin') - number.set_results_name('pmin') - \
pp.CaselessLiteral('pmax') - number.set_results_name('pmax')
likelihood = (confidence ^ pmin_pmax) - \
pp.Optional(pp.CaselessLiteral('tolerance') - number.set_results_name('tolerance'))
# Logit (Miller et al. 2025) penalty: `logit scale <s>` selects the softplus loss
# ln(1 + e^{difference/s}); the optional `pmin/pmax` add probit-style label smoothing for
# apples-to-apples benchmarking (default off).
logit = pp.CaselessLiteral('logit') - pp.CaselessLiteral('scale') - number.set_results_name('scale') - \
pp.Optional(pmin_pmax)
comment = pp.Suppress(pp.Literal('#') - pp.ZeroOrMore(pp.Word(pp.printables)))
constraint = ((pp.Group(ineq).set_results_name('ineq') + pp.Group(enforce).set_results_name('enforce')) ^
pp.Group(split).set_results_name('split')) + \
pp.Optional(pp.Group(weight).set_results_name('weight_expr') ^
pp.Group(likelihood).set_results_name('likelihood_expr') ^
pp.Group(logit).set_results_name('logit_expr')) + pp.Optional(comment)
return constraint.parse_string(line, parse_all=True)
[docs]
class Constraint:
"""
Abstract class representing an optimization constraint with a penalty for violating the constraint
"""
def __init__(self, quant1, sign, quant2, base_model, base_suffix, weight, altpenalty=None, minpenalty=0.,
pmin=None, pmax=None, tolerance=None, scale=None):
"""
Create a constraint of the form (quant1) (sign) (quant2)
Depending on which parameters are passed, the constraint automatically chooses a model for penalty calculation
If weight and/or altpenalty are passed, uses the static (hinge) penalty method.
If confidence and/or tolerance are passed, uses the log likelihood method with a Gaussian CDF (probit).
If scale is passed, uses the logit softplus penalty (Miller et al. 2025).
The three penalty models should not be mixed.
:param quant1: String observable name or float. String could be in the form suffix.Observable to refer to any
observable in the fitting run.
:param sign: One of '<', '<=', '>', '>='
:param quant2: String observable name or float
:param base_suffix: Suffix to assume for observables that dont use the 'suffix.Observable' notation
:param weight: Weight of the constraint, to multiply the extent of violation
:param altpenalty: a 3-tuple [quantity, sign, quantity], giving an inequality to use instead of the regular
constraint inequality to calculate the penalty
:param minpenalty: The minimum penalty that must be applied if the constraint is violated, regardless of how
low the extent of violation is.
:param pmin: Using the log likelihood penalty, the minimum possible probability of this constraint.
Represents the probability that the constraint is satisfied regardless of the output of the model
:param pmax: Using the log likelihood penalty, the maximum possible probability of this constraint.
Represents the probability that the constraint is violated regardless of the output of the model
:param tolerance: Using the log likelihood penalty, the standard deviation of the Gaussian CDF.
:param scale: Using the logit penalty, the scale s of the softplus loss ln(1 + e^{difference/s}).
Must be positive. Smaller s approaches the hinge (weight = 1/s); larger s is softer.
"""
# Flip the inequality if it's a '>', so we can always assume a '<'
if sign in ('>', '>='):
self.quant1 = quant2
self.quant2 = quant1
else:
self.quant1 = quant1
self.quant2 = quant2
if '=' in sign:
self.or_equal = True
else:
self.or_equal = False
self.base_model = base_model
self.base_suffix = base_suffix
self.source_line = '' # Original text from .prop file, set during parsing
self.weight = weight
self.min_penalty = minpenalty
if altpenalty:
# Also force the altpenalty to be a '<'.
# Never matters whether this is an 'or equal' or not.
alt1, altsign, alt2 = altpenalty
if altsign in ('>', '>='):
self.alt1 = alt2
self.alt2 = alt1
else:
self.alt1 = alt1
self.alt2 = alt2
else:
self.alt1 = None
self.alt2 = None
self.pmin = pmin
self.pmax = pmax
self.tolerance = tolerance
self.scale = scale
# Name of a free parameter this constraint's scale (logit s / probit sigma) is tied to,
# or None for a fixed authored scale. Set by bind_scale_param when a fit ties the
# qualitative scale to an estimated parameter (qualitative_scale = fit).
self.scale_param = None
# Choose penalty model depending on what is set to None
# Check for invalid combinations of keys, but these should have been caught during parsing.
if weight is not None:
self.penalty_model = 'static'
if pmin is not None or pmax is not None or tolerance is not None or scale is not None:
raise ValueError(f'Constraint {quant1}{sign}{quant2} has a weight, and so should not have a pmin, pmax, tolerance and/or scale')
elif scale is not None:
# Logit (softplus) penalty. Checked before the probit branch because a logit constraint
# may also carry optional pmin/pmax (label smoothing).
self.penalty_model = 'logit'
if scale <= 0.:
raise PybnfError(f'Constraint {quant1}{sign}{quant2} has a logit scale of {scale}; the scale must be positive.')
if tolerance is not None:
raise ValueError(f'Constraint {quant1}{sign}{quant2} should not have both a logit scale and a tolerance')
if altpenalty is not None:
raise ValueError(f'Constraint {quant1}{sign}{quant2} should not have both a logit scale and an altpenalty')
if (pmin is None) != (pmax is None):
raise ValueError(f'Constraint {quant1}{sign}{quant2} logit penalty needs both pmin and pmax, or neither')
if pmin is not None:
if pmin < 0. or pmax > 1.:
raise PybnfError(f'Constraint {quant1}{sign}{quant2} has invalid probabilities. Settings for pmin '
'and pmax must be between 0 and 1.')
if pmin > pmax:
raise PybnfError(f'Constraint {quant1}{sign}{quant2} has pmin set larger than pmax.')
elif pmin is not None:
self.penalty_model = 'likelihood'
if pmax is None:
raise ValueError(f'Constraint {quant1}{sign}{quant2} has a pmin but not a pmax')
if tolerance is None:
raise ValueError(f'Constraint {quant1}{sign}{quant2} has a pmin and pmax but not a tolerance')
if altpenalty is not None:
raise ValueError(f'Constraint {quant1}{sign}{quant2} should not have both a confidence and a altpenalty')
if pmin < 0. or pmax > 1.:
raise PybnfError(f'Constraint {quant1}{sign}{quant2} has invalid probabilities. Settings for confidence, pmin, and/or '
'pmax must be between 0 and 1.')
if pmin > pmax:
raise PybnfError(f'Constraint {quant1}{sign}{quant2} has pmin set larger than pmax.')
else:
raise ValueError(f'Constraint {quant1}{sign}{quant2} must have either a weight, a confidence, or a logit scale')
# Key tuples need to be initialized after we receive the first data result, so we can figure out the keys.
self.qkeys1 = None
self.qkeys2 = None
self.akeys1 = None
self.akeys2 = None
[docs]
def find_keys(self, sim_data_dict):
"""
Function to be called on the first evaluation of the penalty.
Read through the data dictionary and figure out what keys I need to use to access each relevant variable.
This needs to be done kind of by brute force, because we were never told the model file to use, and it's even
possible the input is poorly defined by referring to a suffix that exists in multiple models.
Subclasses that introduce additional "at"/"between" variables override
:meth:`_find_extra_keys` to resolve those; the common quant/alt key
resolution lives here so it isn't duplicated per subclass.
:param sim_data_dict:
:return:
"""
self.qkeys1, self.qkeys2, self.akeys1, self.akeys2 = (
self.get_key(q, sim_data_dict) for q in (self.quant1, self.quant2, self.alt1, self.alt2))
self._find_extra_keys(sim_data_dict)
logger.debug(f"Got keys for constraint {self.quant1}<{self.quant2}: {[self.qkeys1, self.qkeys2, self.akeys1, self.akeys2]}")
def _find_extra_keys(self, sim_data_dict):
"""
Hook for subclasses to resolve the keys of any additional variables they
reference (the "at"/"start"/"end" conditions). The base constraint has
no such variables, so this does nothing.
"""
pass
def _atvar_key(self, atvar, sim_data_dict):
"""
Resolve the data key for an "at"-style condition variable, defaulting to
the independent variable of the base suffix when the variable name was
omitted (atvar is None).
"""
key = self.get_key(atvar, sim_data_dict)
if key is None:
key = (self.base_model, self.base_suffix,
sim_data_dict[self.base_model][self.base_suffix].indvar)
return key
[docs]
def get_key(self, q, sim_data_dict):
"""
Converts a string from a constraint file ('Observable' or 'suffix.observable') into a tuple of the 3 keys you
would need to index into the sim_data_dict to get the right column of data.
:param q:
:return:
"""
if not isinstance(q, str):
return None
if '.' not in q:
if self.base_model not in sim_data_dict:
raise PybnfError(f'Constraint file is associated with model {self.base_model}, but that model was not found')
if self.base_suffix not in sim_data_dict[self.base_model]:
raise PybnfError(f"Constraint file {self.base_suffix}.prop refers to suffix '{self.base_suffix}' in model '{self.base_model}', but that model does not "
"produce output with that suffix")
if q not in sim_data_dict[self.base_model][self.base_suffix].cols:
raise PybnfError(f"Observable '{q}' specified in constraint file was not found in the output of model "
f"'{self.base_model}', suffix '{self.base_suffix}'")
return (self.base_model, self.base_suffix, q)
else:
parts = q.split('.')
if len(parts) > 2:
raise PybnfError(f'Parsing constraint variable {q}',
"Parsing constraint variable %s - The format should be 'suffix.Observable' Should "
"not contain multiple '.' characters.")
options = []
for m in sim_data_dict:
if parts[0] in sim_data_dict[m]:
options.append(m)
if len(options) == 0:
raise PybnfError(f"Could not find any experimental data corresponding to the suffix '{parts[0]}' specified "
"in your constraint file")
if len(options) > 1:
raise PybnfError(f"Suffix '{parts[0]}' in your constraint file appears in multiple model files. Please "
"change your suffix names so it isn't ambiguous")
if parts[1] not in sim_data_dict[options[0]][parts[0]].cols:
raise PybnfError(f"Obserable '{q}' in constraint file - The output with suffix {parts[0]} does not contain an "
f"observable {parts[1]}")
return (options[0], parts[0], parts[1])
[docs]
def index(self, sim_data_dict, keys):
"""
Shortcut function for applying all 3 indices to the data object
:return:
"""
return sim_data_dict[keys[0]][keys[1]][keys[2]]
def _intrinsic_scale(self):
"""This constraint's authored scale expressed as a **logit-equivalent** ``s`` -- the common
currency the three qualitative losses convert through. Static hinge
``weight w`` -> ``1/w`` (the large-margin identity logit ~ hinge with weight ``1/s``);
logit ``scale s`` -> ``s``; probit ``tolerance sigma`` -> ``sigma/1.6`` (the
``Phi(x) ~ sigma(1.6 x)`` link). Raises on a degenerate scale (zero weight, or a probit
step function with tolerance 0) that has no finite logit-equivalent."""
if self.penalty_model == 'static':
if not self.weight:
raise PybnfError(f"Cannot convert constraint '{self.source_line}' to another "
"qualitative loss: it has zero weight (no finite scale).")
return 1.0 / self.weight
if self.penalty_model == 'logit':
return self.scale
if self.penalty_model == 'likelihood':
if not self.tolerance:
raise PybnfError(f"Cannot convert constraint '{self.source_line}' to another "
"qualitative loss: its probit tolerance is 0 (a step function has "
"no finite scale). Give it a positive tolerance.")
return self.tolerance / 1.6
raise PybnfError(f'Unknown penalty model {self.penalty_model}')
[docs]
def coerce_penalty_model(self, target):
"""Override this constraint's penalty family to ``target`` in {'hinge', 'probit', 'logit'},
deriving the target's scale from whatever was authored via the scale-matching table in
:meth:`_intrinsic_scale` (logit ``s`` <-> hinge ``weight = 1/s`` <-> probit
``tolerance = 1.6 s``). ``'auto'`` / ``None`` is a no-op. This is the mechanism behind the
``qualitative_loss`` config key -- a benchmarking convenience so one ``.prop`` set runs
under all three losses without re-authoring. Authored ``pmin/pmax``
label smoothing is preserved where the target supports it; a probit target with no authored
smoothing gets the unclipped ``pmin=0, pmax=1``."""
if target in (None, 'auto'):
return
base = self._intrinsic_scale()
pmin, pmax = self.pmin, self.pmax
# Clear every family's scale fields; each branch sets only the ones its model reads.
self.weight = None
self.scale = None
self.tolerance = None
self.pmin = None
self.pmax = None
if target == 'hinge':
self.penalty_model = 'static'
self.weight = 1.0 / base
elif target == 'logit':
self.penalty_model = 'logit'
self.scale = base
self.pmin, self.pmax = pmin, pmax # keep label smoothing if it was authored
elif target == 'probit':
self.penalty_model = 'likelihood'
self.tolerance = 1.6 * base
self.pmin = 0.0 if pmin is None else pmin
self.pmax = 1.0 if pmax is None else pmax
else:
raise PybnfError(f"Unknown qualitative_loss target '{target}'. "
"Expected one of 'auto', 'hinge', 'probit', 'logit'.")
[docs]
def bind_scale_param(self, name):
"""Tie this constraint's scale (the logit ``s`` or probit ``sigma``) to an estimated free
parameter ``name``, so a fit can infer it jointly with the model parameters. Only the likelihood families carry a scale: a static (hinge) constraint has no
scale to estimate, so binding one is an error. The authored literal scale is kept as the
fallback/initial value (used when no pset is supplied, e.g. a bare diagnostic penalty())."""
if self.penalty_model == 'static':
raise PybnfError(
f"Constraint '{self.source_line}' uses the static (hinge) penalty, which has no "
"scale to estimate. qualitative_scale = fit needs logit or probit constraints "
"(convert the whole fit with qualitative_loss = logit or probit).")
self.scale_param = name
def _effective_scale(self, pset_values):
"""This constraint's live scale: the current value of the tied free parameter if its scale
is estimated (``scale_param`` set) and that value is available in ``pset_values``, otherwise
the authored literal (logit ``self.scale`` / probit ``self.tolerance``). ``pset_values`` is
the objective's ``{name: value}`` map for the evaluation; ``None`` (a bare penalty() call
outside a fit) falls back to the literal."""
if self.scale_param is not None and pset_values is not None and self.scale_param in pset_values:
return pset_values[self.scale_param]
return self.scale if self.penalty_model == 'logit' else self.tolerance
def _scale_derivative(self, difference, s):
"""Closed-form ``d(penalty)/d(scale)`` at the interval's achieving ``difference`` -- the scalar-gradient contribution to the tied scale parameter's column, needing
no forward sensitivity (the scale does not move the readout). Mirrors the estimated-noise
``d loss/d sigma`` term. Logit: ``-sigma(d/s) * d / s^2`` (unclipped), or the clipped form
when pmin/pmax are set. Probit: ``-(pmax-pmin) * phi(-d/s) * d / (s^2 * adjusted_prob)``."""
if self.penalty_model == 'logit':
x = difference / s
if self.pmin is None:
return -_sigmoid(x) * difference / (s * s)
adjusted = self.pmin + (self.pmax - self.pmin) * _sigmoid(-x)
return -(self.pmax - self.pmin) * _sigmoid(-x) * _sigmoid(x) * difference / (s * s * adjusted)
# probit (Gaussian-CDF likelihood)
u = -difference / s
phi = np.exp(-0.5 * u * u) / np.sqrt(2. * np.pi)
prob = (1. + erf(u / np.sqrt(2.))) / 2.
adjusted = (self.pmax - self.pmin) * prob + self.pmin
return -(self.pmax - self.pmin) * phi * difference / (s * s * adjusted)
[docs]
def get_penalty(self, sim_data_dict, imin, imax, once=False, require_length=None, imin2=None,
imax2=None, pset_values=None):
"""
Helper function for calculating the penalty, that can be called from the subclasses.
Chooses to call either get_static_penalty or get_log_likelihood depending on the penalty model
used for this constraint. ``pset_values`` (the objective's live ``{name: value}`` map) lets a
likelihood constraint resolve an estimated scale parameter.
"""
if self.penalty_model == 'static':
return self.get_static_penalty(sim_data_dict, imin, imax, once, require_length, imin2, imax2)
elif self.penalty_model == 'likelihood':
return self.get_log_likelihood(sim_data_dict, imin, imax, once, require_length, imin2, imax2,
pset_values=pset_values)
elif self.penalty_model == 'logit':
return self.get_log_likelihood_logit(sim_data_dict, imin, imax, once, require_length, imin2, imax2,
pset_values=pset_values)
else:
raise ValueError(f'Invalid penalty model: {self.penalty_model}')
[docs]
def get_difference(self, sim_data_dict, imin, imax, once=False, require_length=None, imin2=None, imax2=None):
"""
Helper function for calculating the penalty, that can be called from the subclasses.
Calculates the difference between the two sides of the inequality. A negative value means the inequality is
satisfied.
Considers the worst case over the specified interval, or the best case if once=True.
The result can be used to calculate the penalty using a static penalty model or a likelihood model.
:param sim_data_dict: The dictionary of data objects
:param imin: First index at which to check the constraint
:param imax: Last index at which to check the constraint (exclusive)
:param once: If true, enforce that the constraint holds once at some point during the time interval
:param require_length: If set to an integer, raise an error if the length of the selected data column(s) is not
equal to that value. (Used to check that "at" and "between" constraints are not encountering an unsupported
case)
:param imin2: If specified, use this different index for quantity 2
:param imax2: If specified, use this different index for quantity 2
:return:
"""
def length_error():
raise PybnfError(f'Applying constraint {self.quant1}<{self.quant2}. Constraints involving observables that have different time '
'points in their simulation outputs are not currently supported')
if imin2 is None:
imin2 = imin
if imax2 is None:
imax2 = imax
if (imax is not None and imax-imin != imax2-imin2) or \
(imax is None and (imin != imin2 or imax2 is not None)):
# Note that the case of imax=None occurs if the desired max index is the end of the time course.
# Should not happen, because currently only the Split At constraint uses imin2 and imax2
raise ValueError('imin2:imax2 has a different length than imin:imax')
if isinstance(self.quant1, str):
q1_col = self.index(sim_data_dict, self.qkeys1)
if require_length and len(q1_col) != require_length:
length_error()
q1 = q1_col[imin:imax]
else:
q1 = self.quant1
if isinstance(self.quant2, str):
q2_col = self.index(sim_data_dict, self.qkeys2)
if require_length and len(q2_col) != require_length:
length_error()
q2 = q2_col[imin2:imax2]
else:
q2 = self.quant2
try:
if once:
difference = np.min(q1 - q2)
else:
difference = np.max(q1 - q2)
except ValueError:
length_error()
return difference
[docs]
def get_static_penalty(self, sim_data_dict, imin, imax, once=False, require_length=None, imin2=None, imax2=None):
"""
Helper function for calculating the static penalty, that can be called from the subclasses.
:param sim_data_dict: The dictionary of data objects
:param imin: First index at which to check the constraint
:param imax: Last index at which to check the constraint (exclusive)
:param once: If true, enforce that the constraint holds once at some point during the time interval
:param require_length: If set to an integer, raise an error if the length of the selected data column(s) is not
equal to that value. (Used to check that "at" and "between" constraints are not encountering an unsupported
case)
:param imin2: If specified, use this different index for quantity 2
:param imax2: If specified, use this different index for quantity 2
:return:
"""
penalty = self.get_difference(sim_data_dict, imin, imax, once, require_length, imin2, imax2)
if penalty > 0 or (penalty == 0. and not self.or_equal):
# Failed constraint
if self.alt1:
# Replace the penalty with the alt penalty
if isinstance(self.alt1, str):
a1 = self.index(sim_data_dict, self.akeys1)[imin:imax]
else:
a1 = self.alt1
if isinstance(self.alt2, str):
a2 = self.index(sim_data_dict, self.akeys2)[imin:imax]
else:
a2 = self.alt2
if once:
penalty = np.min(a1 - a2)
else:
penalty = np.max(a1 - a2)
penalty = max(0., penalty)
# Apply the minimum penalty
penalty = max(self.min_penalty, penalty)
# If constraint satisfied or penalty is negative for any other reason, return 0.
penalty = max(0., penalty)
return penalty * self.weight
[docs]
def get_log_likelihood(self, sim_data_dict, imin, imax, once=False, require_length=None, imin2=None, imax2=None,
pset_values=None):
"""
Helper function for calculating the negative log likelihood of constraint satisfaction given the parameters,
i.e. a likelihood-based penalty function.
The likelihood is calculated with a Gaussian CDF defined in terms of this constraint's confidence
and tolerance (or an estimated scale parameter resolved from ``pset_values``)
:param sim_data_dict: The dictionary of data objects
:param imin: First index at which to check the constraint
:param imax: Last index at which to check the constraint (exclusive)
:param once: If true, enforce that the constraint holds once at some point during the time interval
:param require_length: If set to an integer, raise an error if the length of the selected data column(s) is not
equal to that value. (Used to check that "at" and "between" constraints are not encountering an unsupported
case)
:param imin2: If specified, use this different index for quantity 2
:param imax2: If specified, use this different index for quantity 2
:return:
"""
def cdf(x):
"""
The cumulative distribution function of a Gaussian distribution with mean 0 and variance 1
Previous code versions used the approximation cdf(x) = 1./(1. + np.exp(-1.7*x))
"""
return (1. + erf(x / np.sqrt(2.))) / 2.
difference = self.get_difference(sim_data_dict, imin, imax, once, require_length, imin2, imax2)
k = self._effective_scale(pset_values)
if k == 0:
# Edge case where cdf is a step function
if difference < 0 or (difference == 0 and self.or_equal):
log_likelihood = -np.log(self.pmax)
else:
log_likelihood = -np.log(self.pmin)
else:
# Standard case
# Note that a negative difference is good, hence the sign convention in the calculation below.
prob = cdf(-difference/k)
adjusted_prob = (self.pmax - self.pmin) * prob + self.pmin
log_likelihood = -np.log(adjusted_prob)
return log_likelihood
[docs]
def get_log_likelihood_logit(self, sim_data_dict, imin, imax, once=False, require_length=None, imin2=None,
imax2=None, pset_values=None):
"""Negative log likelihood of constraint satisfaction under the **logit** (Miller et al.
2025) noise model: the comparison outcome is Bernoulli with P(holds) = sigma(-difference/s),
so the single-sided BPSL penalty collapses to the softplus
F(difference; s) = ln(1 + e^{difference/s}) = softplus(difference/s).
``difference`` is the signed constraint margin (negative == satisfied, :meth:`get_difference`).
As ``s -> 0`` with ``weight = 1/s`` this is the 2018 hinge (the large-margin asymptote);
larger ``s`` is a softer penalty. ``np.logaddexp(0, x)`` evaluates the
softplus without overflow. If optional ``pmin/pmax`` were supplied (label smoothing, for
probit parity), the probability is clipped to ``pmin + (pmax-pmin)*sigma(-difference/s)``
before the ``-log`` -- default off (faithful to the arXiv derivation).
:param sim_data_dict: The dictionary of data objects
:param imin: First index at which to check the constraint
:param imax: Last index at which to check the constraint (exclusive)
:param once: If true, enforce that the constraint holds once at some point during the interval
:param require_length: If set, raise if the selected data column length differs (unsupported case)
:param imin2: If specified, use this different index for quantity 2
:param imax2: If specified, use this different index for quantity 2
"""
difference = self.get_difference(sim_data_dict, imin, imax, once, require_length, imin2, imax2)
x = difference / self._effective_scale(pset_values)
if self.pmin is None:
# Faithful softplus: -log sigma(-x) = ln(1 + e^{x}), stable via logaddexp.
return float(np.logaddexp(0.0, x))
# Label-smoothed (probit-parity) variant.
adjusted_prob = self.pmin + (self.pmax - self.pmin) * _sigmoid(-x)
return float(-np.log(adjusted_prob))
[docs]
def penalty(self, sim_data_dict, pset_values=None):
"""
penalty function for violating the constraint. Returns 0 if constraint is satisfied, or a positive value
if the constraint is violated.
Shared across subclasses: each subclass declares the enforcement interval(s) it checks
via :meth:`_penalty_intervals` (its "at" / "between" / "always" / "once" logic), and the
penalty is the sum of :meth:`get_penalty` over them (the static or likelihood model).
Factored from the per-subclass inline loops so :meth:`penalty_gradient` differentiates
exactly the same intervals (#456/#385).
:param sim_data_dict: Dictionary of the form {modelname: {suffix1: Data1}} containing the simulated data objects
:type sim_data_dict: dict
:param pset_values: The objective's live ``{name: value}`` map, so a likelihood constraint whose
scale is tied to an estimated free parameter reads its current value.
``None`` (a bare diagnostic call) falls back to the authored literal scale.
"""
return sum((self.get_penalty(sim_data_dict, pset_values=pset_values, **interval)
for interval in self._penalty_intervals(sim_data_dict)), 0.)
def _penalty_intervals(self, sim_data_dict):
"""The enforcement interval(s) this constraint checks -- a list of keyword-argument dicts
for :meth:`get_penalty` / :meth:`get_penalty_gradient` (``imin``, ``imax``, and optionally
``once`` / ``require_length`` / ``imin2`` / ``imax2``). Each subclass's "at" / "between" /
"always" / "once" enforcement logic lives here, so ``penalty`` and ``penalty_gradient``
evaluate identical intervals by construction; the data keys are resolved on first use (the
lazy ``find_keys``)."""
raise NotImplementedError('Subclasses of Constraint must override _penalty_intervals()')
def _difference_argmax(self, sim_data_dict, imin, imax, once=False, require_length=None,
imin2=None, imax2=None):
"""The signed constraint difference ``max_i(q1_i - q2_i)`` (or ``min`` if ``once``) over
the interval -- the same value :meth:`get_difference` returns -- plus the offset ``a`` of
the achieving point within the sliced interval, which is where the gradient reads the
readout's forward sensitivity (Danskin's theorem: the gradient of a max over a set is the
gradient of the achieving term). The achieving rows in the full column are ``imin + a``
for ``q1`` and ``imin2 + a`` for ``q2``."""
if imin2 is None:
imin2 = imin
if imax2 is None:
imax2 = imax
q1 = self.index(sim_data_dict, self.qkeys1)[imin:imax] if isinstance(self.quant1, str) else self.quant1
q2 = self.index(sim_data_dict, self.qkeys2)[imin2:imax2] if isinstance(self.quant2, str) else self.quant2
diffs = np.atleast_1d(np.asarray(q1, dtype=float) - np.asarray(q2, dtype=float))
a = int(np.argmin(diffs)) if once else int(np.argmax(diffs))
return float(diffs[a]), a
def _readout_gradient(self, raw_sens, n_param, keys1, quant1, row1, keys2, quant2, row2):
"""``d(q1 - q2)/d theta`` at the given rows: each operand's forward sensitivity row from
``raw_sens(model, suffix, observable, row)``, or 0 for a constant operand."""
s1 = raw_sens(*keys1, row1) if isinstance(quant1, str) else np.zeros(n_param)
s2 = raw_sens(*keys2, row2) if isinstance(quant2, str) else np.zeros(n_param)
return s1 - s2
[docs]
def get_penalty_gradient(self, sim_data_dict, raw_sens, index, n_param, imin, imax,
once=False, require_length=None, imin2=None, imax2=None, pset_values=None):
"""The gradient of this interval's penalty w.r.t. the free parameters (#456/#385) --
the constraint counterpart of the objective's per-point gradient. Dispatches to the
static or likelihood model, mirroring :meth:`get_penalty`. Both reduce to ``local penalty
slope * d(readout)/d theta`` at the interval's achieving point: the penalty is a piecewise
(static) or Gaussian-CDF (likelihood) function of the at-/between-time readout ``q1 - q2``,
so its gradient is that readout's forward sensitivity times the model's local slope.
When this constraint's scale is tied to an estimated free parameter (``scale_param``), a closed-form ``d(penalty)/d(scale)`` term is added to that parameter's column --
a scalar contribution needing no forward sensitivity, exactly like the estimated-noise
``d loss/d sigma`` column."""
if self.penalty_model == 'static':
return self._static_penalty_gradient(sim_data_dict, raw_sens, n_param,
imin, imax, once, imin2)
elif self.penalty_model == 'likelihood':
grad = self._likelihood_penalty_gradient(sim_data_dict, raw_sens, n_param,
imin, imax, once, require_length, imin2, imax2,
pset_values)
elif self.penalty_model == 'logit':
grad = self._logit_penalty_gradient(sim_data_dict, raw_sens, n_param,
imin, imax, once, require_length, imin2, imax2,
pset_values)
else:
raise ValueError(f'Invalid penalty model: {self.penalty_model}')
if self.scale_param is not None:
# Add d(penalty)/d(scale) to the tied parameter's column (scalar; no forward sensitivity).
difference = self.get_difference(sim_data_dict, imin, imax, once, require_length, imin2, imax2)
s = self._effective_scale(pset_values)
grad = np.array(grad, dtype=float)
grad[index[self.scale_param]] += self._scale_derivative(difference, s)
return grad
def _static_penalty_gradient(self, sim_data_dict, raw_sens, n_param, imin, imax, once, imin2):
"""Gradient of the static penalty ``weight * max(0, difference)`` (#456), mirroring
:meth:`get_static_penalty` branch for branch. Zero in the satisfied region and in the flat
``min_penalty`` floor (the penalty is locally constant there); ``weight * d(readout)/d
theta`` where the (alt) difference exceeds the floor. The kink at ``difference == 0`` (and
at the floor) takes the subgradient 0 -- the satisfied-side value, documented like the
Laplace kink (#454)."""
difference, a = self._difference_argmax(sim_data_dict, imin, imax, once, imin2=imin2)
if not (difference > 0 or (difference == 0. and not self.or_equal)):
return np.zeros(n_param) # constraint satisfied -> penalty 0, flat
if self.alt1:
alt_diff, alt_a = self._alt_difference_argmax(sim_data_dict, imin, imax, once)
if max(0., alt_diff) <= self.min_penalty:
return np.zeros(n_param) # alt satisfied or floored at min_penalty -> flat
row = imin + alt_a
return self.weight * self._readout_gradient(
raw_sens, n_param, self.akeys1, self.alt1, row, self.akeys2, self.alt2, row)
if difference <= self.min_penalty:
return np.zeros(n_param) # floored at min_penalty -> flat
imin2_eff = imin if imin2 is None else imin2
return self.weight * self._readout_gradient(
raw_sens, n_param, self.qkeys1, self.quant1, imin + a,
self.qkeys2, self.quant2, imin2_eff + a)
def _alt_difference_argmax(self, sim_data_dict, imin, imax, once):
"""The alt-penalty difference ``max/min(alt1 - alt2)`` over ``[imin:imax]`` and the
achieving offset (mirroring :meth:`get_static_penalty`'s altpenalty branch)."""
a1 = self.index(sim_data_dict, self.akeys1)[imin:imax] if isinstance(self.alt1, str) else self.alt1
a2 = self.index(sim_data_dict, self.akeys2)[imin:imax] if isinstance(self.alt2, str) else self.alt2
diffs = np.atleast_1d(np.asarray(a1, dtype=float) - np.asarray(a2, dtype=float))
idx = int(np.argmin(diffs)) if once else int(np.argmax(diffs))
return float(diffs[idx]), idx
def _likelihood_penalty_gradient(self, sim_data_dict, raw_sens, n_param, imin, imax, once,
require_length, imin2, imax2, pset_values=None):
"""Gradient of the likelihood penalty ``-log((pmax-pmin) * Phi(-difference/k) + pmin)``
(#456). The Gaussian-CDF model is smooth, with local slope
``d/d difference = (pmax-pmin) * phi(-difference/k) / (k * adjusted_prob)`` (> 0: a larger
difference is a worse violation, a larger penalty), times ``d(readout)/d theta``. A zero
tolerance is a step function (flat almost everywhere) -> zero gradient, matching the
non-differentiable edge case :meth:`get_log_likelihood` special-cases. ``k`` is the effective
scale -- the estimated free parameter's live value if tied, else the
authored tolerance."""
difference, a = self._difference_argmax(sim_data_dict, imin, imax, once, require_length, imin2, imax2)
k = self._effective_scale(pset_values)
if k == 0:
return np.zeros(n_param)
x = -difference / k
phi = np.exp(-0.5 * x * x) / np.sqrt(2. * np.pi)
prob = (1. + erf(x / np.sqrt(2.))) / 2.
adjusted = (self.pmax - self.pmin) * prob + self.pmin
slope = (self.pmax - self.pmin) * phi / (k * adjusted)
imin2_eff = imin if imin2 is None else imin2
return slope * self._readout_gradient(
raw_sens, n_param, self.qkeys1, self.quant1, imin + a,
self.qkeys2, self.quant2, imin2_eff + a)
def _logit_penalty_gradient(self, sim_data_dict, raw_sens, n_param, imin, imax, once,
require_length, imin2, imax2, pset_values=None):
"""Gradient of the logit softplus penalty ``ln(1 + e^{difference/s})``
(:meth:`get_log_likelihood_logit`). The loss is smooth everywhere (``s > 0``), with local
slope ``dF/d difference = sigma(difference/s) / s`` (the logistic; > 0, a larger difference
is a worse violation), times ``d(readout)/d theta`` at the interval's achieving point --
the same Danskin-argmax structure the probit gradient uses. With optional ``pmin/pmax``
label smoothing the slope becomes
``(pmax-pmin) * sigma(-x) * sigma(x) / (s * adjusted_prob)`` with ``x = difference/s`` and
``adjusted_prob = pmin + (pmax-pmin)*sigma(-x)`` -- collapsing to ``sigma(x)/s`` when
unclipped (``pmin=0, pmax=1``). ``s`` is the effective scale -- the estimated free
parameter's live value if tied, else the authored scale."""
difference, a = self._difference_argmax(sim_data_dict, imin, imax, once, require_length, imin2, imax2)
s = self._effective_scale(pset_values)
x = difference / s
if self.pmin is None:
slope = _sigmoid(x) / s
else:
adjusted = self.pmin + (self.pmax - self.pmin) * _sigmoid(-x)
slope = (self.pmax - self.pmin) * _sigmoid(-x) * _sigmoid(x) / (s * adjusted)
imin2_eff = imin if imin2 is None else imin2
return slope * self._readout_gradient(
raw_sens, n_param, self.qkeys1, self.quant1, imin + a,
self.qkeys2, self.quant2, imin2_eff + a)
[docs]
def penalty_gradient(self, sim_data_dict, raw_sens, index, n_param, pset_values=None):
"""The gradient of this constraint's total penalty w.r.t. the free parameters (layer I,
#456/#385) -- ``raw_sens(model, suffix, observable, row)`` supplies each readout's native-
space forward sensitivity row (the #447 tensor, factor-folded per #448 routing), ``index``
maps a free-parameter name to its column. Sums :meth:`get_penalty_gradient` over the same
enforcement intervals :meth:`penalty` sums :meth:`get_penalty` over, so the gradient
differentiates exactly the penalty PyBNF scores.
Lives on the **scalar-gradient path only**: a penalty is a piecewise / CDF function, not a
sum of squares, so (like an estimated noise normalizer, #451) it cannot enter the residual-
Jacobian -- a fit with active constraints is not ``least_squares_exact``. The static model
is non-smooth at the constraint boundary and at the ``min_penalty`` floor; the subgradient
0 is taken there (documented like the Laplace kink, #454). ``pset_values`` carries the live
parameter values so a constraint with an estimated scale contributes its
``d(penalty)/d(scale)`` column."""
grad = np.zeros(n_param)
for interval in self._penalty_intervals(sim_data_dict):
grad += self.get_penalty_gradient(sim_data_dict, raw_sens, index, n_param,
pset_values=pset_values, **interval)
return grad
[docs]
class AtConstraint(Constraint):
def __init__(self, quant1, sign, quant2, base_model, base_suffix, weight, atvar, atval, altpenalty=None, minpenalty=0.,
repeat=False, before=False, pmin=None, pmax=None, tolerance=None, scale=None):
"""
Creates a new constraint of the form
X1>value at X2=value
:param atvar: Variable checked to determine the 'at' condition, or None for the independent variable
:param atval: Value that the atvar must take to activate the 'at' condition
:param repeat: If True, enforce the constraint every time the 'at' condition is met. If False, only enforce
the first time
:param before: If True, enforce the constraint at the time point immediately before the 'at' condition is met
"""
super().__init__(quant1, sign, quant2, base_model, base_suffix, weight, altpenalty, minpenalty, pmin, pmax,
tolerance, scale)
self.atvar = atvar
self.atval = atval
self.repeat = repeat
self.before = before
self.atkeys = None
logger.debug(f"Created 'at' constraint {self.quant1}<{self.quant2}")
def _find_extra_keys(self, sim_data_dict):
"""Resolve the 'at' condition variable, defaulting to the indvar."""
self.atkeys = self._atvar_key(self.atvar, sim_data_dict)
def _penalty_intervals(self, sim_data_dict):
"""Each "at" crossing of the condition variable is a single-row interval (the next/before
row, per the equal/``before`` adjustments) -- one per crossing if ``repeat``, else the
first only."""
# Load the keys if we haven't yet done so
if not self.atkeys:
self.find_keys(sim_data_dict)
# Find the index where the when condition is met
atdata = self.index(sim_data_dict, self.atkeys)
at_col = atdata < self.atval
# Make array that is True at every point where the atvar crosses the atval
flip_points = at_col[:-1] != at_col[1:]
flip_inds = np.nonzero(flip_points)[0]
if not self.repeat:
flip_inds = flip_inds[:1]
intervals = []
for fi in flip_inds:
# Make sure we pick the correct end of the interval if there's a point equal to the "at"
if np.isclose(atdata[fi+1], self.atval, atol=0.) and not (np.isclose(atdata[fi], self.atval, atol=0.)):
fi += 1
# If constraint was declared with "before", go back 1.
if self.before and fi > 0:
fi -= 1
intervals.append(dict(imin=fi, imax=fi+1, require_length=len(at_col)))
# Todo - if atvar and quant1 were simulated on different time scales, need to scour independent variable cols
return intervals
[docs]
class SplitAtConstraint(Constraint):
def __init__(self, quant1, atvar1, atval1, sign, quant2, atvar2, atval2, base_model, base_suffix,
weight, altpenalty=None, minpenalty=0.,
repeat=False, before1=False, before2=False, pmin=None, pmax=None, tolerance=None, scale=None):
"""
Creates a new constraint of the form
X1 at X2=value < X3 at X4=value
:param atvar: Variable checked to determine the 'at' condition, or None for the independent variable
:param atval: Value that the atvar must take to activate the 'at' condition
:param repeat: If True, enforce the constraint every time the 'at' condition is met. If False, only enforce
the first time
:param before: If True, enforce the constraint at the time point immediately before the 'at' condition is met
"""
super().__init__(quant1, sign, quant2, base_model, base_suffix, weight, altpenalty, minpenalty, pmin, pmax,
tolerance, scale)
# Since the superclass flips the sign for ">", we need to also flip the "at" conditions
if sign in ('>', '>='):
self.atvar1 = atvar2
self.atval1 = atval2
self.atvar2 = atvar1
self.atval2 = atval1
self.before1 = before2
self.before2 = before1
else:
self.atvar1 = atvar1
self.atval1 = atval1
self.atvar2 = atvar2
self.atval2 = atval2
self.before1 = before1
self.before2 = before2
if repeat:
raise PybnfError('The "repeat" keyword is not currently supported for constraints with two "at" conditions')
self.atkeys1 = None
self.atkeys2 = None
logger.debug(f"Created split 'at' constraint {self.quant1}<{self.quant2}")
def _find_extra_keys(self, sim_data_dict):
"""Resolve both 'at' condition variables, each defaulting to the indvar."""
self.atkeys1 = self._atvar_key(self.atvar1, sim_data_dict)
self.atkeys2 = self._atvar_key(self.atvar2, sim_data_dict)
def _penalty_intervals(self, sim_data_dict):
"""A single split interval: ``q1`` at its "at" crossing vs ``q2`` at its own "at"
crossing (the ``imin2``/``imax2`` second readout). Empty if either condition is unmet."""
# Load the keys if we haven't yet done so
if not self.atkeys1:
self.find_keys(sim_data_dict)
# Find the index where the when condition is met
atdata1 = self.index(sim_data_dict, self.atkeys1)
at_col1 = atdata1 < self.atval1
# Make array that is True at every point where the atvar crosses the atval
flip_points1 = at_col1[:-1] != at_col1[1:]
flip_inds1 = np.nonzero(flip_points1)[0]
# Repeat for 2nd at condition
atdata2 = self.index(sim_data_dict, self.atkeys2)
at_col2 = atdata2 < self.atval2
flip_points2 = at_col2[:-1] != at_col2[1:]
flip_inds2 = np.nonzero(flip_points2)[0]
if len(flip_inds1) == 0 or len(flip_inds2) == 0:
# One of the at conditions was not met, so constraint is not enforced.
return []
fi1 = flip_inds1[0]
fi2 = flip_inds2[0]
# Make sure we pick the correct end of the interval if there's a point equal to the "at"
if np.isclose(atdata1[fi1+1], self.atval1, atol=0.) and not (np.isclose(atdata1[fi1], self.atval1, atol=0.)):
fi1 += 1
if np.isclose(atdata2[fi2+1], self.atval2, atol=0.) and not (np.isclose(atdata2[fi2], self.atval2, atol=0.)):
fi2 += 1
# If constraint was declared with "before", go back 1.
if self.before1 and fi1 > 0:
fi1 -= 1
if self.before2 and fi2 > 0:
fi2 -= 1
# Todo - if atvar and quant1 were simulated on different time scales, need to scour independent variable cols
return [dict(imin=fi1, imax=fi1+1, require_length=len(at_col1), imin2=fi2, imax2=fi2+1)]
[docs]
class BetweenConstraint(Constraint):
def __init__(self, quant1, sign, quant2, base_model, base_suffix, weight, startvar, startval, endvar, endval, altpenalty=None,
minpenalty=0., pmin=None, pmax=None, tolerance=None, once=False, scale=None):
"""
Creates a new constraint of the form
X1 < X2 between X3=value X4=value
or, if once=True,
X1 < X2 once between X3=value X4=value
:param startvar: Variable checked to determine the start of the interval, or None for the independent variable
:param startval: Value that the startvar must take to trigger the start of the interval
:param endvar: Variable checked to determine the end of the interval, or None for the independent variable
:param endval: Value that the endvar must take to trigger the start of the interval
"""
super().__init__(quant1, sign, quant2, base_model, base_suffix, weight, altpenalty, minpenalty, pmin, pmax,
tolerance, scale)
self.startvar = startvar
self.startval = startval
self.endvar = endvar
self.endval = endval
self.startkeys = None
self.endkeys = None
self.once = once
logger.debug(f"Created 'between' constraint {self.quant1}<{self.quant2}")
def _find_extra_keys(self, sim_data_dict):
"""Resolve the start/end interval variables, each defaulting to the indvar."""
self.startkeys = self._atvar_key(self.startvar, sim_data_dict)
self.endkeys = self._atvar_key(self.endvar, sim_data_dict)
def _penalty_intervals(self, sim_data_dict):
"""A single interval ``[start, end]`` bounded by the start/end condition crossings, over
which the constraint is enforced at its worst point (or, if ``once``, its best)."""
if not self.startkeys:
self.find_keys(sim_data_dict)
# Find the indices bounding the condition
startdat = self.index(sim_data_dict, self.startkeys)
startcol = startdat < self.startval
start = np.nonzero(startcol[:-1] != startcol[1:])[0]
if len(start) == 0:
# Interval never started
return []
else:
start = start[0]
# If a point is exactly equal, make sure we pick the right end of the interval
if start < len(startdat) - 1 and np.isclose(startdat[start+1], self.startval, atol=0.) \
and not (np.isclose(startdat[start], self.startval, atol=0.)):
start += 1
enddat = self.index(sim_data_dict, self.endkeys)
endcol = enddat < self.endval
if len(startcol) != len(endcol):
raise PybnfError(f'Applying constraint {self.quant1}<{self.quant2}. Constraints involving observables that have different time '
'points in their simulation outputs are not currently supported')
# Note: [0] strips a useless extra dimension
end = np.nonzero(endcol[start+1:-1] != endcol[start+2:])[0]
if len(end) == 0:
# Interval never ended
end = len(startcol) - 1
else:
end = end[0] # Take the first point satisfying the end condition
end += start + 1
if end < len(endcol) - 1 and np.isclose(enddat[end+1], self.endval, atol=0.) \
and not (np.isclose(enddat[end], self.endval, atol=0.)):
end += 1
return [dict(imin=start, imax=end+1, require_length=len(endcol), once=self.once)]
[docs]
class AlwaysConstraint(Constraint):
def __init__(self, quant1, sign, quant2, base_model, base_suffix, weight, altpenalty=None, minpenalty=0.,
pmin=None, pmax=None, tolerance=None, scale=None):
"""
Creates a new constraint of the form
X1>X2 always
"""
super().__init__(quant1, sign, quant2, base_model, base_suffix, weight, altpenalty, minpenalty, pmin, pmax,
tolerance, scale)
logger.debug(f"Created 'always' constraint {self.quant1}<{self.quant2}")
def _penalty_intervals(self, sim_data_dict):
"""The whole data column (worst miss over all time) is the single enforcement interval."""
if not self.qkeys1 and not self.qkeys2:
self.find_keys(sim_data_dict)
# Todo - if q1 and q2 are on different time scales
# Note: Indexing by 0:None takes the entire column
return [dict(imin=0, imax=None)]
[docs]
class OnceConstraint(Constraint):
def __init__(self, quant1, sign, quant2, base_model, base_suffix, weight, altpenalty=None, minpenalty=0.,
pmin=None, pmax=None, tolerance=None, scale=None):
"""
Creates a new constraint of the form
X1>X2 once
"""
super().__init__(quant1, sign, quant2, base_model, base_suffix, weight, altpenalty, minpenalty, pmin, pmax,
tolerance, scale)
logger.debug(f"Created 'once' constraint {self.quant1}<{self.quant2}")
def _penalty_intervals(self, sim_data_dict):
"""The whole data column is the interval, enforced ``once`` (the best point must satisfy)."""
if not self.qkeys1 and not self.qkeys2:
self.find_keys(sim_data_dict)
# Note: Indexing by 0:None takes the entire column
return [dict(imin=0, imax=None, once=True)]