Constraints#
Many SFC and behavioral models have adding-up relationships between
parameters. Tobin portfolio shares must sum to one, the corresponding
rate sensitivities must sum to zero, factor income shares must close
to total income, and so on. The LinearConstraint system lets
a model declare these relationships once, and MacroStat enforces them
both at parameter initialization and inside every simulation step,
without breaking autograd.
Public API#
|
Raised for malformed or unresolvable parameter constraints. |
|
A linear adding-up constraint on a group of parameters. |
|
Where a named parameter lives in a vectorized tensor dict. |
|
Map canonical parameter names to |
Properties and methods#
|
Parameter names that are free (all except the last). |
|
The parameter name that is derived from the others. |
|
Enforce the constraint on a vectorized tensor dict. |
|
Return the tensor slice corresponding to this location. |
|
Write |
|
Return the |
Residual parameterization#
A LinearConstraint enforces
by treating the last parameter in param_names as the residual
of the others:
The free parameters remain independent. The derived parameter is computed from them. Because the derivation is a linear combination of tensors, the operation is fully differentiable: autograd records the dependency
and the gradient of any downstream loss with respect to
p_derived is zero by construction. This is the correct behavior:
p_derived is no longer a free parameter, so its sensitivity is
absorbed into the free parameters of the same group. See
Differentiability and Jacobian Tools for an “Expected disagreements” note that revisits
this from the Jacobian side.
Declaring constraints on a model#
Constraints are declared by overriding
get_constraints() on the
model’s Parameters subclass. The
method returns an ordered tuple of LinearConstraint
instances. As an example, the GL06INSOUT model declares five
constraints for its 4-asset Tobin portfolio (M1, M2, Bills, Bonds),
with M1 cash as the residual asset in every group:
def get_constraints(self) -> tuple[LinearConstraint, ...]:
"""Return adding-up constraints for the Tobin portfolio matrix.
The GL06INSOUT model has a 4-asset Tobin portfolio allocation
(M1, M2, Bills, Bonds). The Godley-Lavoie adding-up constraints
require constant shares to sum to 1, and each rate/income
sensitivity column to sum to 0. M1 (cash) is the buffer-stock
residual asset.
"""
return (
# Constants: lambda_20 + lambda_30 + lambda_40 + lambda_10 = 1
LinearConstraint(
param_names=(
"WealthShareM2_Constant",
"WealthShareBills_Constant",
"WealthShareBonds_Constant",
"WealthShareM1_Constant",
),
target=1.0,
),
# Deposit rate sensitivities sum to 0
LinearConstraint(
param_names=(
"WealthShareM2_DepositRate",
"WealthShareBills_DepositRate",
"WealthShareBonds_DepositRate",
"WealthShareM1_DepositRate",
),
target=0.0,
),
# Bill rate sensitivities sum to 0
LinearConstraint(
param_names=(
"WealthShareM2_BillRate",
"WealthShareBills_BillRate",
"WealthShareBonds_BillRate",
"WealthShareM1_BillRate",
),
target=0.0,
),
# Bond yield sensitivities sum to 0
LinearConstraint(
param_names=(
"WealthShareM2_BondYield",
"WealthShareBills_BondYield",
"WealthShareBonds_BondYield",
"WealthShareM1_BondYield",
),
target=0.0,
),
# Income sensitivities sum to 0
LinearConstraint(
param_names=(
"WealthShareM2_Income",
"WealthShareBills_Income",
"WealthShareBonds_Income",
"WealthShareM1_Income",
),
target=0.0,
),
)
Each LinearConstraint accepts:
param_names(tuple[str, ...]): all parameters in the group. The last entry is the derived (residual) parameter; everything before it is free.target(float): the value the group must sum to.
Constraints are applied in declaration order. Chaining — where the
derived parameter of one constraint appears as a free parameter in
another — is explicitly rejected by
verify_constraints()
and will raise ConstraintError at init time. If you need
cascading relationships, collapse them into a single constraint or
restructure the parameter group.
Vector and matrix constraints#
A constraint does not need to live at the scalar layer. Parameters
that follow the sectoral naming convention sector.name or
rowsec.colsec.name are vectorised by
vectorize_parameters()
into 1-D or 2-D tensors at step time, and the same
LinearConstraint can enforce an adding-up identity across
those tensor slots.
The seam that makes this work is the
ConstraintResolver. At step time, each constraint asks the
resolver to map its canonical parameter names to
ParameterLocation records: a (tensor_key, index) pair
that says where the parameter lives inside the step-time tensor
dictionary. Scalar parameters resolve to index=None; sector-
indexed parameters resolve to index=(i,); sector-by-sector
parameters resolve to index=(i, j). LinearConstraint.apply()
reads the free slots, computes target - sum(free), and writes
the residual into the derived slot via
torch.Tensor.index_put(), which is out-of-place and
differentiable.
As an example, consider a two-sector household share that must sum to one:
from macrostat.core import LinearConstraint, Parameters
class HouseholdShares(Parameters):
def get_default_parameters(self):
return {
"Household.Share": {"value": 0.6, ...},
"Firm.Share": {"value": 0.4, ...},
}
def get_default_hyperparameters(self):
h = super().get_default_hyperparameters()
h["vector_sectors"] = ["Household", "Firm"]
return h
def get_constraints(self):
return (
LinearConstraint(
param_names=("Household.Share", "Firm.Share"),
target=1.0,
),
)
When the model initialises, the resolver is built lazily by
get_constraint_resolver()
and cached on the Parameters
instance. At step time,
apply_parameter_shocks()
calls LinearConstraint.apply() with that resolver. All
parameters inside one constraint must share a shape class: all
scalar, or all indexed into the same tensor with the same rank.
Mixing shapes is rejected at init time by
verify_constraints().
Adding a constraint to your own model#
To add a new constraint group to a model:
Open the model’s
parameters.pyand locate theParameterssubclass.Override or extend the
get_constraints()method so that it returns a tuple ofLinearConstraintinstances. If the parent already declares constraints, returnsuper().get_constraints() + (...).Choose which parameter in the group is the residual. This is typically the buffer-stock asset, the anchor category, or any parameter you do not want to estimate independently. List it last in
param_names.Pick the
target(1.0 for shares, 0.0 for sensitivities, or any other linear sum).Re-instantiate the parameters object.
verify_constraints()is called automatically and will raise if the constraint is ill-formed;enforce_constraints()then adjusts the residual parameter to satisfy the sum.
Existing models that do not declare any constraints need no changes. Constraints are opt-in.
Verification and enforcement#
Constraints are checked and enforced at several points in the parameter lifecycle.
Init-time verification.
verify_constraints() is
called once when a Parameters
instance is constructed. It runs six structural checks and one
soft check, all of which raise ConstraintError (a
ValueError subclass) on failure:
A constraint references a parameter name that does not exist in
self.values. The error lists the unknown name and the available parameters.The same parameter is declared as the derived parameter in two or more constraints. The error lists the duplicates.
A parameter is the derived parameter in one constraint and a free parameter in another; chained constraints are not supported.
A constraint references a parameter name that cannot be located by the resolver. This is a defensive check against divergence between
vectorize_parameters()andget_constraint_resolver().A constraint mixes scalar and indexed parameter locations, spans multiple tensor keys, or mixes index ranks. All parameters in one constraint must share a shape class.
The prospective post-enforcement value of the derived parameter lies outside its declared bounds. Catches misdeclared constraints at init time rather than after enforcement.
If the default values stored in self.values do not satisfy a
declared constraint, verify_constraints does not raise. It
instead emits a warning of the form
"Constraint violation in defaults: sum(...) = ..., target = ...."
so the user knows that the residual parameter will be adjusted on
the next call to enforce_constraints.
Init-time enforcement.
enforce_constraints()
runs immediately after verification. For each constraint, it
overwrites the derived parameter’s value with
target - sum(free). This is a float-level operation that
modifies self.values in place.
Step-time enforcement. During simulation,
apply_parameter_shocks()
applies any scenario shocks to the parameter tensors and then runs
each constraint’s LinearConstraint.apply() on the shocked
tensors, handing in the resolver obtained from
get_constraint_resolver().
The resolver and constraint list are fetched fresh on every call,
so the step-time view of the parameter layout always matches the
current Parameters instance.
This second pass keeps the adding-up identity intact even when
scenarios shock free parameters in the group, and because it
operates on tensors with requires_grad=True, autograd sees the
residual relationship at every timestep.
Helpers for callers.
get_free_param_names()
returns the parameter names that are not derived by any
constraint. Use it whenever you want to iterate over the parameters
that are actually independent (for calibration, sampling, or
diagnostics).
See also#
Parameters for the full Parameters API including the constraint hooks.
Behavior for the role of
apply_parameter_shocksin step-time enforcement.Differentiability and Jacobian Tools for how the residual parameterization shows up in numerical and autograd Jacobians.
Parameter constraints with LinearConstraint for a runnable notebook walkthrough.