Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Households and value-dependent choice

A single decision maker maximizes her own value. A household does not: it takes one action for two people, it is only viable while both of them want it, and whether it forms or ends depends on what each member would be worth outside it. Three declarations cover that, and each goes in a slot an ordinary model already has.

DeclarationGoes inSays
CollectiveUtilityfunctions["utility"]who the stakeholders are, and how their action values are traded off
ValueDependentConstraintconstraintswhere the cell is feasible, reading values as well as states
GateTransition(gates=...)which branch within a target, from the landing state

ProjectedRegimeValue reads another regime’s value in the same period, for a value constraint’s comparison and for a route’s fallback. A move decided by comparing values is not a Gate: it is a participation Exit of the source regime, or a ParticipationCheck evaluated on landing.

The complete worked model is lcm_examples.collective_household. This page walks through the pieces; the exact declaration contracts are in Collective regimes, and a staged build-up of one is in the collective example.

A household with two stakeholders

The utilities keys are the regime’s stakeholders, in the order you write them. That order fixes the trailing axis of the regime’s value function and of every published array, so value_f comes before value_m in the simulated frame.

from lcm import CollectiveUtility, Regime

couple = Regime(
    states={"wealth": couple_wealth},
    state_transitions={"wealth": next_couple_wealth},
    actions={"consumption": consumption},
    functions={
        "utility": CollectiveUtility(
            utilities={"f": her_utility, "m": his_utility},
        )
    },
)

The CollectiveUtility remains the raw functions["utility"] declaration. Pylcm derives the stakeholder metadata from it and gives the numerical engine a decomposed_functions view with utility_f and utility_m; it does not replace what the model author wrote. Each value in utilities can be a callable, a Phased(solve=..., simulate=...) pair, or None. None delegates that stakeholder’s body to a utility_<stakeholder> function supplied at regime or model level; successful model finalization guarantees that every delegated body arrived.

The household maximizes one objective over the feasible actions and reads off each stakeholder’s own action value Q_s at that common choice. Omitting objective weights the stakeholders equally. To weigh them differently, declare a ParetoObjective:

from lcm import ParetoObjective

CollectiveUtility(
    utilities={"f": her_utility, "m": his_utility},
    objective=ParetoObjective(weights={"f": bargaining_weight, "m": 1.0}),
)

A weight is a constant or a function of the regime’s states and of period / age — so a state-dependent bargaining position works. Every other argument a weight names becomes a free parameter under the regime’s pareto_objective key in get_params_template(), estimated like anything else. That cuts both ways: an argument spelled like one of the regime’s other functions does not receive that function’s output, it becomes a parameter you have to supply. A weight may not read an action: a weight that varied with the choice would state a different objective per candidate, whose maximizer is a Pareto optimum of no fixed weighting.

Declaring the weights rather than writing the sum as an ordinary function is what lets the engine own what a Pareto weight means — one per stakeholder, finite and non-negative, with a strictly positive total, normalized cell by cell, and multiplied in zero-safely. That last one matters: a stakeholder carrying zero weight may still hold an admissible -inf of her own, and 0.0 * -inf is NaN, which through the argmax would let the wrong partner decide the household’s choice.

Normalization is "pointwise" by default, which rescales the declared weights to sum to one wherever the objective is evaluated, so a state-dependent weight cannot change the scale of the household’s objective from cell to cell. normalization="none" uses the weights as written, for a scalarization whose level is itself meant to vary.

Participation: a constraint that reads values

A household is feasible only where each partner is at least as well off inside it as alone. “Alone” is that partner’s own regime, in the same period — not next period’s continuation. ProjectedRegimeValue reads it, and ValueDependentConstraint compares against it:

from lcm import ProjectedRegimeValue, ValueDependentConstraint


def participation_f(*, Q_f, V_alone_f, slack):
    return Q_f >= V_alone_f - slack


