# Copyright 2024 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helper methods for constructing augmented trajectory functions.""" import dataclasses import functools from typing import Any, Callable, Sequence, Tuple from dinosaur import coordinate_systems from dinosaur import pytree_utils from dinosaur import typing import haiku as hk import jax import jax.numpy as jnp Array = typing.Array DynamicalSystem = Any # to prevent circular dependency on model_builder Pytree = typing.Pytree tree_map = jax.tree_util.tree_map tree_leaves = jax.tree_util.tree_leaves # Linter confused by wrapped functions # pylint: disable=g-bare-generic def with_preprocessing( fn: Callable[..., Pytree], preprocess_fn: Callable, ) -> Callable[..., Pytree]: """Generates a function that computes `fn` on `preprocess_fn(x)`.""" @functools.wraps(fn) def apply_fn(x, *args, **kwargs): return fn(preprocess_fn(x), *args, **kwargs) return apply_fn def with_post_processing( fn: Callable[..., Pytree], post_process_fn: Callable, ) -> Callable[..., Pytree]: """Generates a function that applies `post_process_fn` to outputs of `fn`.""" @functools.wraps(fn) def apply_fn(*args, **kwargs): return post_process_fn(*fn(*args, **kwargs)) return apply_fn def with_forcing( fn: Callable[..., Pytree], forcing_fn: typing.ForcingFn, forcing_data: typing.ForcingData, ) -> Callable[..., Pytree]: """Converts fn(x, forcing) to fn(x).""" # evaluates forcing=forcing_fn(forcing_data, x.sim_time) # when x does not have sim_time, forcing_fn will get sim_time=None @functools.wraps(fn) def wrapped(x, forcing_fn=forcing_fn): # handle dataclass or dict for state data if dataclasses.is_dataclass(x): if isinstance(x, typing.ModelState): sim_time = dataclasses.asdict(x.state).get('sim_time', None) else: sim_time = dataclasses.asdict(x).get('sim_time', None) else: sim_time = x.get('sim_time', None) # handle sim_time of ndim 0 or 1 if sim_time is not None: sim_time = jax.numpy.asarray(sim_time) if sim_time.ndim: forcing_fn = jax.vmap(forcing_fn, in_axes=(None, 0)) forcing = forcing_fn(forcing_data, sim_time) # pyrefly: ignore[bad-argument-type] return fn(x, forcing=forcing) return wrapped def with_split_input( fn: Callable[..., Pytree], split_index: int, time_axis: int = 0, ) -> Callable[..., Pytree]: """Decorates `fn` to be evaluated on first `split_index` time slices. The returned function is a generalization to pytrees of the function: `fn(x[:split_index], *args, **kwargs)` Args: fn: function to be transformed. split_index: number of input elements along the time axis to use. time_axis: axis corresponding to time dimension in `x` to decorated `fn`. Returns: decorated `fn` that is evaluated on only `split_index` first time slices of provided inputs. """ @functools.wraps(fn) def apply_fn(x, *args, **kwargs): init, _ = pytree_utils.split_along_axis(x, split_index, axis=time_axis) return fn(init, *args, **kwargs) return apply_fn def with_input_included( trajectory_fn: typing.TrajectoryFn, time_axis: int = 0, num_last_input_frames_to_trim: int = 0, ) -> typing.TrajectoryFn: """Returns a `trajectory_fn` that concatenates inputs `x` to trajectory.""" if num_last_input_frames_to_trim > 0: num_last_input_frames_to_trim = -num_last_input_frames_to_trim else: num_last_input_frames_to_trim = None # pyrefly: ignore[bad-assignment] inputs_time_slice = slice(None, num_last_input_frames_to_trim) @functools.wraps(trajectory_fn) def _trajectory(x, *args, **kwargs): final, unroll = trajectory_fn(x, *args, **kwargs) x_concat = pytree_utils.slice_along_axis(x, time_axis, inputs_time_slice) return final, pytree_utils.concat_along_axis([x_concat, unroll], time_axis) return _trajectory def trajectory_with_inputs_and_forcing( model: DynamicalSystem, num_init_frames: int, start_with_input: bool = False, ) -> typing.TrajectoryFn: """Returns trajectory_fn that comuptes model trajectory from target data. Wraps the default model.trajectory_fn to operate on data representation. It corresponds to slicing `num_init_frames` from the inputs, encoding and unrolling the trajectory. Args: model: model of a dynamical system used to obtain the trajectory. num_init_frames: number of time frames used from the physics trajectory to initialize the model state. start_with_input: whether the firest decoded step in the output trajectory should correspond to last input time or first future output. Returns: Trajectory function that operates on target data trajectory by encoding the `initial_frames` inputs and unrolls trajectory in a model space. """ def _trajectory_fn(x, forcing_data, outer_steps, inner_steps=1): # configure the model.trajectory function with a decoder on the output. trajectory_fn = functools.partial( model.trajectory, outer_steps=outer_steps, inner_steps=inner_steps, forcing_data=forcing_data, start_with_input=start_with_input) # add preprocessing to encode input to model state. encode_fn = with_forcing(model.encode, model.forcing_fn, forcing_data) trajectory_fn = with_preprocessing(trajectory_fn, encode_fn) trajectory_fn = with_split_input(trajectory_fn, num_init_frames) return trajectory_fn(x) return _trajectory_fn def trajectory_with_inputs_and_forcing_and_stop_gradients( model: DynamicalSystem, num_init_frames: int, start_with_input: bool = False, stop_gradient_outer_steps: Sequence[int] = (), ) -> typing.TrajectoryFn: """Returns trajectory_fn that comuptes model trajectory from target data. This extension of `trajectory_with_inputs_and_forcing` allows adding stop gradients to the trajectory at designated steps. For example, if `stop_gradient_outer_steps = [2]`, then gradients along the trajectory stop at t=2. This does not mean that gradients with respect to X[2] will be zero. It simply means that, for t > 2, gradients of X[t] with respect to X[2] will be zero. Wraps the default model.trajectory_fn to operate on data representation. It corresponds to slicing `num_init_frames` from the inputs, encoding and unrolling the trajectory. Args: model: model of a dynamical system used to obtain the trajectory. num_init_frames: number of time frames used from the physics trajectory to initialize the model state. start_with_input: whether the firest decoded step in the output trajectory should correspond to last input time or first future output. stop_gradient_outer_steps: Tuple (possibly empty) indicating outer steps at which to place stop gradients. Returns: Trajectory function that operates on target data trajectory by encoding the `initial_frames` inputs and unrolls trajectory in a model space. Decoding is not done by this function. """ stop_gradient_outer_steps = list(sorted(stop_gradient_outer_steps)) if num_init_frames != 1: raise ValueError(f'{num_init_frames=} is not supported yet.') if stop_gradient_outer_steps and min(stop_gradient_outer_steps) <= 0: raise ValueError( f'{stop_gradient_outer_steps=} contained non-positive values' ) expand_dim0 = lambda tree: tree_map(lambda x_i: x_i[jnp.newaxis], tree) concat_dim0 = lambda trees: pytree_utils.concat_along_axis(trees, axis=0) slice_dim0 = lambda tree, idx: pytree_utils.slice_along_axis( tree, axis=0, idx=idx ) def concat_trajectories_with_stop_grads( x, forcing_data, outer_steps, inner_steps=1 ): if ( stop_gradient_outer_steps and max(stop_gradient_outer_steps) > outer_steps ): raise ValueError( f'{stop_gradient_outer_steps=} contained values > {outer_steps=}' ) outer_steps_seq = list(stop_gradient_outer_steps) if not outer_steps_seq or outer_steps_seq[-1] != outer_steps: outer_steps_seq.append(outer_steps) # The first leg needs to encode the input. So use # trajectory_with_inputs_and_forcing, which does the encoding. final_state, first_leg = trajectory_with_inputs_and_forcing( model, num_init_frames=num_init_frames, start_with_input=start_with_input, )( x, forcing_data=forcing_data, outer_steps=outer_steps_seq[0], inner_steps=inner_steps, ) # At this point, sections contains times [0, ..., outer_steps_seq[0]] sections = [ first_leg, ] # Subsequent legs do not need encoding, so use model.trajectory directly. trajectory_fn = functools.partial( model.trajectory, inner_steps=inner_steps, forcing_data=forcing_data, start_with_input=start_with_input, ) for i in range(1, len(outer_steps_seq)): # outer_steps_seq[-1] may or may not be in stop_gradient_outer_steps. # The other steps will be by construction. assert set(outer_steps_seq[:-1]).issubset(stop_gradient_outer_steps) stop_grad_at_start = outer_steps_seq[i - 1] in stop_gradient_outer_steps initial_state = final_state # this_leg contains times [outer_steps_seq[0]+1, ..., outer_steps_seq[1]] final_state, this_leg = trajectory_fn( jax.lax.stop_gradient(initial_state) if stop_grad_at_start else initial_state, outer_steps=outer_steps_seq[i] - outer_steps_seq[i - 1], ) if stop_grad_at_start and start_with_input: # Replace the initial point that had a stop gradient on it. this_leg = concat_dim0([ expand_dim0(initial_state), slice_dim0(this_leg, idx=slice(1, None)), ]) sections.append(this_leg) return final_state, concat_dim0(sections) return concat_trajectories_with_stop_grads def decoded_trajectory_with_forcing( model: DynamicalSystem, start_with_input: bool = False, ) -> typing.TrajectoryFn: """Returns trajectory_fn that comuptes decoded trajectory values. Args: model: model of a dynamical system used to obtain the trajectory. start_with_input: whether the firest decoded step in the output trajectory should correspond to last input time or first future output. Returns: Trajectory function that additionally decodes trajectory values. """ def _trajectory_fn(x, forcing_data, outer_steps, inner_steps=1): # configure the model.trajectory function with a decoder on the output. trajectory_fn = functools.partial( model.trajectory, forcing_data=forcing_data, post_process_fn=with_forcing(model.decode, model.forcing_fn, forcing_data), start_with_input=start_with_input) return trajectory_fn(x, outer_steps, inner_steps) return _trajectory_fn def decoded_trajectory_with_inputs_and_forcing( model: DynamicalSystem, num_init_frames: int, start_with_input: bool = False, ) -> typing.TrajectoryFn: """Returns trajectory_fn operating on decoded input and forcing data. The returned function uses `num_init_frames` of the physics space trajectory provided as an input to model.encode_fn to initialize the model state, then unrolls the trajectory of specified length that is decoded to the physics space using `model.decode_fn`. Args: model: model of a dynamical system used to obtain the trajectory. num_init_frames: number of time frames used from the physics trajectory to initialize the model state. start_with_input: whether the firest decoded step in the output trajectory should correspond to last input time or first future output. Returns: Trajectory function that operates on physics space trajectories and returns unrolls in physics space. """ def _trajectory_fn(x, forcing_data, outer_steps, inner_steps=1): # configure the model.trajectory function with a decoder on the output. trajectory_fn = decoded_trajectory_with_forcing(model, start_with_input) trajectory_fn = functools.partial( trajectory_fn, forcing_data=forcing_data, outer_steps=outer_steps, inner_steps=inner_steps) # add preprocessing to encode input to model state. trajectory_fn = with_preprocessing( trajectory_fn, with_forcing(model.encode, model.forcing_fn, forcing_data)) # concatenate input trajectory to output trajectory for easier comparison. trajectory_fn = with_input_included( trajectory_fn, num_last_input_frames_to_trim=int(start_with_input)) # make trajectories operate on full examples by splitting the init. trajectory_fn = with_split_input(trajectory_fn, num_init_frames) return trajectory_fn(x) return _trajectory_fn def process_trajectory( input_trajectory: Pytree, process_fn: Callable[[Pytree], Pytree], ) -> Pytree: """Processes trajectory by applying `process_fn` along time axis.""" step_fn = lambda c, x: tuple([None, hk.remat(process_fn)(x)]) _, out = hk.scan(step_fn, None, xs=input_trajectory) return out def _maybe_to_nodal_with_physics_sharding(x, /, coords): x = coordinate_systems.maybe_to_nodal(x, coords) x = coords.with_physics_sharding(x) return x def _maybe_to_modal_with_physics_sharding(x, /, coords): x = coordinate_systems.maybe_to_modal(x, coords) x = coords.with_physics_sharding(x) return x def compute_prediction_representations( predicted_trajectory: typing.Pytree, forcing_data: typing.ForcingData, model: DynamicalSystem, ) -> typing.TrajectoryRepresentations: """Computes TrajectoryRepresentations for predicted trajectory. Args: predicted_trajectory: predictions on `model.coords` coordinates. forcing_data: forcing data to be used for encode/decode transformations. model: model used for conversion between representations. Returns: `TrajectoryRepresentations` for predictions. """ decode_fn = with_forcing(model.decode, model.forcing_fn, forcing_data) data_to_nodal = functools.partial( _maybe_to_nodal_with_physics_sharding, coords=model.output_coords) data_to_modal = functools.partial( _maybe_to_modal_with_physics_sharding, coords=model.output_coords) model_to_nodal = functools.partial( _maybe_to_nodal_with_physics_sharding, coords=model.coords) model_to_modal = functools.partial( _maybe_to_modal_with_physics_sharding, coords=model.coords) predicted_data_trajectory = process_trajectory( predicted_trajectory, decode_fn) # Note: we pass original prediction to the decoder, but use dict for outputs. if isinstance(predicted_trajectory, typing.ModelState): predicted_trajectory = predicted_trajectory.state if dataclasses.is_dataclass(predicted_trajectory): # Losses operate on dicts: convert struct to dict if needed. predicted_trajectory = predicted_trajectory.asdict() return typing.TrajectoryRepresentations( data_nodal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] predicted_data_trajectory, data_to_nodal), data_modal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] predicted_data_trajectory, data_to_modal), model_nodal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] predicted_trajectory, model_to_nodal), model_modal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] predicted_trajectory, model_to_modal), ) def compute_target_representations( target_trajectory: typing.Pytree, forcing_data: typing.ForcingData, model: DynamicalSystem, ) -> typing.TrajectoryRepresentations: """Computes TrajectoryRepresentations for target trajectory. Note: currently this method only supports models that use a single time slice for initialization. Args: target_trajectory: target trajectory on `model.output_coords` coordinates. forcing_data: forcing data to be used for encode/decode transformations. model: model used for conversion between representations. Returns: `TrajectoryRepresentations` for predictions. """ encode_slice_fn = with_forcing(model.encode, model.forcing_fn, forcing_data) encode_fn = lambda tree: encode_slice_fn( # pylint: disable=g-long-lambda. jax.tree_util.tree_map(lambda x: jnp.expand_dims(x, 0), tree)) data_to_nodal = functools.partial( _maybe_to_nodal_with_physics_sharding, coords=model.output_coords) data_to_modal = functools.partial( _maybe_to_modal_with_physics_sharding, coords=model.output_coords) model_to_nodal = functools.partial( _maybe_to_nodal_with_physics_sharding, coords=model.coords) model_to_modal = functools.partial( _maybe_to_modal_with_physics_sharding, coords=model.coords) target_model_trajectory = process_trajectory( target_trajectory, encode_fn) if isinstance(target_model_trajectory, typing.ModelState): target_model_trajectory = target_model_trajectory.state if dataclasses.is_dataclass(target_model_trajectory): # Losses operate on dicts: convert struct to dict if needed. target_model_trajectory = target_model_trajectory.asdict() return typing.TrajectoryRepresentations( data_nodal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] target_trajectory, data_to_nodal), data_modal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] target_trajectory, data_to_modal), model_nodal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] target_model_trajectory, model_to_nodal), model_modal_trajectory=process_trajectory( # pyrefly: ignore[unexpected-keyword] target_model_trajectory, model_to_modal), ) def compute_prediction_and_target_representations( predicted_model_trajectory: typing.Pytree, target_data_trajectory: typing.Pytree, forcing_data: typing.ForcingData, model: DynamicalSystem, ) -> Tuple[typing.TrajectoryRepresentations, typing.TrajectoryRepresentations]: """Computes TrajectoryRepresentations for predicted and target trajectories. Note: currently this method only supports models that use a single time slice for initialization. While computing all terms seems wasteful, once jit-ed all unused computations are optimized away. It is also tempting to compute all representations at once, but as of 2023-02-28 compiler doesn't manage to remove unused computation from a single primitive. Args: predicted_model_trajectory: predictions on `model.coords` coordinates. target_data_trajectory: target data on `model.output_coords` coordinates. forcing_data: forcing data to be used for encode/decode transformations. model: model used for conversion between representations. Returns: Tuple of `TrajectoryRepresentations` for predictions and targets. """ prediction_representations = compute_prediction_representations( predicted_model_trajectory, forcing_data, model) target_representations = compute_target_representations( target_data_trajectory, forcing_data, model) return prediction_representations, target_representations @jax.custom_jvp def safe_sqrt(x: Array) -> jax.Array: """Sqrt(x) with gradient = 0 for x near 0.""" return jnp.sqrt(x) @safe_sqrt.defjvp def safe_sqrt_jvp( primals: Array, tangents: Array, ) -> tuple[jax.Array, jax.Array]: (x,) = primals (x_dot,) = tangents primal_out = safe_sqrt(x) eps = jnp.finfo(x.dtype).eps safe_x = jnp.where(x > eps, x, 1.0) tangent_out = jnp.where(x > eps, x_dot / (2 * safe_sqrt(safe_x)), 0) return primal_out, tangent_out