Define observation/control alignment for discrete-time models - #331
Conversation
bdacde1 to
007bab7
Compare
007bab7 to
da290c9
Compare
Add observation_control_alignment for discrete-time Simulator (#312) Add an explicit observation_control_alignment: Literal["same_time", "previous_transition"] field to DynamicalModel, defaulting to "same_time" (today's behavior, unchanged). "previous_transition" pairs y_{k+1} with u_k (the control that produced x_{k+1}) instead of pairing y_k with u_k, matching DiscreteControlLoopSimulator's existing closed-loop convention and avoiding the acausal y_0-depends-on-u_0 coupling. For "previous_transition", DiscreteTimeSimulator/dsx.simulate never samples y_0 and excludes x_0/t_0 from the returned SimulatedResult -- states, observations, times, and the caller's ctrl_values all end up the same length, with no padding or off-by-one bookkeeping required. Scope: the plain Simulator/DiscreteTimeSimulator/dsx.simulate generation path only. mppi.py and discrete_controller_simulators.py are unchanged, deferred to a follow-up.
Include x_0 in all results; add controls to SimulatedResult
For observation_control_alignment="previous_transition", the result now keeps
x_0 and the full times/states path (length T), matching "same_time". Only
observations stay one shorter (y_1..y_{T-1}, length T-1) since y_0 is never
sampled -- so states[k+1] pairs with observations[k].
Add a controls field to SimulatedResult carrying the aligned ctrl_values used
(length T for same_time, T-1 for previous_transition; None when uncontrolled).
Also drop the bespoke _sample_discrete_observation_path in favor of calling
_emit_observations directly with sliced states/times, and fix
_sample_observation_path to vmap over arrays rather than indexing by a scanned
integer, which crashed on zero-length observation paths.
Simplified docstring
da290c9 to
65ebdf0
Compare
Trim the observation_control_alignment docstrings in DynamicalModel, _validate_observation_control_alignment, SimulatedResult and _sample_observation_path, and rewrite the DiscreteTimeSimulator docstring with explicit transition and per-convention observation equations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
"DiscreteControlLoopSimulator now deliberately always uses the previous-transition convention, independent of dynamics.observation_control_alignment:"
|
|
"previous_transition only works for simulation, no conditioning and not filtering. Worth delegating to a seperate PR?"
|
|
"Adding controls in SimulatedResults for ODE/SDE/Continuous time. It might also be worthwile to expand support for continuous control beyond zero-order hold."
|
mattlevine22
left a comment
There was a problem hiding this comment.
Overall, seems solid to me.
Main asks are:
-
Clean up the latex math and make sure it renders in markdown (I didn't read all of it because of that) and is fully specified including all zero cases (e.g. s_0 (even with a note that pi auto-initializes s0 in practice))
-
Try to return useful time-indexes that offer some redundancy to users in SimulatedResult (rather than needing to write things like
t[1:]) -
We should always at least issue a warning if we override a user's choice; here w.r.t.
dynamics.observation_control_alignment. -
Look to see if it is easy for the user to simply supply
t0instead of appending it topredict_timesin previous-mode; my guess is not, which is fine. May just be better dealt all at once with #272. Would be great to get your thoughts on this though. -
Add some
is_finitechecks to the tests (esp hierarchical)...we've gotten fooled in the past and had passing tests that were producing NaNs.
NB: there are a lot of new tests, which we've been resistant of. However, I think they seem reasonable, and in particular, when messing with indexing I feel better about more guardrails and edge case tests, so I'm happy with these.
| @@ -6,22 +6,22 @@ single discrete-time trajectory. At each step it performs | |||
| \[ | |||
There was a problem hiding this comment.
This isn't rendering on the GitHub or cursor markdown viewers. Mind massaging this somehow to get it to appear? sometimes you need to wrap long latex lines in ..., not really sure though.
| belief; no synthetic initial observation is generated. Every observation at | ||
| `t[k + 1]` receives `u[k]`, the control that produced its state. Closed-loop | ||
| simulation therefore always follows the `"previous_transition"` convention, | ||
| independently of `dynamics.observation_control_alignment`. For `T` prediction |
There was a problem hiding this comment.
Would rather us give an error if the user selects the wrong dynamics.observation_control_alignment. We should allow them to omit it and then default to the appropriate choice at the right time...hopefully not easier said than done?
| " t = result.times[0]\n", | ||
| " filtered_mean = result.filtered_states_mean[0]\n", | ||
| " axes[0].plot(t, result.observations[0, :, 0], \".\", color=color, alpha=0.4, label=f\"{label} (observed)\")\n", | ||
| " axes[0].plot(t[1:], result.observations[0, :, 0], \".\", color=color, alpha=0.4, label=f\"{label} (observed)\")\n", |
There was a problem hiding this comment.
I don't love that the user has to know to do t[1:]. I've messed stuff like that up so many times over the years.
What do you think about having explicit fields for the observation times, state times, and control times?
@DanWaxman I know this may be a bit wasteful, but (a) maybe it can be optional to return them and (b) I really think ease of use here may be worth it. At least times are scalar so it really just scales with length of the series (not dimensionality of the system).
| "\\begin{aligned}\n", | ||
| "&x_0 \\sim p(x_0)\\\\\n", | ||
| "&y_0 | x_0 \\sim p(y_0 | x_0, t_0) \\\\\n", | ||
| "&\\hat{x}_{0|0} = \\text{FilterUpdate}(y_0, t_0) \\\\\n", |
There was a problem hiding this comment.
Should still define all the 0 variables so that this system of relations is fully specified. I'll try to flag that where I see it, but maybe ask uncle AI to find those spots.
| observation_dim: int | None = None, | ||
| categorical_state: bool | None = None, | ||
| continuous_time: bool | None = None, | ||
| observation_control_alignment: Literal[ |
There was a problem hiding this comment.
After thinking on it, perhaps for now leave this as is with same_time default; When doing control stuff, if it is same_time, issue a warning that you are changing it to previous.
| assert tr["f_times"]["value"].shape == (2, 1, 4) | ||
| assert tr["f_states"]["value"].shape == (2, 1, 4, 2) | ||
| assert tr["f_observations"]["value"].shape == (2, 1, 3, 1) | ||
| assert tr["f_controls"]["value"].shape == (2, 1, 3, 1) |
There was a problem hiding this comment.
I would assert that things are not NaNs. That has bitten us before.
There was a problem hiding this comment.
Added a finiteness check
| filter_config=EKFConfig(), | ||
| ) | ||
|
|
||
| assert result.states is not None |
There was a problem hiding this comment.
I'd assert more clearly what these should be, e.g. finite arrays
| filter_config=KFConfig(filter_source="cuthbert"), | ||
| ) | ||
|
|
||
| assert result.states is not None |
There was a problem hiding this comment.
again, maybe assert these are finite arrays
There was a problem hiding this comment.
might even be worth a helper that is assert_finite(array, shape=Optional)
|
What you wrote above looks right to me and sounds consistent with this PR. Sounds like the only update is that you are proposing to be more accommodating to time-alignment in the closed loop controller? Seems good to me. |
| y_{k+1} \mid x_{k+1},u_k &\sim p(y_{k+1}\mid x_{k+1},u_k,t_{k+1}), \\ | ||
| \hat{x}_{k+1\mid k+1} &= \operatorname{FilterUpdate} | ||
| (\hat{x}_{k\mid k},u_k,y_{k+1},t_k,t_{k+1}). | ||
| \hat p_{k+1} &= \operatorname{FilterUpdate} |
There was a problem hiding this comment.
RIP now it gets mad: "The following macros are not allowed: operatorname"
Add observation_control_alignment for discrete-time Simulator (#312) Add an explicit observation_control_alignment: Literal["same_time", "previous_transition"] field to DynamicalModel, defaulting to "same_time" (today's behavior, unchanged). "previous_transition" pairs y_{k+1} with u_k (the control that produced x_{k+1}) instead of pairing y_k with u_k, matching DiscreteControlLoopSimulator's existing closed-loop convention and avoiding the acausal y_0-depends-on-u_0 coupling. For "previous_transition", DiscreteTimeSimulator/dsx.simulate never samples y_0 and excludes x_0/t_0 from the returned SimulatedResult -- states, observations, times, and the caller's ctrl_values all end up the same length, with no padding or off-by-one bookkeeping required. Scope: the plain Simulator/DiscreteTimeSimulator/dsx.simulate generation path only. mppi.py and discrete_controller_simulators.py are unchanged, deferred to a follow-up.
Include x_0 in all results; add controls to SimulatedResult
For observation_control_alignment="previous_transition", the result now keeps
x_0 and the full times/states path (length T), matching "same_time". Only
observations stay one shorter (y_1..y_{T-1}, length T-1) since y_0 is never
sampled -- so states[k+1] pairs with observations[k].
Add a controls field to SimulatedResult carrying the aligned ctrl_values used
(length T for same_time, T-1 for previous_transition; None when uncontrolled).
Also drop the bespoke _sample_discrete_observation_path in favor of calling
_emit_observations directly with sliced states/times, and fix
_sample_observation_path to vmap over arrays rather than indexing by a scanned
integer, which crashed on zero-length observation paths.
Simplified docstring
Trim the observation_control_alignment docstrings in DynamicalModel, _validate_observation_control_alignment, SimulatedResult and _sample_observation_path, and rewrite the DiscreteTimeSimulator docstring with explicit transition and per-convention observation equations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f6dac32 to
2599ef4
Compare
Summary of changesOpen-loop control now supports both the
A unified principle is that I have simplified and reworked some of the tests to properly verify the correctness of the different conventions. In open-loop control
Here there are
We have In closed-loop control
The control Here the policy uses the predicted distribution. This is not implemented because Cuthbert's
Here the policy uses the filtering distribution. In this convention,
Sharp edgesThe new
I think one way of doing this could be to add an explicit keyword
@mattlevine22 can you let me know if you think this makes sense? (also I tried fixing all the rendering issues). |
|
Is |
|
Yes, to preserve the old behavior. |
mattlevine22
left a comment
There was a problem hiding this comment.
Overall, my recommendation is to accept this PR as is and move on.
- I don't think it breaks any existing stuff...and if we discover that it does, we will just fix it.
observe_y0boolean property ofDynamicalModelis a good idea, but better as a new PR. I think this PR is about as big as I can generally handle reviewing haha.
Notes:
- I suppose that IF you set
observe_y0=True, then there should be a warning that says: "y0 will be generated with a 0 control value (ignore this warning if your observation model does not depend on control inputs". - Doing
observe_y0will also require dealing with more branches of the code (e.g., asserting it is True in all the Filters/Smoothers/OpenLoopSimulators unless you want to start supporting it True...which is even more code). DynamicalModelhas at0property...is this being considered at all? It is a bit dicey, and needs to become a more powerful grounding property soon (perhaps not in this PR other than maintaining current internal consistency).
@DanWaxman could you please look at this briefly and decide whether you'd like us to (a) do some more careful reviewing or (b) go for it?
I read the code in detail in my last iteration, but now it is a bit tough for me to follow what has changed (@MatthieuDarcy this is an example where fewer targeted commits helps a lot in the review process)
|
Yes my apologies for this, I know this is too much but it turned out to be a bit of a rabbit hole. @mattlevine22 @DanWaxman IF you want a more careful review, I can break it up into smaller PRs, doing one for open loop and one for closed loop (and removing some of the superfluous elements), but this will take me a bit of time. |
|
My gut is that this is good enough but want to get Dan's take |
DanWaxman
left a comment
There was a problem hiding this comment.
This seems reasonable to me overall. I left comments, mostly about docs. But also:
- does it make sense to use an enum instead of a string?
- I think the belief stuff can be implemented for the EnKF already, it gives its predicted states (as it is necessary for the EnRTS algorithm)
| def _validate_policy_control(u: Any, control_dim: int) -> Real[Array, " control_dim"]: | ||
| """Normalize one control returned by a policy, rejecting bad shapes.""" | ||
| if isinstance(u, Distribution): | ||
| raise ValueError( | ||
| "Returning a distribution is not yet supported, instead " | ||
| "sample from this distribution inside your policy." | ||
| ) | ||
| u = jnp.asarray(u) | ||
| expected_control_shape = (control_dim,) | ||
| if u.shape != expected_control_shape: | ||
| raise ValueError( | ||
| "control_policy must return one control vector with shape " | ||
| f"{expected_control_shape}; got {u.shape}." | ||
| ) | ||
| return u | ||
|
|
There was a problem hiding this comment.
I feel okay with just having this still be in type checking instead of a check
There was a problem hiding this comment.
Personally I like it because it gives an explicit error message for when a user gives a bad policy
There was a problem hiding this comment.
Okay, I don't feel super strongly about it (but would remove the jnp.asarray call, that should happen way earlier... also lets u be typed better in this function)
There was a problem hiding this comment.
yes that part I completely agree with and is removed
There was a problem hiding this comment.
Could you put in an array-like type for u then?
There was a problem hiding this comment.
Yes, I had to add Distribution to allow for the Distribution check
| - under `same_time`, $\tilde x_k$ is the predicted state | ||
| - under `previous_transition`, $\tilde x_k$ is the filtered state |
There was a problem hiding this comment.
Can you write what the "predicted state" and "filtered state" mean here? Like
There was a problem hiding this comment.
Let me know if you find this sufficiently clear. I'm not as familiar as you are regarding the accepted terminology/notation
DanWaxman
left a comment
There was a problem hiding this comment.
I made one more reply, commenting about a missing type for control in the validation function. Otherwise, this looks good to me. Thank you Matthieu!!
mattlevine22
left a comment
There was a problem hiding this comment.
Looks great, thank you for the hard work on this one!




Add observation_control_alignment for discrete-time Simulator (#312).
Mathematical summary
Implements the
same_timevsprevious time distinction.same_time(default, preserves the existing behavior) implements:and subsequently
previous_timeimplementsSummary of changes
previous_transitioncontractAdds an explicit property of
DynamicalModeldefined at initialization calledobservation_control_alignment: Literal[ "same_time", "previous_transition" ] = "same_time"When defined as
previous_transition, this results in the following behaviorSimulatedResults. This is very practical when doing MPC. This leads to a similar behavior as observations: when using "same_time" it is of sizeDiscreteTimeSimulator.This means that$T$ vs $T-1$ ) when using
statesandtimesare of different sizes tocontrolsandobservations(specifically sizeprevious_transition.Updated
DiscreteControlLoopSimulatorDiscreteControlLoopSimulatornow deliberately always uses the previous-transition convention, independent ofdynamics.observation_control_alignment:Previously, it was assumed that the observation function could support$T$ states, times, and beliefs, and $T-1$ aligned observations and controls. This matches the
u=None. A closed-loop result therefore hasprevious_transitionconvention and avoids imposing an implicit requirement that the observation model acceptu=Noneor be control-independent, but is change in the behavior.Future work
previous_transitiononly works for simulation, no conditioning and not filtering. Worth delegating to a seperate PR?SimulatedResultsfor ODE/SDE/Continuous time. It might also be worthwile to expand support for continuous control beyond zero-order hold.LTI_discretedoes not acceptobservation_control_alignmentas a keyword, I did not touch it but worth looking at it once we are satisfied with the contract.