constraints = {
    "affordable": consumption_within_wealth,
    "participation_f": ValueDependentConstraint(
        predicate=participation_f,
        references={
            "V_alone_f": ProjectedRegimeValue(
                regime="single_f",
                projection={"wealth": half_of_couple_wealth},
            )
        },
    ),
}

The predicate may read Q_<s> for each stakeholder, each key of its own references, and ordinary states, actions, functions and parameters. projection owes one entry per state of the referenced regime; the value is interpolated at the resulting coordinates. A constraint-local projection may not introduce free parameters — that is checked when the Regime is constructed, naming the reference, the projection and the offending argument.

The regime’s mask is the AND of the ordinary constraints and the value-dependent ones. A cell whose mask is empty carries the -inf sentinel: there is no viable household there. Where its members go instead is declared on the regime as a participation Exit, or on the incoming edge as a ParticipationCheck reading the household’s policy support.

A value constraint cannot excuse a discrete law that leaves its grid. Such a law sends a state to a code outside 0..n-1, and an off-grid code has no value to compare with. The transition check therefore counts every cell the ordinary constraints allow, in the solve and the simulation, and raises InvalidStateTransitionError where a law leaves its grid there. When a restriction does not read a value, declare it as an ordinary constraint beside the value-dependent one:

def rest_only(*, work):
    return work == 0


def participation_f(*, Q_f):
    return Q_f >= 0.0


constraints = {
    "rest_only": rest_only,  # value-free: an ordinary constraint
    "participation_f": ValueDependentConstraint(predicate=participation_f),
}

Written as one predicate, (work == 0) & (Q_f >= 0.0), the same restriction is refused when only work == 1 sends a discrete state off its grid.

Marrying and dissolving: a participation check on landing

A raw transition between regimes with different stakeholder structure stays rejected — there is no rule that would say who the arriving row is. A ParticipationCheck supplies one. It lives on the source’s edges in Model(edges=...), in the Transition’s participation, keyed by the destination it decides on. Its reads name every value the predicate compares, each a ParticipationRead of a regime at projected coordinates:

from lcm import (
    ParticipationCheck,
    ParticipationRead,
    ProjectedRegimeValue,
    StakeholderRoute,
    StochasticTransition,
    Transition,
)

single_f = Regime(
    states={"wealth": single_wealth},
    state_transitions={"wealth": next_single_wealth},
    actions={"consumption": consumption},
    functions={"utility": utility_single},
)

edges = {
    "single_f": Transition(
        law={
            "couple": StochasticTransition(func=meets_a_partner),
            "single_f": StochasticTransition(func=meets_nobody),
        },
        participation={
            "couple": ParticipationCheck(
                predicate=mutual_consent,
                routes={
                    "her": StakeholderRoute(
                        target_stakeholder="f",
                        fallback=ProjectedRegimeValue(
                            regime="single_f",
                            projection={"wealth": half_of_couple_wealth},
                        ),
                    )
                },
                reads={
                    "V_couple_f": ParticipationRead(
                        kind="IntegratedFull", regime="couple", stakeholder="f"
                    ),
                    "V_couple_m": ParticipationRead(
                        kind="IntegratedFull", regime="couple", stakeholder="m"
                    ),
                    "V_alone_f": ParticipationRead(
                        kind="IntegratedFull", regime="single_f", projection=...
                    ),
                    "V_alone_m": ParticipationRead(
                        kind="IntegratedFull", regime="single_m", projection=...
                    ),
                },
            ),
        },
    ),
    # ... the other sources' edges
}

The law names its targets, so the Transition derives its destinations from it: each key at every non-final age, and each route’s fallback regime wherever its decided target is reached. Supplying targets as well is allowed only if it says exactly the same.

The law and the decision are two distinct operations. The law’s "couple" cell decides whether this target edge is attempted at all, like any other per-target cell. The decision, having arrived at the target’s coordinates, says whether the row keeps that target or takes its route’s fallback. A move decided in the current period instead — the household dissolving because a partner walks out now — is a participation Exit in the source’s Regime(exits=...); see the migration guide.

A gate routes on states

