Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 5

Custom Autograd Functions in PyTorch

Recommended resources

Welcome to the next stage of our deep dive into the mathematical foundations of AI. In our last lesson, you peeled back the layers of PyTorch's autograd engine, understanding how reverse-mode automatic differentiation on dynamic computational graphs makes modern deep learning possible. You now know the theory behind loss.backward(). Today, we move from being a user of this powerful system to an architect.

This lesson is dedicated to making PyTorch your own. You will learn how to extend its automatic differentiation capabilities by implementing a custom torch.autograd.Function. This involves manually defining both the forward computation and, crucially, the backward pass that calculates the gradients. Mastering this skill is a significant step toward your goal of moving beyond APIs, as it allows you to implement novel operations for research or create highly optimized components for production systems.

1. The Anatomy of a Custom Function

When PyTorch's extensive library of operations isn't sufficient—either because you're designing a new mathematical function or because you need to fuse operations for performance—you can create your own by subclassing torch.autograd.Function.

This process has a clear and logical structure, requiring you to implement two static methods:

  • forward(ctx, *args): This method executes the forward computation. It receives a context object ctx as its first argument, followed by the input tensors. Inside this function, you perform your calculations and must save any tensors needed for the backward pass using ctx.save_for_backward(). The forward method runs in a no_grad context, meaning PyTorch does not build a graph of the operations within this method. Instead, the function itself becomes a single node in the larger computational graph.
  • backward(ctx, *grad_outputs): This is where you implement the gradient calculation. It receives the ctx object and the incoming gradients from the subsequent parts of the network (grad_outputs). You can retrieve the tensors you saved in the forward pass via ctx.saved_tensors. Your task is to compute the gradient of the loss with respect to each of the inputs of the forward function and return them in the same order.

Let's ground this in the chain rule you're familiar with. If your function is , the grad_outputs argument is the upstream gradient, . Your backward method must calculate the local gradient, , and return the product: .

This graph for `y = relu(z)` illustrates the core idea. The backward pass for the `relu` node receives the upstream gradient `∂y/∂z = 1` (let's assume it's 1 for this example, though in a real network it would be `∂L/∂y`). It then multiplies this by the local derivative `∂z/∂u` and `∂z/∂b` to propagate the gradients further back. Your custom `backward` method is responsible for calculating this local multiplication step.

2. Implementing a Custom Activation Function

To make this concrete, let's implement a "Clipped ReLU" function. This function will act like a standard ReLU, but it will be capped at a maximum value. The formula is:

The derivative with respect to has three regions:

Here is the implementation in PyTorch. Notice how we must return a gradient for every input to forward, even those that don't require one (in which case we return None).

import torch

class ClippedReLUFunction(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x, clip_val):
        """
        Forward pass for Clipped ReLU.
        - ctx: context object to store information for backward pass.
        - x: input tensor.
        - clip_val: the maximum value to clip to.
        """



        # Save the input tensor for the backward pass.
        # We don't need to save clip_val if it's not a tensor that requires gradients.
        ctx.save_for_backward(x, torch.tensor(clip_val))
        



        # Apply the clipped relu operation.
        return torch.clamp(x, min=0, max=clip_val)

    @staticmethod
    def backward(ctx, grad_output):
        """
        Backward pass for Clipped ReLU.
        - ctx: context object with saved tensors.
        - grad_output: gradient of the loss with respect to the output of this function.
        """



        # Retrieve the saved input tensor and clip_val.
        x, clip_val = ctx.saved_tensors
        



        # Create a copy of the incoming gradient.
        grad_x = grad_output.clone()
        



        # Create a mask for the derivative.
        # The gradient is 1 only where 0 < x < clip_val, and 0 otherwise.
        mask = (x > 0) & (x < clip_val)
        



        # Apply the mask. We can simply multiply because the derivative is 0 or 1.
        grad_x = grad_x * mask.float()
        



        # We must return a gradient for each input of forward.
        # Since clip_val is a fixed parameter, its gradient is None.
        return grad_x, None




# A simple wrapper to make it easier to use.
def clipped_relu(x, clip_val=6.0):
    return ClippedReLUFunction.apply(x, clip_val)




# --- Example Usage ---
# Create a tensor that requires gradients
a = torch.tensor([-2.0, 0.5, 3.0, 7.0], requires_grad=True)
clip_value = 6.0




# Apply our custom function
b = clipped_relu(a, clip_value)
print(f"Input: {a.data}")
print(f"Output: {b.data}") # Expected: [0., 0.5, 3., 6.]




# Calculate some dummy loss and backpropagate
loss = b.sum()
loss.backward()




