# Copyright 2021 DeepMind Technologies Limited. All Rights Reserved.
#
# 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
#
#     http://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.
# ==============================================================================

"""JAX implementation of CLRS baseline models."""

import functools
import os
import pickle
from typing import Dict, List, Optional, Tuple, Union

import chex

from clrs._src import decoders
from clrs._src import losses
from clrs._src import model
from clrs._src import nets
from clrs._src import probing
from clrs._src import processors
from clrs._src import samplers
from clrs._src import specs

import haiku as hk
import jax
import jax.numpy as jnp
import numpy as np
import optax


_Array = chex.Array
_DataPoint = probing.DataPoint
_Features = samplers.Features
_FeaturesChunked = samplers.FeaturesChunked
_Feedback = samplers.Feedback
_Location = specs.Location
_Seed = jnp.integer
_Spec = specs.Spec
_Stage = specs.Stage
_Trajectory = samplers.Trajectory
_Type = specs.Type
_OutputClass = specs.OutputClass

# pytype: disable=signature-mismatch


def _maybe_pick_first_pmapped(tree):
  if jax.local_device_count() == 1:
    return tree
  return jax.tree_util.tree_map(lambda x: x[0], tree)


@jax.jit
def _restack_from_pmap(tree):
  """Stack the results of a pmapped computation across the first two axes."""
  restack_array = lambda x: jnp.reshape(x, (-1,) + x.shape[2:])
  return jax.tree_util.tree_map(restack_array, tree)


def _maybe_restack_from_pmap(tree):
  if jax.local_device_count() == 1:
    return tree
  return _restack_from_pmap(tree)