A Gate in Transition(gates=...) takes the same routes as a participation check, but its predicate decides from the landing state alone: a Boolean function on the target regime’s grid that reads ordinary target states and params and the target fold’s period / age.

One Gate per target holds at every age the target is reached and in both phases; it is never wrapped in ByAge or Phased. When the source’s law is Phased, only the law differs between the phases, which permits a perceived probability in solve and a realized probability in simulation without changing what consent means. When the edges are Phased with a Transition in each phase, a target reached in both carries the equal Gate in both or none. A route’s fallback is the one part of a gate that may be Phased.

A gate never reads a value. A predicate naming V_target, V_target_<s> or D_target raises GateReadsValueError when the Gate is declared, and a Gate takes no references. Write a comparison of values as a ParticipationCheck with named reads, or as an Exit of the source regime.

The Boolean requirement is not a build-time check: the dtype is examined where the gate is actually evaluated, so a gate returning a probability is refused on the first solve() rather than at construction. The branch is selected with a strict where, in which every nonzero value is true, so a 0.25 would otherwise open the edge for every row.

The key is always the open branch

This is the rule that decides how a dissolution edge is written. A couple that stays together continues in the couple regime, so that is the key, with a predicate that holds where the household still has a feasible action:

edges = {
    "couple": Transition(
        law={"couple": StochasticTransition(func=stays_married)},
        participation={
            "couple": ParticipationCheck(
                predicate=has_policy_support,  # target_support
                reads={
                    "target_support": ParticipationRead(
                        kind="Participating",
                        regime="couple",
                        stakeholder="f",
                        field="support",
                        projection=...,
                    )
                },
                routes={
                    "f": StakeholderRoute(
                        target_stakeholder="f",
                        fallback=ProjectedRegimeValue(
                            regime="single_f", projection=...
                        ),
                    ),
                    "m": StakeholderRoute(
                        target_stakeholder="m",
                        fallback=ProjectedRegimeValue(
                            regime="single_m", projection=...
                        ),
                    ),
                },
            ),
        },
    ),
    # ... the other sources' edges
}

Keying it by single_f instead would send both partners to single_f whenever the couple stays together, because the key is where the open branch goes.

The same rule holds for a Gate. Each route owns four destinations, and simulation carries all four: the open branch’s regime (the key) and role (target_stakeholder), and the closed branch’s regime and role (fallback.regime and fallback.stakeholder). A row landing in a singleton regime carries no role.

Entering on the target’s own terms

The candidate target state is computed by the source’s law of motion, so a law that is right for staying single can be wrong for marrying. Two people pool their wealth, and the couple’s grid is on that scale — declare the entry law per target:

state_transitions = {
    "wealth": {
        "couple": pooled_wealth,  # 2 * (1 + r) * (wealth - consumption)
        "single_f": next_single_wealth,  # (1 + r) * (wealth - consumption)
        "single_f_terminal": next_single_wealth,
    }
}

Without it every bride lands at the bottom of the couple’s grid, where the household is infeasible — and the consent decision then shuts for a reason that is an artifact of the law rather than an economic decision.

Landing between grid nodes

A gate’s off_grid says what the edge promises when the realized target coordinate falls between the target’s nodes:

Participation checks over several periods

A ParticipationCheck is a participation check at the date a scheduled transition lands. The transition produces the landing state, the check compares values there, and the row then either takes the target or its route’s fallback. lcm_examples.collective_household declares two:

A household choosing today has to price these checks: its savings depend on what each member will be worth after tomorrow’s check. Tomorrow’s value depends on the household’s own choice tomorrow, which prices the check the day after, and so on to the horizon. Pylcm evaluates the first of these checks exactly and every later one from a stored table of what the check compares. This section says what that means for your results.

What is exact

The rebuilt action problem still reads its own continuation from the stored value function by interpolation, like every GridSearch problem. “Exact” means the check adds no approximation of its own.

What is interpolated