# Check the gradients
print(f"Gradient of loss w.r.t. a: {a.grad}") # Expected: [0., 1., 1., 0.]

Run this code and analyze the output. The gradient a.grad should be [0., 1., 1., 0.], perfectly matching the derivative we defined. You have successfully taught PyTorch a new operation!


3. Advanced Mechanics and Common Pitfalls

The ctx object and save_for_backward have subtleties that are crucial for writing correct and robust functions, especially when considering higher-order gradients (gradients of gradients).

The main rule is that save_for_backward should only be used for input or output tensors of the forward function. Saving an intermediate result calculated inside forward can lead to incorrect gradients during a second backward pass, because the operations that created that intermediate result were not tracked by autograd.

Double Backward with Custom Functions - PyTorch

This PyTorch tutorial provides excellent, focused examples of how save_for_backward works and what to avoid.

First, read the examples for the Square and Exp functions in the sections Saving the Inputs and Saving the Outputs. These show the standard, correct usage where you save an input or an output tensor. Next, and most importantly, read the section What not to do. Pay close attention to the SinhBad example and the resulting visualization. It demonstrates why directly saving an intermediate result that is not an input or output breaks the computational graph for higher-order derivatives. This is a common mistake and understanding why it's wrong is key to deep expertise.

The tutorial also presents a workaround for when you need to save intermediate results: you can include them as extra outputs of the forward function. This ensures they are part of the computational graph, allowing autograd to correctly track their history for higher-order gradients.


4. From Custom Math to High-Performance Engineering

Your motivation to become a senior AI engineer requires looking beyond just correctness to performance. While custom autograd functions are great for novel mathematics, their most common industrial application is for performance optimization. Python's overhead can be a bottleneck. By implementing the forward and backward passes in C++ or CUDA, you can fuse multiple operations into a single, highly optimized kernel.

Pleasingly, the torch.autograd.Function structure remains the same. You simply wrap the calls to your high-performance C++ code inside the familiar forward and backward static methods.

Custom C++ and CUDA Extensions - PyTorch Tutorials

This tutorial demonstrates how to build a custom nn.Module backed by a C++ implementation. The core of this integration is an autograd.Function.

Focus on the Python code in the section titled Using Your Extension. Observe the LLTMFunction class. You'll see the exact same pattern: a subclass of torch.autograd.Function with forward and backward methods. The only difference is that instead of performing the computations in Python, they call out to the compiled C++ functions (lltm_cpp.forward and lltm_cpp.backward).

This powerfully illustrates how the concept you learned today is the bridge between Python-level deep learning logic and high-performance, low-level implementations—a critical intersection for any senior AI engineer.


5. The Vector-Jacobian Product in Practice

Finally, let's briefly revisit the mathematics to formalize what the grad_output argument in the backward method represents. As you learned previously, reverse-mode AD is fundamentally about computing vector-Jacobian products. The grad_output is the "vector" in this product.

The Fundamentals of Autograd

This official PyTorch video recaps the concept of vector-Jacobian products (VJP) and shows how it applies to the .backward() call.

Watch the segment explaining the mathematical formulation of VJP, which connects a model function (f(x)) and a loss function (g(y)). Then, observe how a non-scalar output requires an explicit vector to be passed to .backward(). This vector is precisely what your custom backward method receives as grad_output.

Your backward function's role is to manually implement the "Jacobian-product" part of the VJP for your specific operation. It takes the incoming gradient vector and multiplies it by the local Jacobian of your function to produce the gradient with respect to the inputs.


Conclusion

In this lesson, you've transitioned from a consumer to a creator within the PyTorch ecosystem. You now have the tools and understanding to extend the framework's core capabilities.

Key takeaways include:

  • Custom operations are built by subclassing torch.autograd.Function and implementing static forward and backward methods.
  • The forward method computes the output and uses ctx.save_for_backward to store tensors needed for the gradient calculation.
  • The backward method implements the chain rule, using ctx.saved_tensors and the incoming grad_output to compute and return the gradients for each of the forward method's inputs.
  • This pattern is central to both implementing novel mathematical operations and to integrating high-performance C++/CUDA code for production-level optimization.
  • Understanding this mechanism is fundamental to debugging, extending, and optimizing deep learning models at an expert level.

You are now equipped to not only use the building blocks of deep learning but to design and forge your own. This is a crucial skill that will serve you throughout your journey.

In our next lesson, we will broaden our scope from building a single custom component to architecting the entire training process. You will build a custom PyTorch training loop from scratch, giving you full control over every step of model optimization.