diff --git a/effectful/handlers/jax/monoid.py b/effectful/handlers/jax/monoid.py index 28233e764..fe528aa4d 100644 --- a/effectful/handlers/jax/monoid.py +++ b/effectful/handlers/jax/monoid.py @@ -18,6 +18,7 @@ from effectful.handlers.jax.scipy.special import logsumexp from effectful.ops.monoid import ( And, + Body, CartesianProduct, EvaluateIntp, LogSumExp, @@ -25,6 +26,7 @@ Min, Monoid, NormalizeIntp, + Optimum, Or, Product, Streams, @@ -51,6 +53,11 @@ logger = logging.getLogger(__name__) +# ``Optimum`` lives in the backend-independent module, but once the JAX backend +# is imported it should be a valid input/output of ``jax.jit``. +jax.tree_util.register_dataclass(Optimum) + + is_equality.register(jnp.equal) for a, b in { (jnp.less, jnp.greater), @@ -90,8 +97,10 @@ def _is_jax(t): return issubclass(t, jax.Array | jax.core.Tracer) # exists array valued and non-array-valued args - if any(_is_jax(t) for t in arg_types) and any( - not _is_jax(t) for t in arg_types + if ( + any(_is_jax(t) for t in arg_types) + and any(not _is_jax(t) for t in arg_types) + and all(issubclass(t, jax.typing.ArrayLike) for t in arg_types) ): return monoid.plus( *( @@ -259,6 +268,76 @@ def __call__( ARRAY_REDUCTORS[LogSumExp] = logsumexp +class ReduceOptimum(ObjectInterpretation): + """Reduce an assignment-carrying JAX score with ``argmin`` or ``argmax``. + + The initial kernel supports independent ``range`` streams and assignments + that record stream variables directly. Those are the normal form produced + by ``Sum.weighted(..., lambda v: Optimum(0, {x: v}))``. + """ + + @implements(Monoid.reduce) + def reduce(self, monoid: Monoid, body: Body, streams: Streams): + if monoid not in (Min, Max) or not isinstance(body, Optimum): + return fwd() + if not issubclass(typeof(body.value), jax.Array): + return fwd() + if not streams or not all( + isinstance(stream, range) for stream in streams.values() + ): + return fwd() + + # For now assignments must be direct references to reduction variables. + # Keeping this check narrow is preferable to silently returning a wrong + # provenance value for an arbitrary assignment expression. + assignment_vars = {} + for key, value in body.assignment.items(): + if not (isinstance(value, Term) and value.op in streams): + return fwd() + assignment_vars[key] = value.op + + if any(len(typing.cast(range, stream)) == 0 for stream in streams.values()): + return monoid.identity + + score_fvs = fvsof(body.value) + used = tuple(k for k in streams if k in score_fvs) + used_streams = {k: streams[k] for k in used} + + if used: + # Materialize one leading positional axis per used stream. Existing + # delta lowering performs vectorized substitution and preserves any + # trailing batch dimensions of the score. + score = monoid.reduce( + monoid.delta(tuple(k() for k in used), body.value), used_streams + ) + if not isinstance(score, jax.Array | jax.core.Tracer): + return fwd() + + reduction_shape = tuple(len(typing.cast(range, streams[k])) for k in used) + if tuple(score.shape[: len(used)]) != reduction_shape: + return fwd() + + flat_size = functools.reduce(lambda a, b: a * b, reduction_shape, 1) + flat_score = jnp.reshape(score, (flat_size, *score.shape[len(used) :])) + arg_reduce = jnp.argmin if monoid is Min else jnp.argmax + flat_index = arg_reduce(flat_score, axis=0) + value = jnp.take_along_axis(flat_score, flat_index[None], axis=0)[0] + coordinates = jnp.unravel_index(flat_index, reduction_shape) + positions = dict(zip(used, coordinates, strict=True)) + else: + # An invariant score ties everywhere; monoid reduction chooses the + # first candidate, matching both Python's min/max and JAX argmin/max. + value = body.value + positions = {} + + assignment = {} + for key, variable in assignment_vars.items(): + stream = typing.cast(range, streams[variable]) + position = positions.get(variable, 0) + assignment[key] = stream.start + stream.step * position + return Optimum(value, assignment) + + class ReduceArray(ObjectInterpretation): """Reduce an array body over range streams.""" @@ -822,6 +901,7 @@ def einsum( ReduceDeltaSimpleRange(), ReduceArrayScan(), PlusCastArray(), + ReduceOptimum(), ) NormalizeIntp.extend( diff --git a/effectful/handlers/numpyro.py b/effectful/handlers/numpyro.py index 818f5b3bb..dcd95a6f6 100644 --- a/effectful/handlers/numpyro.py +++ b/effectful/handlers/numpyro.py @@ -1,6 +1,7 @@ try: import numpyro import numpyro.distributions as dist + import numpyro.optim except ImportError: raise ImportError("Numpyro is required to use effectful.handlers.numpyro") @@ -16,14 +17,16 @@ from effectful.handlers.jax._handlers import _register_jax_op, is_eager_array from effectful.ops.monoid import ( LogSumExp, + Min, Monoid, NormalizeIntp, + Optimum, Product, Stream, Streams, Sum, ) -from effectful.ops.semantics import evaluate, fwd, typeof +from effectful.ops.semantics import evaluate, fvsof, fwd, handler, typeof from effectful.ops.syntax import ObjectInterpretation, defdata, deffn, defop, implements from effectful.ops.types import NotHandled, Operation, Term @@ -1194,7 +1197,6 @@ def _embed_independent(d: dist.Independent) -> Term[dist.Independent]: return Independent(d.base_dist, d.reinterpreted_batch_ndims) -@Operation.define def distribution_stream( distribution: numpyro.distributions.Distribution, ) -> Stream[jax.Array]: @@ -1237,4 +1239,135 @@ def _(self, monoid, body, streams: Streams): return fwd() +@Operation.define +def constraint_stream( + constraint: numpyro.distributions.constraints.Constraint, prototype: jax.Array +) -> Stream[jax.Array]: + raise NotHandled + + +class NestConstraintMinReduce(ObjectInterpretation): + """Move constraint streams into an inner :data:`~effectful.ops.monoid.Min`. + + This rewrites:: + + Min.reduce(body, constraint_streams | other_streams) + + to:: + + Min.reduce(Min.reduce(body, constraint_streams), other_streams) + + when both partitions are nonempty. A constraint stream may depend on an + outer stream, but an outer stream may not depend on a constraint variable; + the latter ordering would move the variable outside its scope and is left + for another handler. + """ + + @implements(Min.reduce) + def reduce(self, body, streams): + constraint_streams = { + key: stream + for key, stream in streams.items() + if isinstance(stream, Term) and stream.op is constraint_stream + } + if not constraint_streams or len(constraint_streams) == len(streams): + return fwd() + + other_streams = { + key: stream + for key, stream in streams.items() + if key not in constraint_streams + } + if fvsof(other_streams) & set(constraint_streams): + return fwd() + + return Min.reduce(Min.reduce(body, constraint_streams), other_streams) + + +class AdamConstraintMinReduce(ObjectInterpretation): + """Minimize an all-continuous constraint-stream bundle with Adam. + + Optimization takes place in unconstrained coordinates using NumPyro's + ``biject_to`` transforms. The prototypes carried by + :func:`constraint_stream` determine shape and dtype; each constraint's + :meth:`~numpyro.distributions.constraints.Constraint.feasible_like` method + constructs the constrained initial value. + """ + + def __init__( + self, + step_size=1e-2, + *, + num_steps: int = 1_000, + b1: float = 0.9, + b2: float = 0.999, + eps: float = 1e-8, + ): + if num_steps < 0: + raise ValueError("num_steps must be nonnegative") + self.num_steps = num_steps + self.optimizer = numpyro.optim.Adam(step_size=step_size, b1=b1, b2=b2, eps=eps) + + @implements(Min.reduce) + def reduce(self, body, streams): + if not streams or not all( + isinstance(stream, Term) and stream.op is constraint_stream + for stream in streams.values() + ): + return fwd() + + constraints = tuple(stream.args[0] for stream in streams.values()) + if any(constraint.is_discrete for constraint in constraints): + return fwd() + + score = body.value if isinstance(body, Optimum) else body + stream_keys = tuple(streams) + + transforms = tuple( + numpyro.distributions.transforms.biject_to(constraint) + for constraint in constraints + ) + prototypes = tuple(stream.args[1] for stream in streams.values()) + constrained_initial = tuple( + constraint.feasible_like(prototype) + for constraint, prototype in zip(constraints, prototypes, strict=True) + ) + unconstrained_initial = tuple( + transform.inv(value) + for transform, value in zip(transforms, constrained_initial, strict=True) + ) + + def substitute(value, unconstrained): + constrained = tuple( + transform(x) + for transform, x in zip(transforms, unconstrained, strict=True) + ) + substitutions = { + key: (lambda value=value: value) + for key, value in zip(stream_keys, constrained, strict=True) + } + with handler(substitutions): + return evaluate(value) + + def objective(unconstrained): + return substitute(score, unconstrained) + + initial_score = objective(unconstrained_initial) + if isinstance(initial_score, Term): + return fwd() + if jnp.ndim(initial_score) != 0: + raise ValueError("Min objective must be scalar") + + state = self.optimizer.init(unconstrained_initial) + + def step(_, state): + (_, _), state = self.optimizer.eval_and_stable_update( + lambda params: (objective(params), None), state + ) + return state + + state = jax.lax.fori_loop(0, self.num_steps, step, state) + return substitute(body, self.optimizer.get_params(state)) + + NormalizeIntp.extend(ReduceEnumerableDistribution()) diff --git a/effectful/ops/monoid.py b/effectful/ops/monoid.py index 37ae0bf6f..2dd0c1e0c 100644 --- a/effectful/ops/monoid.py +++ b/effectful/ops/monoid.py @@ -203,10 +203,16 @@ def __init__(self, name: str, identity: T, zero: T): self.zero = zero +@dataclass(frozen=True) +class Optimum[T]: + """A value together with an assignment that attains it.""" + + value: T + assignment: Mapping[Operation, Any] + + Min = Monoid(name="Min", identity=float("inf")) Max = Monoid(name="Max", identity=-float("inf")) -ArgMin = Monoid(name="ArgMin", identity=(Min.identity, None)) -ArgMax = Monoid(name="ArgMax", identity=(Max.identity, None)) Sum = Group(name="Sum", identity=0) Product = MonoidWithZero(name="Product", identity=1, zero=0) LogSumExp = Monoid(name="LogSumExp", identity=float("-inf")) @@ -1573,34 +1579,6 @@ def plus(self, *args): ) -class ArgMinPlus(ObjectInterpretation): - """Scalar score implementation of :data:`ArgMin`.""" - - @implements(ArgMin.plus) - def plus(self, *args): - if not args or not all(isinstance(a, tuple) for a in args): - return fwd() - if any(isinstance(a[0], Term) for a in args): - return fwd() - if not all(isinstance(a[0], int | float) for a in args): - return fwd() - return min(args, key=lambda a: a[0]) - - -class ArgMaxPlus(ObjectInterpretation): - """Scalar score implementation of :data:`ArgMax`.""" - - @implements(ArgMax.plus) - def plus(self, *args): - if not args or not all(isinstance(a, tuple) for a in args): - return fwd() - if any(isinstance(a[0], Term) for a in args): - return fwd() - if not all(isinstance(a[0], int | float) for a in args): - return fwd() - return max(args, key=lambda a: a[0]) - - def _disjoint_merge[K, V](*dicts: Mapping[K, V]) -> Mapping[K, V]: merged = {} for d in dicts: @@ -1613,6 +1591,72 @@ def _disjoint_merge[K, V](*dicts: Mapping[K, V]) -> Mapping[K, V]: return merged +class PlusOptimum(ObjectInterpretation): + """Sum values that are annotated with a minimizing (or maximizing) assignment.""" + + def _is_reducible(self, args): + return ( + args + and any(isinstance(a, Optimum) for a in args) + and all(not fvsof(a.value if isinstance(a, Optimum) else a) for a in args) + ) + + def _optimum_min_max(self, func, *args): + return ( + func(args, key=lambda a: a.value if isinstance(a, Optimum) else a) + if self._is_reducible(args) + else fwd() + ) + + @implements(Min.plus) + def _min_plus(self, *args): + return self._optimum_min_max(min, *args) + + @implements(Max.plus) + def _max_plus(self, *args): + return self._optimum_min_max(max, *args) + + @implements(Sum.plus) + def _sum_plus(self, *args): + return ( + Optimum( + Sum.plus( + *(arg.value if isinstance(arg, Optimum) else arg for arg in args) + ), + _disjoint_merge( + *( + arg.assignment if isinstance(arg, Optimum) else {} + for arg in args + ) + ), + ) + if self._is_reducible(args) + else fwd() + ) + + @implements(Monoid.plus) + def plus(self, monoid, *args): + return ( + monoid.plus(*(a.value if isinstance(a, Optimum) else a for a in args)) + if self._is_reducible(args) + else fwd() + ) + + +class PlusCastOptimum(ObjectInterpretation): + """Upcast non-Optimum arguments to Monoid.plus.""" + + @implements(Monoid.plus) + def plus(self, monoid, *args): + num_optimum = sum(isinstance(a, Optimum) for a in args) + if 0 < num_optimum < len(args): + new_args = ( + Optimum(a, {}) if not isinstance(a, Optimum) else a for a in args + ) + return monoid.plus(*new_args) + return fwd() + + class CartesianProductPlus(ObjectInterpretation): """Pure-Python implementation of :data:`CartesianProduct`.""" @@ -2130,15 +2174,13 @@ def extend(self, *intps: Interpretation) -> typing.Self: ReducePartial(), DeltaConcrete(), SumPlus(), - SumInverse(), MinPlus(), MaxPlus(), ProductPlus(), - ArgMinPlus(), - ArgMaxPlus(), CartesianProductPlus(), UnionPlus(), IntersectionPlus(), + PlusOptimum(), ReduceWhereToMasks(), ) @@ -2190,6 +2232,8 @@ def extend(self, *intps: Interpretation) -> typing.Self: PlusOrder(), PlusCastFloat(), PlusCastIterable(), + PlusOptimum(), + PlusCastOptimum(), MaskFusion(), MaskBool(), WhereHoist(), @@ -2202,8 +2246,6 @@ def extend(self, *intps: Interpretation) -> typing.Self: MinPlus(), MaxPlus(), ProductPlus(), - ArgMinPlus(), - ArgMaxPlus(), CartesianProductPlus(), UnionPlus(), AndPlus(), diff --git a/effectful/ops/syntax.py b/effectful/ops/syntax.py index c41fcc9c6..7b0d192cd 100644 --- a/effectful/ops/syntax.py +++ b/effectful/ops/syntax.py @@ -1039,7 +1039,7 @@ def syntactic_hash(__dispatch: Callable[[type], Callable[[Any], int]], x) -> int :param x: A term. :returns: An integer hash. """ - if dataclasses.is_dataclass(x) and not isinstance(x, type): + if dataclasses.is_dataclass(x) and not isinstance(x, type | Term): return hash( ( "dataclass", diff --git a/tests/test_handlers_numpyro.py b/tests/test_handlers_numpyro.py index 8a9b03b3c..9dab2d689 100644 --- a/tests/test_handlers_numpyro.py +++ b/tests/test_handlers_numpyro.py @@ -12,8 +12,8 @@ import effectful.handlers.jax.numpy as jnp import effectful.handlers.numpyro as dist from effectful.handlers.jax import bind_dims, jax_getitem, sizesof, unbind_dims -from effectful.ops.monoid import LogSumExp, Product, Sum -from effectful.ops.semantics import typeof +from effectful.ops.monoid import LogSumExp, Min, Optimum, Product, Sum +from effectful.ops.semantics import handler, typeof from effectful.ops.syntax import deffn, defop from effectful.ops.types import Operation, Term from tests._monoid_helpers import JaxBackend @@ -1058,3 +1058,87 @@ def model(): mcmc.run(jr.PRNGKey(0)) assert mcmc.get_samples()["theta"].shape == (20, 3) + + +def test_nest_constraint_min_reduce(): + x = defop(jax.Array, name="x") + y = defop(jax.Array, name="y") + constrained = dist.constraint_stream( + numpyro.distributions.constraints.positive, jnp.asarray(1.0) + ) + + with handler(dist.NestConstraintMinReduce()): + result = Min.reduce(x(), {x: constrained, y: range(3)}) + + assert isinstance(result, Term) and result.op is Min.reduce + inner, outer_streams = result.args + assert list(outer_streams.values()) == [range(3)] + assert isinstance(inner, Term) and inner.op is Min.reduce + assert len(inner.args[1]) == 1 + inner_stream = next(iter(inner.args[1].values())) + assert isinstance(inner_stream, Term) and inner_stream.op is dist.constraint_stream + + +def test_adam_constraint_min_reduce(): + x = defop(jax.Array, name="x") + y = defop(jax.Array, name="y") + streams = { + x: dist.constraint_stream( + numpyro.distributions.constraints.positive, jnp.asarray(0.5) + ), + y: dist.constraint_stream( + numpyro.distributions.constraints.real, jnp.asarray(0.0) + ), + } + objective = Optimum( + (x() - 2.0) ** 2 + (y() + 1.0) ** 2, + {"x": x(), "y": y()}, + ) + + with handler(dist.AdamConstraintMinReduce(step_size=0.05, num_steps=500)): + result = Min.reduce(objective, streams) + + assert isinstance(result, Optimum) + assert jnp.allclose(result.value, 0.0, atol=1e-5) + assert jnp.allclose(result.assignment["x"], 2.0, atol=1e-4) + assert jnp.allclose(result.assignment["y"], -1.0, atol=1e-4) + + +def test_adam_constraint_min_reduce_initializes_with_feasible_like(): + x = defop(jax.Array, name="x") + constraint = numpyro.distributions.constraints.interval(-2.0, 4.0) + stream = dist.constraint_stream(constraint, jnp.asarray(100.0)) + + with handler(dist.AdamConstraintMinReduce(num_steps=0)): + result = Min.reduce(x(), {x: stream}) + + assert jnp.allclose(result, 1.0) + + +def test_adam_constraint_min_reduce_forwards_mixed_bundle(): + x = defop(jax.Array, name="x") + y = defop(jax.Array, name="y") + constrained = dist.constraint_stream( + numpyro.distributions.constraints.real, jnp.asarray(0.0) + ) + + with handler(dist.AdamConstraintMinReduce()): + result = Min.reduce(x() ** 2, {x: constrained, y: range(2)}) + + assert isinstance(result, Term) and result.op is Min.reduce + assert len(result.args[1]) == 2 + + +def test_nest_constraint_min_reduce_preserves_stream_dependencies(): + x = defop(jax.Array, name="x") + y = defop(jax.Array, name="y") + constrained = dist.constraint_stream( + numpyro.distributions.constraints.positive, jnp.asarray(1.0) + ) + + with handler(dist.NestConstraintMinReduce()): + result = Min.reduce(x(), {x: constrained, y: (x(),)}) + + assert isinstance(result, Term) and result.op is Min.reduce + assert len(result.args[1]) == 2 + assert not (isinstance(result.args[0], Term) and result.args[0].op is Min.reduce) diff --git a/tests/test_ops_monoid.py b/tests/test_ops_monoid.py index 6bc4b8810..7cc974547 100644 --- a/tests/test_ops_monoid.py +++ b/tests/test_ops_monoid.py @@ -28,6 +28,7 @@ MonoidOverMapping, MonoidOverSequence, NormalizeIntp, + Optimum, Or, PlusAssoc, PlusCastIterable, @@ -65,7 +66,7 @@ solve_group_equality, ) from effectful.ops.semantics import coproduct, evaluate, fvsof, handler -from effectful.ops.syntax import as_dict, ite, range_, syntactic_eq +from effectful.ops.syntax import as_dict, deffn, ite, range_, syntactic_eq from effectful.ops.types import NotHandled, Operation, Term from tests._monoid_helpers import Backend, IntBackend, JaxBackend, syntactic_eq_alpha @@ -1349,6 +1350,86 @@ def test_reduce_weighted_factorization(backend: Backend): ) +def test_reduce_argmin_weighted_factorization(backend: Backend): + """Factoring a separable argmin preserves both minimizing assignments.""" + x, xx, y, yy, v = backend.define_vars("x", "xx", "y", "yy", "v", ret="scalar") + + lhs = Min.reduce( + Sum.plus(Optimum((x() - 1) ** 2, {}), Optimum((y() - 2) ** 2, {})), + { + x: Sum.weighted(range(3), deffn(Optimum(Sum.identity, {xx: v()}), v)), + y: Sum.weighted(range(4), deffn(Optimum(Sum.identity, {yy: v()}), v)), + }, + ) + rhs = Sum.plus( + Min.reduce( + Sum.plus( + Optimum(Sum.identity, {xx: x()}), + Sum.plus(Optimum((x() - 1) ** 2, {})), + ), + {x: range(3)}, + ), + Min.reduce( + Sum.plus( + Optimum(Sum.identity, {yy: y()}), + Sum.plus(Optimum((y() - 2) ** 2, {})), + ), + {y: range(4)}, + ), + ) + + backend.check_rewrite( + lhs=lhs, rhs=rhs, rule=coproduct(ReduceWeightedStream(), Factor()) + ) + + +def test_reduce_argmin_weighted_repeated_factorization(backend: Backend): + """Factoring repeatedly preserves every weighted minimizing assignment.""" + x, xx, y, yy, z, zz, v = backend.define_vars( + "x", "xx", "y", "yy", "z", "zz", "v", ret="scalar" + ) + + lhs = Min.reduce( + Sum.plus( + Optimum((x() - 1) ** 2, {}), + Optimum((y() - 2) ** 2, {}), + Optimum((z() - 3) ** 2, {}), + ), + { + x: Sum.weighted(range(3), deffn(Optimum(Sum.identity, {xx: v()}), v)), + y: Sum.weighted(range(4), deffn(Optimum(Sum.identity, {yy: v()}), v)), + z: Sum.weighted(range(5), deffn(Optimum(Sum.identity, {zz: v()}), v)), + }, + ) + rhs = Sum.plus( + Min.reduce( + Sum.plus( + Optimum(Sum.identity, {xx: x()}), + Sum.plus(Optimum((x() - 1) ** 2, {})), + ), + {x: range(3)}, + ), + Min.reduce( + Sum.plus( + Optimum(Sum.identity, {yy: y()}), + Sum.plus(Optimum((y() - 2) ** 2, {})), + ), + {y: range(4)}, + ), + Min.reduce( + Sum.plus( + Optimum(Sum.identity, {zz: z()}), + Sum.plus(Optimum((z() - 3) ** 2, {})), + ), + {z: range(5)}, + ), + ) + + backend.check_rewrite( + lhs=lhs, rhs=rhs, rule=coproduct(ReduceWeightedStream(), Factor()) + ) + + def test_weighted_expectation_demo(): """Demo: compute E[f(X)] = Σ_x w(x)·f(x) via a weighted reduce. @@ -1601,3 +1682,48 @@ def test_reduce_unfactor_reduces(Sum, Product, backend: Backend): ) rhs = Sum.reduce(Product.plus(f(x()), g(y())), {x: X(), y: Y(), z: Z()}) backend.check_rewrite(lhs=lhs, rhs=rhs, rule=ReduceUnfactor()) + + +def test_reduce_argmin(backend: Backend): + x, xx, v = backend.define_vars("x", "xx", "v", ret="scalar") + + expr = Min.reduce( + (x() - 1) ** 2, + {x: Sum.weighted(range(3), deffn(Optimum(Sum.identity, {xx: v()}), v))}, + ) + + with handler(NormalizeIntp), handler(EvaluateIntp): + result = evaluate(expr) + + assert result == Optimum(0, {xx: 1}) + + +def test_reduce_argmin_sum(backend: Backend): + x, xx, v = backend.define_vars("x", "xx", "v", ret="scalar") + + expr = Min.reduce( + Sum.plus((x() - 1) ** 2, 2 * (x() - 2) ** 2), + {x: Sum.weighted(range(3), deffn(Optimum(Sum.identity, {xx: v()}), v))}, + ) + + with handler(NormalizeIntp), handler(EvaluateIntp): + result = evaluate(expr) + + assert result == Optimum(1, {xx: 2}) + + +def test_reduce_argmin_sum_disjoint(backend: Backend): + x, xx, y, yy, v = backend.define_vars("x", "xx", "y", "yy", "v", ret="scalar") + + expr = Min.reduce( + Sum.plus((x() - 1) ** 2, (y() - 2) ** 2), + { + x: Sum.weighted(range(3), deffn(Optimum(Sum.identity, {xx: v()}), v)), + y: Sum.weighted(range(3), deffn(Optimum(Sum.identity, {yy: v()}), v)), + }, + ) + + with handler(NormalizeIntp), handler(EvaluateIntp): + result = evaluate(expr) + + assert result == Optimum(0, {xx: 1, yy: 2})