The action problem rebuilt at d+1d+1 contains the next check, at d+2d+2. Pylcm does not rebuild that one in turn. It reads a stored surface instead, tabulated at every node of the target’s grid at d+2d+2. The surface does not store the verdict or the value after it. It stores what the check consumes, separately:

At a landing between nodes each of these is interpolated on its own, exactly as a value function is, and the check is run again on the interpolated operands at that landing. Each member then takes the branch the check selects there. On a node every stored entry is that node’s own, so the read is the check the rebuild takes at that node.

Policy support is a yes-or-no answer, so it is stored as an indicator, one or zero. Read between nodes, it counts as support where the interpolated indicator is at least one half: exact on nodes, and in a cell whose two ends disagree the switch falls halfway between them. A value read off an answer with support is the support-weighted mean of the supported nodes around the landing, so a node without support, whose value is −∞-\infty, neither enters it nor makes it infinite.

A check that observes realized physical information (an observes= on the decision) cannot be run again between nodes, because the realization it reads exists only at a node. Its surface stores the value each member holds after the check instead, integrated over that information at each node, and is interpolated like a value function; where that check switches, a member’s value can jump inside the cell, with the error up to the jump that the next section describes for a check on support.

A node whose check fails its integrity test stores NaN in every entry, so any value read from it is NaN as well rather than a silently wrong number.

This affects a period only when

In a four-period household model only period 0 is affected; periods 1 to 3 are unchanged. In a longer model every period up to the last few is.

Grid nodes of today’s value function are not exempt. A node’s value prices landings tomorrow, and those generally fall between nodes of the next date’s surface.

Saved solutions carry the surfaces beside the value functions, so a loaded solution simulates exactly like the one that wrote it.

Why: the cost of rebuilding every check

Rebuilding every check at its landing nests one action problem inside the next, down to the horizon. Each further participation date multiplies the cost of a period by roughly the target’s number of actions times its reads and lottery nodes. In the household example with 10 wealth nodes and 5 consumption shares, going from three to four periods raised the couple’s period-0 query time from 57 ms to 1048 ms on one CPU machine, and the four-period solve took 84.9 s with 10.9 GB peak memory.

With stored surfaces each period prices exactly one check, whatever the horizon, so the solve grows linearly in the number of periods. The same four-period solve takes 37.0 s and 3.8 GB peak memory, compiling from an empty cache. A surface holds a few entries per node instead of one value per member (eight instead of two for the household’s continuing couple), which is small next to the target’s own value function and its action problem.

There is no option to rebuild every check instead.

How large the error is

Between two grid nodes xi<xi+1x_i < x_{i+1}, a distance hh apart, each branch and each compared value is read off its own straight chord. Where the check does not switch inside the cell, every member’s value is a smooth branch read off its chord, and its error is second order in hh. What happens where the check switches, at x∗x^*, depends on what the check compares:

 values decide: the switch is found again        support decides: the switch is put
 at the landing, from the two chords             halfway, wherever x* is in the cell

  ^                 accept (his)                  ^                 accept
  |         o-------*------------o                |         o--------------o
  |                 :                             |                 :   :
  |                 : J                           |                 : J : wrong side
  |                 :                             |                 :   :
  |  o--------------*-----o  reject               |  o--------------:---:--o  reject
  +--+--------------+-----+---> y                 +--+--------------+---+--+---> y
    x_i            x*   x_{i+1}                     x_i            x*  mid x_{i+1}

With JJ the gap between a member’s branches at x∗x^*, the error at a landing yy is at most

h28max⁡∣B′′∣⏟branch B the check selectsexcept∣J∣⏟∣y−x∗∣=O(h2) (values decide)∣y−x∗∣≤h/2 (support decides).\underbrace{\tfrac{h^2}{8}\max|B''|}_{\text{branch } B \text{ the check selects}} \qquad \text{except} \qquad \underbrace{|J|}_{\substack{|y - x^*| = O(h^2) \text{ (values decide)} \\ |y - x^*| \le h/2 \text{ (support decides)}}}.