@functools.partial(jax.jit, static_argnums=[1, 2])
def _pmap_reshape(x, n_devices, split_axis=0):
  """Splits a pytree over n_devices on axis split_axis for pmapping."""
  def _reshape(arr):
    new_shape = (arr.shape[:split_axis] +
                 (n_devices, arr.shape[split_axis] // n_devices) +
                 arr.shape[split_axis + 1:])
    return jnp.moveaxis(jnp.reshape(arr, new_shape), split_axis, 0)
  return jax.tree_util.tree_map(_reshape, x)


def _maybe_pmap_reshape(x, split_axis=0):
  n_devices = jax.local_device_count()
  if n_devices == 1:
    return x
  return _pmap_reshape(x, n_devices, split_axis)


@functools.partial(jax.jit, static_argnums=1)
def _pmap_data(data: Union[_Feedback, _Features], n_devices: int):
  """Replicate/split feedback or features for pmapping."""
  if isinstance(data, _Feedback):
    features = data.features
  else:
    features = data
  pmap_data = features._replace(
      inputs=_pmap_reshape(features.inputs, n_devices),
      hints=_pmap_reshape(features.hints, n_devices, split_axis=1),
      lengths=_pmap_reshape(features.lengths, n_devices),
  )
  if isinstance(data, _Feedback):
    pmap_data = data._replace(
        features=pmap_data,
        outputs=_pmap_reshape(data.outputs, n_devices)
    )
  return pmap_data


def _maybe_pmap_data(data: Union[_Feedback, _Features]):
  n_devices = jax.local_device_count()
  if n_devices == 1:
    return data
  return _pmap_data(data, n_devices)


def _maybe_put_replicated(tree):
  if jax.local_device_count() == 1:
    return jax.device_put(tree)
  else:
    return jax.device_put_replicated(tree, jax.local_devices())


def _maybe_pmap_rng_key(rng_key: _Array):
  n_devices = jax.local_device_count()
  if n_devices == 1:
    return rng_key
  pmap_rng_keys = jax.random.split(rng_key, n_devices)
  return jax.device_put_sharded(list(pmap_rng_keys), jax.local_devices())


class BaselineModel(model.Model):
  """Model implementation with selectable message passing algorithm."""

  def __init__(
      self,
      spec: Union[_Spec, List[_Spec]],
      dummy_trajectory: Union[List[_Feedback], _Feedback],
      processor_factory: processors.ProcessorFactory,
      hidden_dim: int = 32,
      encode_hints: bool = False,
      decode_hints: bool = True,
      encoder_init: str = 'default',
      use_lstm: bool = False,
      learning_rate: float = 0.005,
      grad_clip_max_norm: float = 0.0,
      checkpoint_path: str = '/tmp/clrs3',
      freeze_processor: bool = False,
      dropout_prob: float = 0.0,
      hint_teacher_forcing: float = 0.0,
      hint_repred_mode: str = 'soft',
      name: str = 'base_model',
      nb_msg_passing_steps: int = 1,
  ):
    """Constructor for BaselineModel.

    The model consists of encoders, processor and decoders. It can train
    and evaluate either a single algorithm or a set of algorithms; in the
    latter case, a single processor is shared among all the algorithms, while
    the encoders and decoders are separate for each algorithm.

    Args:
      spec: Either a single spec for one algorithm, or a list of specs for
        multiple algorithms to be trained and evaluated.
      dummy_trajectory: Either a single feedback batch, in the single-algorithm
        case, or a list of feedback batches, in the multi-algorithm case, that
        comply with the `spec` (or list of specs), to initialize network size.
      processor_factory: A callable that takes an `out_size` parameter
        and returns a processor (see `processors.py`).
      hidden_dim: Size of the hidden state of the model, i.e., size of the
        message-passing vectors.
      encode_hints: Whether to provide hints as model inputs.
      decode_hints: Whether to provide hints as model outputs.
      encoder_init: The initialiser type to use for the encoders.
      use_lstm: Whether to insert an LSTM after message passing.
      learning_rate: Learning rate for training.
      grad_clip_max_norm: if greater than 0, the maximum norm of the gradients.
      checkpoint_path: Path for loading/saving checkpoints.
      freeze_processor: If True, the processor weights will be frozen and
        only encoders and decoders (and, if used, the lstm) will be trained.
      dropout_prob: Dropout rate in the message-passing stage.
      hint_teacher_forcing: Probability of using ground-truth hints instead
        of predicted hints as inputs during training (only relevant if
        `encode_hints`=True)
      hint_repred_mode: How to process predicted hints when fed back as inputs.
        Only meaningful when `encode_hints` and `decode_hints` are True.
        Options are:
          - 'soft', where we use softmaxes for categoricals, pointers
              and mask_one, and sigmoids for masks. This will allow gradients
              to flow through hints during training.
          - 'hard', where we use argmax instead of softmax, and hard
              thresholding of masks. No gradients will go through the hints
              during training; even for scalar hints, which don't have any
              kind of post-processing, gradients will be stopped.
          - 'hard_on_eval', which is soft for training and hard for evaluation.
      name: Model name.
      nb_msg_passing_steps: Number of message passing steps per hint.

    Raises:
      ValueError: if `encode_hints=True` and `decode_hints=False`.
    """
    super(BaselineModel, self).__init__(spec=spec)

    if encode_hints and not decode_hints:
      raise ValueError('`encode_hints=True`, `decode_hints=False` is invalid.')

    assert hint_repred_mode in ['soft', 'hard', 'hard_on_eval']

    self.decode_hints = decode_hints
    self.checkpoint_path = checkpoint_path
    self.name = name
    self._freeze_processor = freeze_processor
    if grad_clip_max_norm != 0.0:
      optax_chain = [optax.clip_by_global_norm(grad_clip_max_norm),
                     optax.scale_by_adam(),
                     optax.scale(-learning_rate)]
      self.opt = optax.chain(*optax_chain)
    else:
      self.opt = optax.adam(learning_rate)

    self.nb_msg_passing_steps = nb_msg_passing_steps

    self.nb_dims = []
    if isinstance(dummy_trajectory, _Feedback):
      assert len(self._spec) == 1
      dummy_trajectory = [dummy_trajectory]
    for traj in dummy_trajectory:
      nb_dims = {}
      for inp in traj.features.inputs:
        nb_dims[inp.name] = inp.data.shape[-1]
      for hint in traj.features.hints:
        nb_dims[hint.name] = hint.data.shape[-1]
      for outp in traj.outputs:
        nb_dims[outp.name] = outp.data.shape[-1]
      self.nb_dims.append(nb_dims)

    self._create_net_fns(hidden_dim, encode_hints, processor_factory, use_lstm,
                         encoder_init, dropout_prob, hint_teacher_forcing,
                         hint_repred_mode)
    self._device_params = None
    self._device_opt_state = None
    self.opt_state_skeleton = None

  def _create_net_fns(self, hidden_dim, encode_hints, processor_factory,
                      use_lstm, encoder_init, dropout_prob,
                      hint_teacher_forcing, hint_repred_mode):
    def _use_net(*args, **kwargs):
      return nets.Net(self._spec, hidden_dim, encode_hints, self.decode_hints,
                      processor_factory, use_lstm, encoder_init,
                      dropout_prob, hint_teacher_forcing,
                      hint_repred_mode,
                      self.nb_dims, self.nb_msg_passing_steps)(*args, **kwargs)

    self.net_fn = hk.transform(_use_net)
    pmap_args = dict(axis_name='batch', devices=jax.local_devices())
    n_devices = jax.local_device_count()
    func, static_arg, extra_args = (
        (jax.jit, 'static_argnums', {}) if n_devices == 1 else
        (jax.pmap, 'static_broadcasted_argnums', pmap_args))
    pmean = functools.partial(jax.lax.pmean, axis_name='batch')
    self._maybe_pmean = pmean if n_devices > 1 else lambda x: x
    extra_args[static_arg] = 3
    self.jitted_grad = func(self._compute_grad, **extra_args)
    extra_args[static_arg] = 4
    self.jitted_feedback = func(self._feedback, donate_argnums=[0, 3],
                                **extra_args)
    extra_args[static_arg] = [3, 4, 5]
    self.jitted_predict = func(self._predict, **extra_args)
    extra_args[static_arg] = [3, 4]
    self.jitted_accum_opt_update = func(accum_opt_update, donate_argnums=[0, 2],
                                        **extra_args)

  def init(self, features: Union[_Features, List[_Features]], seed: _Seed):
    if not isinstance(features, list):
      assert len(self._spec) == 1
      features = [features]
    self.params = self.net_fn.init(jax.random.PRNGKey(seed), features, True,  # pytype: disable=wrong-arg-types  # jax-ndarray
                                   algorithm_index=-1,
                                   return_hints=False,
                                   return_all_outputs=False)
    self.opt_state = self.opt.init(self.params)
    # We will use the optimizer state skeleton for traversal when we
    # want to avoid updating the state of params of untrained algorithms.
    self.opt_state_skeleton = self.opt.init(jnp.zeros(1))

  @property
  def params(self):
    if self._device_params is None:
      return None
    return jax.device_get(_maybe_pick_first_pmapped(self._device_params))

  @params.setter
  def params(self, params):
    self._device_params = _maybe_put_replicated(params)

  @property
  def opt_state(self):
    if self._device_opt_state is None:
      return None
    return jax.device_get(_maybe_pick_first_pmapped(self._device_opt_state))

  @opt_state.setter
  def opt_state(self, opt_state):
    self._device_opt_state = _maybe_put_replicated(opt_state)

  def _compute_grad(self, params, rng_key, feedback, algorithm_index):
    lss, grads = jax.value_and_grad(self._loss)(
        params, rng_key, feedback, algorithm_index)
    return self._maybe_pmean(lss), self._maybe_pmean(grads)

  def _feedback(self, params, rng_key, feedback, opt_state, algorithm_index):
    lss, grads = jax.value_and_grad(self._loss)(
        params, rng_key, feedback, algorithm_index)
    grads = self._maybe_pmean(grads)
    params, opt_state = self._update_params(params, grads, opt_state,
                                            algorithm_index)
    lss = self._maybe_pmean(lss)
    return lss, params, opt_state

  def _predict(self, params, rng_key: hk.PRNGSequence, features: _Features,
               algorithm_index: int, return_hints: bool,
               return_all_outputs: bool):
    outs, hint_preds = self.net_fn.apply(
        params, rng_key, [features],
        repred=True, algorithm_index=algorithm_index,
        return_hints=return_hints,
        return_all_outputs=return_all_outputs)
    outs = decoders.postprocess(self._spec[algorithm_index],
                                outs,
                                sinkhorn_temperature=0.1,
                                sinkhorn_steps=50,
                                hard=True,
                                )
    return outs, hint_preds

  def compute_grad(
      self,
      rng_key: hk.PRNGSequence,
      feedback: _Feedback,
      algorithm_index: Optional[int] = None,
  ) -> Tuple[float, _Array]:
    """Compute gradients."""

    if algorithm_index is None:
      assert len(self._spec) == 1
      algorithm_index = 0
    assert algorithm_index >= 0

    # Calculate gradients.
    rng_keys = _maybe_pmap_rng_key(rng_key)  # pytype: disable=wrong-arg-types  # numpy-scalars
    feedback = _maybe_pmap_data(feedback)
    loss, grads = self.jitted_grad(
        self._device_params, rng_keys, feedback, algorithm_index)
    loss = _maybe_pick_first_pmapped(loss)
    grads = _maybe_pick_first_pmapped(grads)

    return  loss, grads

  def feedback(self, rng_key: hk.PRNGSequence, feedback: _Feedback,
               algorithm_index=None) -> float:
    if algorithm_index is None:
      assert len(self._spec) == 1
      algorithm_index = 0
    # Calculate and apply gradients.
    rng_keys = _maybe_pmap_rng_key(rng_key)  # pytype: disable=wrong-arg-types  # numpy-scalars
    feedback = _maybe_pmap_data(feedback)
    loss, self._device_params, self._device_opt_state = self.jitted_feedback(
        self._device_params, rng_keys, feedback,
        self._device_opt_state, algorithm_index)
    loss = _maybe_pick_first_pmapped(loss)
    return loss

  def predict(self, rng_key: hk.PRNGSequence, features: _Features,
              algorithm_index: Optional[int] = None,
              return_hints: bool = False,
              return_all_outputs: bool = False):
    """Model inference step."""
    if algorithm_index is None:
      assert len(self._spec) == 1
      algorithm_index = 0

    rng_keys = _maybe_pmap_rng_key(rng_key)  # pytype: disable=wrong-arg-types  # numpy-scalars
    features = _maybe_pmap_data(features)
    return _maybe_restack_from_pmap(
        self.jitted_predict(
            self._device_params, rng_keys, features,
            algorithm_index,
            return_hints,
            return_all_outputs))

  def _loss(self, params, rng_key, feedback, algorithm_index):
    """Calculates model loss f(feedback; params)."""
    output_preds, hint_preds = self.net_fn.apply(
        params, rng_key, [feedback.features],
        repred=False,
        algorithm_index=algorithm_index,
        return_hints=True,
        return_all_outputs=False)

    nb_nodes = _nb_nodes(feedback, is_chunked=False)
    lengths = feedback.features.lengths
    total_loss = 0.0

    # Calculate output loss.
    for truth in feedback.outputs:
      total_loss += losses.output_loss(
          truth=truth,
          pred=output_preds[truth.name],
          nb_nodes=nb_nodes,
      )

    # Optionally accumulate hint losses.
    if self.decode_hints:
      for truth in feedback.features.hints:
        total_loss += losses.hint_loss(
            truth=truth,
            preds=[x[truth.name] for x in hint_preds],
            lengths=lengths,
            nb_nodes=nb_nodes,
        )

    return total_loss

  def _update_params(self, params, grads, opt_state, algorithm_index):
    updates, opt_state = filter_null_grads(
        grads, self.opt, opt_state, self.opt_state_skeleton, algorithm_index)
    if self._freeze_processor:
      params_subset = _filter_out_processor(params)
      updates_subset = _filter_out_processor(updates)
      assert len(params) > len(params_subset)
      assert params_subset
      new_params = optax.apply_updates(params_subset, updates_subset)
      new_params = hk.data_structures.merge(params, new_params)
    else:
      new_params = optax.apply_updates(params, updates)

    return new_params, opt_state

  def update_model_params_accum(self, grads) -> None:
    grads = _maybe_put_replicated(grads)
    self._device_params, self._device_opt_state = self.jitted_accum_opt_update(
        self._device_params, grads, self._device_opt_state, self.opt,
        self._freeze_processor)

  def verbose_loss(self, feedback: _Feedback, extra_info) -> Dict[str, _Array]:
    """Gets verbose loss information."""
    hint_preds = extra_info

    nb_nodes = _nb_nodes(feedback, is_chunked=False)
    lengths = feedback.features.lengths
    losses_ = {}

    # Optionally accumulate hint losses.
    if self.decode_hints:
      for truth in feedback.features.hints:
        losses_.update(
            losses.hint_loss(
                truth=truth,
                preds=[x[truth.name] for x in hint_preds],
                lengths=lengths,
                nb_nodes=nb_nodes,
                verbose=True,
            ))

    return losses_

  def restore_model(self, file_name: str, only_load_processor: bool = False):
    """Restore model from `file_name`."""
    path = os.path.join(self.checkpoint_path, file_name)
    with open(path, 'rb') as f:
      restored_state = pickle.load(f)
      if only_load_processor:
        restored_params = _filter_in_processor(restored_state['params'])
      else:
        restored_params = restored_state['params']
      self.params = hk.data_structures.merge(self.params, restored_params)
      self.opt_state = restored_state['opt_state']

  def save_model(self, file_name: str):
    """Save model (processor weights only) to `file_name`."""
    os.makedirs(self.checkpoint_path, exist_ok=True)
    to_save = {'params': self.params, 'opt_state': self.opt_state}
    path = os.path.join(self.checkpoint_path, file_name)
    with open(path, 'wb') as f:
      pickle.dump(to_save, f)


class BaselineModelChunked(BaselineModel):
  """Model that processes time-chunked data.

    Unlike `BaselineModel`, which processes full samples, `BaselineModelChunked`
    processes fixed-timelength chunks of data. Each tensor of inputs and hints
    has dimensions chunk_length x batch_size x ... The beginning of a new
    sample withing the chunk is signalled by a tensor called `is_first` of
    dimensions chunk_length x batch_size.

    The chunked model is intended for training. For validation and test, use
    `BaselineModel`.
  """

  mp_states: List[List[nets.MessagePassingStateChunked]]
  init_mp_states: List[List[nets.MessagePassingStateChunked]]

  def _create_net_fns(self, hidden_dim, encode_hints, processor_factory,
                      use_lstm, encoder_init, dropout_prob,
                      hint_teacher_forcing, hint_repred_mode):
    def _use_net(*args, **kwargs):
      return nets.NetChunked(
          self._spec, hidden_dim, encode_hints, self.decode_hints,
          processor_factory, use_lstm, encoder_init, dropout_prob,
          hint_teacher_forcing, hint_repred_mode,
          self.nb_dims, self.nb_msg_passing_steps)(*args, **kwargs)

    self.net_fn = hk.transform(_use_net)
    pmap_args = dict(axis_name='batch', devices=jax.local_devices())
    n_devices = jax.local_device_count()
    func, static_arg, extra_args = (
        (jax.jit, 'static_argnums', {}) if n_devices == 1 else
        (jax.pmap, 'static_broadcasted_argnums', pmap_args))
    pmean = functools.partial(jax.lax.pmean, axis_name='batch')
    self._maybe_pmean = pmean if n_devices > 1 else lambda x: x
    extra_args[static_arg] = 4
    self.jitted_grad = func(self._compute_grad, **extra_args)
    extra_args[static_arg] = 5
    self.jitted_feedback = func(self._feedback, donate_argnums=[0, 4],
                                **extra_args)
    extra_args[static_arg] = [3, 4]
    self.jitted_accum_opt_update = func(accum_opt_update, donate_argnums=[0, 2],
                                        **extra_args)

  def _init_mp_state(self, features_list: List[List[_FeaturesChunked]],
                     rng_key: _Array):
    def _empty_mp_state():
      return nets.MessagePassingStateChunked(  # pytype: disable=wrong-arg-types  # numpy-scalars
          inputs=None, hints=None, is_first=None,
          hint_preds=None, hiddens=None, lstm_state=None)
    empty_mp_states = [[_empty_mp_state() for _ in f] for f in features_list]
    dummy_params = [self.net_fn.init(rng_key, f, e, False,
                                     init_mp_state=True, algorithm_index=-1)
                    for (f, e) in zip(features_list, empty_mp_states)]
    mp_states = [
        self.net_fn.apply(d, rng_key, f, e, False,
                          init_mp_state=True, algorithm_index=-1)[1]
        for (d, f, e) in zip(dummy_params, features_list, empty_mp_states)]
    return mp_states

  def init(self,
           features: List[List[_FeaturesChunked]],
           seed: _Seed):
    self.mp_states = self._init_mp_state(features,
                                         jax.random.PRNGKey(seed))  # pytype: disable=wrong-arg-types  # jax-ndarray
    self.init_mp_states = [list(x) for x in self.mp_states]
    self.params = self.net_fn.init(
        jax.random.PRNGKey(seed), features[0], self.mp_states[0],  # pytype: disable=wrong-arg-types  # jax-ndarray
        True, init_mp_state=False, algorithm_index=-1)
    self.opt_state = self.opt.init(self.params)
    # We will use the optimizer state skeleton for traversal when we
    # want to avoid updating the state of params of untrained algorithms.
    self.opt_state_skeleton = self.opt.init(jnp.zeros(1))

  def predict(self, rng_key: hk.PRNGSequence, features: _FeaturesChunked,
              algorithm_index: Optional[int] = None):
    """Inference not implemented. Chunked model intended for training only."""
    raise NotImplementedError

  def _loss(self, params, rng_key, feedback, mp_state, algorithm_index):
    (output_preds, hint_preds), mp_state = self.net_fn.apply(
        params, rng_key, [feedback.features],
        [mp_state],
        repred=False,
        init_mp_state=False,
        algorithm_index=algorithm_index)

    nb_nodes = _nb_nodes(feedback, is_chunked=True)

    total_loss = 0.0
    is_first = feedback.features.is_first
    is_last = feedback.features.is_last

    # Calculate output loss.
    for truth in feedback.outputs:
      total_loss += losses.output_loss_chunked(
          truth=truth,
          pred=output_preds[truth.name],
          is_last=is_last,
          nb_nodes=nb_nodes,
      )

    # Optionally accumulate hint losses.
    if self.decode_hints:
      for truth in feedback.features.hints:
        loss = losses.hint_loss_chunked(
            truth=truth,
            pred=hint_preds[truth.name],
            is_first=is_first,
            nb_nodes=nb_nodes,
        )
        total_loss += loss

    return total_loss, (mp_state,)

  def _compute_grad(self, params, rng_key, feedback, mp_state, algorithm_index):
    (lss, (mp_state,)), grads = jax.value_and_grad(self._loss, has_aux=True)(
        params, rng_key, feedback, mp_state, algorithm_index)
    return self._maybe_pmean(lss), mp_state, self._maybe_pmean(grads)

  def _feedback(self, params, rng_key, feedback, mp_state, opt_state,
                algorithm_index):
    (lss, (mp_state,)), grads = jax.value_and_grad(self._loss, has_aux=True)(
        params, rng_key, feedback, mp_state, algorithm_index)
    grads = self._maybe_pmean(grads)
    params, opt_state = self._update_params(params, grads, opt_state,
                                            algorithm_index)
    lss = self._maybe_pmean(lss)
    return lss, params, opt_state, mp_state

  def compute_grad(
      self,
      rng_key: hk.PRNGSequence,
      feedback: _Feedback,
      algorithm_index: Optional[Tuple[int, int]] = None,
  ) -> Tuple[float, _Array]:
    """Compute gradients."""

    if algorithm_index is None:
      assert len(self._spec) == 1
      algorithm_index = (0, 0)
    length_index, algorithm_index = algorithm_index
    # Reusing init_mp_state improves performance.
    # The next, commented out line, should be used for proper state keeping.
    # mp_state = self.mp_states[length_index][algorithm_index]
    mp_state = self.init_mp_states[length_index][algorithm_index]
    rng_keys = _maybe_pmap_rng_key(rng_key)  # pytype: disable=wrong-arg-types  # numpy-scalars
    feedback = _maybe_pmap_reshape(feedback, split_axis=1)
    mp_state = _maybe_pmap_reshape(mp_state, split_axis=0)

    loss, mp_state, grads = self.jitted_grad(
        self._device_params, rng_keys, feedback, mp_state, algorithm_index)
    loss = _maybe_pick_first_pmapped(loss)
    grads = _maybe_pick_first_pmapped(grads)
    mp_state = _maybe_restack_from_pmap(mp_state)
    self.mp_states[length_index][algorithm_index] = mp_state
    return loss, grads

  def feedback(self, rng_key: hk.PRNGSequence, feedback: _Feedback,
               algorithm_index=None) -> float:
    if algorithm_index is None:
      assert len(self._spec) == 1
      algorithm_index = (0, 0)
    length_index, algorithm_index = algorithm_index
    # Reusing init_mp_state improves performance.
    # The next, commented out line, should be used for proper state keeping.
    # mp_state = self.mp_states[length_index][algorithm_index]
    mp_state = self.init_mp_states[length_index][algorithm_index]
    rng_keys = _maybe_pmap_rng_key(rng_key)  # pytype: disable=wrong-arg-types  # numpy-scalars
    feedback = _maybe_pmap_reshape(feedback, split_axis=1)
    mp_state = _maybe_pmap_reshape(mp_state, split_axis=0)
    loss, self._device_params, self._device_opt_state, mp_state = (
        self.jitted_feedback(
            self._device_params, rng_keys, feedback,
            mp_state, self._device_opt_state, algorithm_index))
    loss = _maybe_pick_first_pmapped(loss)
    mp_state = _maybe_restack_from_pmap(mp_state)
    self.mp_states[length_index][algorithm_index] = mp_state
    return loss

  def verbose_loss(self, *args, **kwargs):
    raise NotImplementedError


def _nb_nodes(feedback: _Feedback, is_chunked) -> int:
  for inp in feedback.features.inputs:
    if inp.location in [_Location.NODE, _Location.EDGE]:
      if is_chunked:
        return inp.data.shape[2]  # inputs are time x batch x nodes x ...
      else:
        return inp.data.shape[1]  # inputs are batch x nodes x ...
  assert False


def _param_in_processor(module_name):
  return processors.PROCESSOR_TAG in module_name


def _filter_out_processor(params: hk.Params) -> hk.Params:
  return hk.data_structures.filter(
      lambda module_name, n, v: not _param_in_processor(module_name), params)


def _filter_in_processor(params: hk.Params) -> hk.Params:
  return hk.data_structures.filter(
      lambda module_name, n, v: _param_in_processor(module_name), params)


def _is_not_done_broadcast(lengths, i, tensor):
  is_not_done = (lengths > i + 1) * 1.0
  while len(is_not_done.shape) < len(tensor.shape):
    is_not_done = jnp.expand_dims(is_not_done, -1)
  return is_not_done


def accum_opt_update(params, grads, opt_state, opt, freeze_processor):
  """Update params from gradients collected from several algorithms."""
  # Average the gradients over all algos
  grads = jax.tree_util.tree_map(
      lambda *x: sum(x) / (sum([jnp.any(k) for k in x]) + 1e-12), *grads)
  updates, opt_state = opt.update(grads, opt_state)
  if freeze_processor:
    params_subset = _filter_out_processor(params)
    assert len(params) > len(params_subset)
    assert params_subset
    updates_subset = _filter_out_processor(updates)
    new_params = optax.apply_updates(params_subset, updates_subset)
    new_params = hk.data_structures.merge(params, new_params)
  else:
    new_params = optax.apply_updates(params, updates)

  return new_params, opt_state


@functools.partial(jax.jit, static_argnames=['opt'])
def opt_update(opt, flat_grads, flat_opt_state):
  return opt.update(flat_grads, flat_opt_state)


def filter_null_grads(grads, opt, opt_state, opt_state_skeleton, algo_idx):
  """Compute updates ignoring params that have no gradients.

  This prevents untrained params (e.g., encoders/decoders for algorithms
  that are not being trained) to accumulate, e.g., momentum from spurious
  zero gradients.

  Note: this works as intended for "per-parameter" optimizer state, such as
    momentum. However, when the optimizer has some global state (such as the
    step counts in Adam), the global state will be updated every time,
    affecting also future updates of parameters that had null gradients in the
    current step.

  Args:
    grads: Gradients for all parameters.
    opt: Optax optimizer.
    opt_state: Optimizer state.
    opt_state_skeleton: A "skeleton" of optimizer state that has been
      initialized with scalar parameters. This serves to traverse each parameter
      of the otpimizer state during the opt state update.
    algo_idx: Index of algorithm, to filter out unused encoders/decoders.
      If None, no filtering happens.
  Returns:
    Updates and new optimizer state, where the parameters with null gradient
      have not been taken into account.
  """
  def _keep_in_algo(k, v):
    """Ignore params of encoders/decoders irrelevant for this algo."""
    # Note: in shared pointer decoder modes, we should exclude shared params
    #       for algos that do not have pointer outputs.
    if ((processors.PROCESSOR_TAG in k) or
        (f'algo_{algo_idx}_' in k)):
      return v
    return jax.tree_util.tree_map(lambda x: None, v)

  if algo_idx is None:
    masked_grads = grads
  else:
    masked_grads = {k: _keep_in_algo(k, v) for k, v in grads.items()}
  flat_grads, treedef = jax.tree_util.tree_flatten(masked_grads)
  flat_opt_state = jax.tree_util.tree_map(
      lambda _, x: x  # pylint:disable=g-long-lambda
      if isinstance(x, (np.ndarray, jax.Array))
      else treedef.flatten_up_to(x),
      opt_state_skeleton,
      opt_state,
  )

  # Compute updates only for the params with gradient.
  flat_updates, flat_opt_state = opt_update(opt, flat_grads, flat_opt_state)

  def unflatten(flat, original):
    """Restore tree structure, filling missing (None) leaves with original."""
    if isinstance(flat, (np.ndarray, jax.Array)):
      return flat
    return jax.tree_util.tree_map(lambda x, y: x if y is None else y, original,
                                  treedef.unflatten(flat))

  # Restore the state and updates tree structure.
  new_opt_state = jax.tree_util.tree_map(lambda _, x, y: unflatten(x, y),
                                         opt_state_skeleton, flat_opt_state,
                                         opt_state)
  updates = unflatten(flat_updates,
                      jax.tree_util.tree_map(lambda x: 0., grads))
  return updates, new_opt_state
