Specifying utilities¶
microPURC is linear in parameters: the utility of link \(\ell\) is
\(z_\ell^\top \beta\), where \(z_\ell\) collects the link’s attributes.
A model is a set of terms, each mapping one regressor to one parameter
\(\beta\). The micropurc.spec package lets you declare these terms
either with a Python DSL or with the serializable schema they lower to.
The DSL¶
Each summand is beta(name) * <attribute expression>, optionally gated by a
covariate segment and optionally scaled by a constant. Attribute expressions
may be a single attribute, a product of attributes (an interaction), or an
attribute under a scalar transform (log, power, standardize).
from micropurc import beta, attr, seg, log, power
utility = (
beta("b_time") * attr("time")
+ beta("b_toll_low") * attr("toll") * seg("income", "<=", 3.0)
+ beta("b_toll_high") * attr("toll") * seg("income", ">", 3.0)
+ beta("b_logdist") * log(attr("length"))
+ beta("b_toll_sq") * power(attr("toll"), 2.0)
)
spec = utility.to_spec()
Here seg("income", "<=", 3.0) restricts the toll coefficient to trips whose
income covariate is at most 3, so the toll sensitivity differs across
income segments while sharing the network’s toll attribute.
The schema¶
The DSL lowers to a ModelSpec, a plain
dataclass that round-trips to and from JSON. This makes specifications
serializable and comparable across runs.
from micropurc.spec import ModelSpec
text = spec.to_json()
restored = ModelSpec.from_json(text)
assert restored == spec
Compiling to a design¶
compile_design() binds the named attributes to a
network’s columns and produces the estimation design. Without segmentation the
design is a single matrix Z; with segmentation (or attributes that vary
across variants) it is a stack of per-variant designs, or a lazy design that
the native engine evaluates without materializing (N, L, K).
import numpy as np
from micropurc import Network, compile_design
net = Network.grid(rows=4, cols=4, K=3)
# Generate the attributes the spec names. ``length`` must be strictly
# positive because the spec takes its ``log``.
rng = np.random.default_rng(7)
net.Z = np.column_stack([
rng.uniform(1.0, 5.0, net.num_links), # length
rng.uniform(1.0, 5.0, net.num_links), # time
np.where(rng.random(net.num_links) < 0.4,
rng.uniform(1.0, 3.0, net.num_links), 0.0), # toll
])
net.attribute_names = ["length", "time", "toll"]
# Two income variants, one covariate column.
variant_covariates = np.array([[1.0], [5.0]])
trip_variant = np.array([0, 1, 0, 1])
design = compile_design(
spec,
net,
variant_covariates=variant_covariates,
covariate_names=["income"],
trip_variant=trip_variant,
materialize=False, # build a lazy design for the native engine
)