Three measurements show the pattern. All compare against rebuilding every check at its landing; “value after the check” is the surface the previous release stored, the value each member holds after the check, interpolated across the switch.

A two-member reference model (pure Python; nine consumption shares; she leaves where her single value beats the household, he always prefers to stay). The largest error of a read at the last check, over landings in the cell holding the switch but at least h/4h/4 from it:

node spacing hhvalue after the checkbranches, decided at the landing
4.751.370.069
2.380.840.015
1.191.640.0036
0.591.250.00084
0.300.900.00026

His jump is 2.7. Read after the check, the error near the switch does not shrink; read as branches, it falls by about four for each halving of hh. With support deciding the same model’s check instead, neither read converges near the switch (both stay between 0.36 and 2.0, for a jump of 1.9), as the bound above says.

A pylcm witness (tests/solution/test_participation_pricing_depth.py; the same structure: she leaves rich households at the last check, he is overruled, and every period-0 node lands at the middle of a cell there). The largest error of the couple’s period-0 values, both members, all nodes:

node spacing hhvalue after the checkbranches, decided at the landing
42.480.048
22.460.017
12.450.0050
0.52.450.0014

The household example (four periods, three consumption shares, 64-bit), the couple’s period-0 values for both members, whose values range from about 1.9 to 7.9:

wealth nodesnode spacingmean errormax error
412.70.1020.284
67.60.1160.241
104.20.0750.693
162.50.0340.548

The only check this model reads from a surface is the continuing couple’s, and support decides it, so storing branches changes only the nodes whose landings fall in a cell where support switches: no node at 4 wealth nodes, two at 6 and 16, three at 10 (where the second-largest error falls from 0.56 to 0.21). At 10 and 16 nodes the largest error is not among them; it is the same whether the surface stores branches or the value after the check. It comes from a decision taken on the surfaces’ small smooth error near a threshold — the household’s own choice, or the exact check at the next landing — which then flips and moves a member’s value by a jump. Such a flip is an error in the stretch of wealth around a threshold, not at a fixed node: it moves as the grid is refined, and the mean, which falls to 0.034 at 16 nodes, is the better summary of how far the solution is from rebuilding every check.

This is the same kind of error a GridSearch model already makes wherever it interpolates a value function: a value function is a maximum over discrete choices, with a kink wherever the best choice switches, and any choice made on interpolated values can flip near a tie.

When to care and what to do

No verdict is interpolated, but the values a check compares price the checks after it, and so do earlier choices such as saving ahead of a later check. Both carry the surfaces’ error. It matters most when

To check and reduce it:

Simulating: every row carries its own role

Stakeholder identity is per subject, not per call. A row’s role picks its route on a value-dependent transition; the branch it takes then sets the role it carries onward.

initial_conditions = {
    "wealth": jnp.asarray([...]),
    "age": jnp.zeros(n_subjects),
    "regime_id": jnp.full(n_subjects, model.regime_names_to_ids["couple"], jnp.int32),
    "own_stakeholder": jnp.asarray([model.stakeholder_names_to_ids["f"], ...]),
}

The role is published as an own_stakeholder column beside the columns that identify a row, carrying the stakeholder’s label and missing wherever the row occupies no role. One cohort can therefore hold both partners, and a model whose collective regimes use disjoint role vocabularies — carer and ward in one, f and m in another — is expressible.

Parameters

Everything lands where the declaration is:

template["couple"]["utility_f"]["crra"]  # a stakeholder's own utility
template["couple"]["pareto_objective"]["weight_f"]  # the objective's free arguments
template["couple"]["participation_f"]["slack"]  # a value constraint's predicate
template["edges"]["single_f"]["couple"]["predicate"]["bonus"]  # one gate's predicate

Edge parameters belong to the edges, not to the source regime: each sits at its declaration path under params["edges"][source][target] and is discovered from that edge’s own target states — never from a union over unrelated regimes. See Edge parameter paths.

What this costs

See the resource contract for the workloads these declarations are benchmarked on and which axis each cost is allowed to grow along.