docs: protected Pair<Gradient, INDArray> calcBackpropGradients(INDArray epsilon, boolean withOutputLayer, boolean tbptt,
                                                               boolean returnInputActGrad) {
          if (flattenedGradients == null) {
              initGradientsView();
          }
          String multiGradientKey;
          Gradient gradient = new DefaultGradient(flattenedGradients);
  
          LayerWorkspaceMgr mgrEven;
          LayerWorkspaceMgr mgrOdd;
  
          if(layerWiseConfigurations.getTrainingWorkspaceMode() == WorkspaceMode.NONE){
              mgrEven = LayerWorkspaceMgr.noWorkspaces();
              mgrOdd = mgrEven;
              WorkspaceUtils.assertNoWorkspacesOpen("Expected no workspace active in calcBackpropGradients when " +
                      "training workspace is set to none");
          } else {
              /*
              Workspaces for backprop in MLN share some features with outputOfLayerDetached, in terms of the
              "two alternating workspaces" idea (but for activation gradients here, instead of activations there).
  
              Workspace design for backprop:
              First: we calculate all activations, and ensure they are in WS_ALL_LAYERS_ACT. We assume this is done
                     EXTERNALLY to this method
              Then: we iterate backwards over layers.
  
              Activations gradient workspaces: opened/closed every second layer.
              mgrEven (WS_LAYER_ACT_1) activation grad WS opens at start of 8, 4, 2, 0; closed at end of 7, 5, 3, 1 etc
              mgrOdd (WS_LAYER_ACT_2) activation grad WS opens at start of 7, 3, 5, 1; closed at end of 6, 4, 2, 0 etc
  
               */
  
              mgrEven = LayerWorkspaceMgr.builder()
                      //Activations in context of backprop (preOut methods etc) are not used outside of the layer itself
                      .with(ArrayType.ACTIVATIONS, WS_LAYER_WORKING_MEM, WS_LAYER_WORKING_MEM_CONFIG)
                      .with(ArrayType.INPUT, WS_ALL_LAYERS_ACT, WS_ALL_LAYERS_ACT_CONFIG) //Usually not required here. Exception: OutputLayer dropout
                      .with(ArrayType.ACTIVATION_GRAD, WS_LAYER_ACT_1, WS_LAYER_ACT_X_CONFIG)
                      .with(ArrayType.FF_WORKING_MEM, WS_LAYER_WORKING_MEM, WS_LAYER_WORKING_MEM_CONFIG)
                      .with(ArrayType.BP_WORKING_MEM, WS_LAYER_WORKING_MEM, WS_LAYER_WORKING_MEM_CONFIG)
                      .with(ArrayType.RNN_FF_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM_CONFIG)
                      .with(ArrayType.RNN_BP_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM_CONFIG)
                      .build();
  
              mgrOdd = LayerWorkspaceMgr.builder()
                      //Activations in context of backprop (preOut methods etc) are not used outside of the layer itself
                      .with(ArrayType.ACTIVATIONS, WS_LAYER_WORKING_MEM, WS_LAYER_WORKING_MEM_CONFIG)
                      .with(ArrayType.INPUT, WS_ALL_LAYERS_ACT, WS_ALL_LAYERS_ACT_CONFIG) //Usually not required here. Exception: OutputLayer dropout
                      .with(ArrayType.ACTIVATION_GRAD, WS_LAYER_ACT_2, WS_LAYER_ACT_X_CONFIG)
                      .with(ArrayType.FF_WORKING_MEM, WS_LAYER_WORKING_MEM, WS_LAYER_WORKING_MEM_CONFIG)
                      .with(ArrayType.BP_WORKING_MEM, WS_LAYER_WORKING_MEM, WS_LAYER_WORKING_MEM_CONFIG)
                      .with(ArrayType.RNN_FF_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM_CONFIG)
                      .with(ArrayType.RNN_BP_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM, WS_RNN_LOOP_WORKING_MEM_CONFIG)
                      .build();
  
              if(epsilon == null) {
                  //If epsilon is non-null: external errors use case -> inputs are already detached
                  WorkspaceUtils.assertOpenActiveAndCurrent(WS_ALL_LAYERS_ACT, "calcBackpropGradients method requires workspace WS_ALL_LAYERS_ACT" +
                          " to be open when workspaces are used");
              }
          }
          mgrEven.setHelperWorkspacePointers(helperWorkspaces);
          mgrOdd.setHelperWorkspacePointers(helperWorkspaces);
  
          //calculate and apply the backward gradient for every layer
          /*
           * Skip the output layer for the indexing and just loop backwards updating the coefficients for each layer.
           * (when withOutputLayer == true)
           *
           * Activate applies the activation function for each layer and sets that as the input for the following layer.
           *
           * Typical literature contains most trivial case for the error calculation: wT * weights
           * This interpretation transpose a few things to get mini batch because ND4J is rows vs columns organization for params
           */
          int numLayers = getnLayers();
          //Store gradients is a list; used to ensure iteration order in DefaultGradient linked hash map. i.e., layer 0 first instead of output layer
          LinkedList<Triple<String, INDArray, Character>> gradientList = new LinkedList<>();
  
  
          Pair<Gradient, INDArray> currPair = null;
          MemoryWorkspace wsActGradCloseNext = null;
          MemoryWorkspace wsActGradTemp = null;
          MemoryWorkspace initialWorkspace = Nd4j.getMemoryManager().getCurrentWorkspace();
          try {
              for (int i = layers.length - 1; i >= 0; i--) {
                  if (layers[i] instanceof FrozenLayer) {
                      break;
                  }
  
                  LayerWorkspaceMgr workspaceMgr = (i % 2 == 0 ? mgrEven : mgrOdd);
  
                  if (withOutputLayer && i == layers.length - 1) {
                      if (!(getOutputLayer() instanceof IOutputLayer)) {
                          log.warn("Warning: final layer isn't output layer. You cannot use backprop without an output layer.");
                          return null;
                      }
  
                      IOutputLayer outputLayer = (IOutputLayer) getOutputLayer();
                      if (labels == null && outputLayer.needsLabels())
                          throw new IllegalStateException("No labels found");
                      outputLayer.setLabels(labels);
                  }
  
                  //Open activation gradients WS *then* BP working memory, so BP working memory is opened last for use in layers
                  wsActGradTemp = workspaceMgr.notifyScopeEntered(ArrayType.ACTIVATION_GRAD);
                  try(MemoryWorkspace wsBPWorking = workspaceMgr.notifyScopeEntered(ArrayType.BP_WORKING_MEM)){
  
                      //Note that because we're opening activation workspaces not in a simple nested order, we'll manually
                      // override the previous workspace setting. Otherwise, when we close these workspaces, the "current"
                      // workspace may be set to the incorrect one
                      wsActGradTemp.setPreviousWorkspace(initialWorkspace);
                      wsBPWorking.setPreviousWorkspace(initialWorkspace);
  
                      INDArray eps = (i == layers.length - 1 ? epsilon : currPair.getRight());  //eps is null for OutputLayer
  
                      if(!tbptt){
                          //Standard case
                          currPair = layers[i].backpropGradient(eps, workspaceMgr);
                      } else {
                          //TBPTT gradient
                          if (layers[i] instanceof RecurrentLayer) {
                              currPair = ((RecurrentLayer) layers[i]).tbpttBackpropGradient(currPair.getSecond(),
                                      layerWiseConfigurations.getTbpttBackLength(), workspaceMgr);
                          } else {
                              currPair = layers[i].backpropGradient(currPair.getSecond(), workspaceMgr);
                          }
                      }
  
                      if(currPair.getSecond() != null) {
                          //Edge case: may be null for Embedding layer, for example
                          validateArrayWorkspaces(workspaceMgr, currPair.getSecond(), ArrayType.ACTIVATION_GRAD, i,
                                  false, "Backprop");
                      }
  
                      for (Map.Entry<String, INDArray> entry : currPair.getFirst().gradientForVariable().entrySet()) {
                          String origName = entry.getKey();
                          multiGradientKey = String.valueOf(i) + "_" + origName;
                          gradientList.addLast(new Triple<>(multiGradientKey, entry.getValue(),
                                  currPair.getFirst().flatteningOrderForVariable(origName)));
                      }
                      if (getLayerWiseConfigurations().getInputPreProcess(i) != null) {
                          currPair = new Pair<>(currPair.getFirst(),
                                  this.layerWiseConfigurations.getInputPreProcess(i)
                                          .backprop(currPair.getSecond(), getInputMiniBatchSize(), workspaceMgr));
                          if (i > 0 && currPair.getSecond() != null){
                              validateArrayWorkspaces(workspaceMgr, currPair.getSecond(), ArrayType.ACTIVATION_GRAD, i,
                                      true, "Backprop");
                          }
                      }
  
                      if(i == 0 ){
                          if(returnInputActGrad && currPair.getSecond() != null){
                              currPair.setSecond(currPair.getSecond().detach());
                          } else {
                              currPair.setSecond(null);
                          }
                      }
  
                      if(wsActGradCloseNext != null){
                          wsActGradCloseNext.close();
                      }
                      wsActGradCloseNext = wsActGradTemp;
                      wsActGradTemp = null;
                  }
              }
          } finally {
              if(wsActGradCloseNext != null){
                  wsActGradCloseNext.close();
              }
              if(wsActGradTemp != null){
                  //Should only be non-null on exception
                  wsActGradTemp.close();
              }
              Nd4j.getMemoryManager().setCurrentWorkspace(initialWorkspace);
          }
  
          if (layerWiseConfigurations.getTrainingWorkspaceMode() == WorkspaceMode.NONE) {
              WorkspaceUtils.assertNoWorkspacesOpen("Expected no workspace active in calcBackpropGradients when " +
                      "training workspace is set to none");
          } else {
              if(epsilon == null) {
                  //If epsilon != null: external errors use case (inputs are detached instead)
                  WorkspaceUtils.assertOpenActiveAndCurrent(WS_ALL_LAYERS_ACT, "calcBackpropGradients: WS_ALL_LAYERS_ACT is no" +
                          " longer the currently open/active workspace");
              }
          }
  
          //Add gradients to Gradients (map), in correct order
          for (Triple<String, INDArray, Character> triple : gradientList) {
              gradient.setGradientFor(triple.getFirst(), triple.getSecond(), triple.getThird());
          }
  
          return new Pair<>(gradient, currPair.getSecond());
      }