What's Automatic Differentiation?
Reference: https://huggingface.co/blog/andmholm/what-is-automatic-differentiation
写在开始之前
这篇blog是本人结合Reference对Automatic Differentiation(自动微分)
的总结, 目的主要是回顾Machine Learning基础并完成CSE234
的PA1, 考虑到CSE234课程要求, 涉及到课程具体代码实现的部分将以注释&伪代码的形式实现, 希望读者能自行实现Automatic Differentiation
, Base is not only base.
同Reference blog的建议一样, 本文也建议读者有一些微积分
/ 线性代数
/ 机器学习
的基础, 有一些导数 or 梯度等概念将不会重新简述, 本文希望从应用和实现的角度对autodiff library 中的算子做再实现(也是CSE234的课程要求).
Introduction
在Machine Learning中, 神经网络运用了广泛的数学理论, 逐步从简单处理二分类或者多分类的简单架构逐步发展到现在拥有对话能力的LLM, 核心问题一直是优化问题, 即如何让模型进行学习?
现在使用的最多的, 也是被广泛认为最好的数学方法, 是gradient descent
(梯度下降)方法及其变体. 梯度下降法是一种优化算法, 目的是通过一步步迭代来提升模型的性能, 下面是对这个算法的详细解释:
梯度下降
- 使用一个目标函数(Loss), 计算输入的真实值与对这组输入的预测值之间的损失(or error).
- 通过求损失关于模型每个参数的偏导(求梯度)的方式, 来找出模型对损失的影响.
- 通过将每个参数减去各自的梯度(梯度缩放由学习率超参进行调整, 当然我们也可以通过编写调度器实现学习率的动态调整), 朝着loss最小化的方向调整模型参数.
- 清除所有梯度, 然后重复1-3的训练过程, 直到模型达到最佳性能(不幸的是过度训练可能导致参数在某一个方向上下降过多, 又称过拟合, 所以我们一般需要人为设置训练轮次来恰当的结束训练).
这个过程显然需要一个足够强大的深度神经网络来支撑, 当然如何找到这样一个自动优化过程是复杂且艰巨的, 我们不得不感谢前人在这方面作出的杰出贡献(https://arxiv.org/pdf/1502.05767), it's an honor to follow them.
在开始讨论自动微分之前, 我们先看一下更为朴素的数值微分和符号微分.
数值微分(Numeric Differentiation)
数值微分是一种极为朴实无华的实现, 经典的极限定义可以帮我们很好的理解这一点:
对于上式求导的含义我们不再赘述, 但是我们必须要注意到神经网络实际上要对多维的array做算术运算, 简单对某个x取极限并没有意义, 在ML领域, 对某个参数求数值偏导的定义, 可以认为是这样的:
上式是 forward difference
, 用于实现多变量函数中参数向量中单个参数的偏导数. 表示一个单位向量,第个元素为 1,其他元素均为 0.
Autodiff Library
这是CSE234(2025 winter) PA1一些Autodiff的算子实现, 尊重课程要求, 仅对原课程代码已经实现的算子分享全部代码, 其余算子均为个人实现, 仅提供注释供参考~
课程已实现的算子
class Op:
"""The class of operations performed on nodes."""
def __call__(self, *kwargs) -> Node:
"""Create a new node with this current op.
Returns
-------
The created new node.
"""
raise NotImplementedError
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Compute the output value of the given node with its input
node values given.
Parameters
----------
node: Node
The node whose value is to be computed
input_values: List[torch.Tensor]
The input values of the given node.
Returns
-------
output: torch.Tensor
The computed output value of the node.
"""
raise NotImplementedError
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given a node and its output gradient node, compute partial
adjoints with regards to each input node.
Parameters
----------
node: Node
The node whose inputs' partial adjoints are to be computed.
output_grad: Node
The output gradient with regard to given node.
Returns
-------
input_grads: List[Node]
The list of partial gradients with regard to each input of the node.
"""
raise NotImplementedError
class PlaceholderOp(Op):
"""The placeholder op to denote computational graph input nodes."""
def __call__(self, name: str) -> Node:
return Node(inputs=[], op=self, name=name)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
raise RuntimeError(
"Placeholder nodes have no inputs, and there values cannot be computed."
)
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
raise RuntimeError("Placeholder nodes have no inputs.")
class AddOp(Op):
"""Op to element-wise add two nodes."""
def __call__(self, node_A: Node, node_B: Node) -> Node:
return Node(
inputs=[node_A, node_B],
op=self,
name=f"({node_A.name}+{node_B.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the element-wise addition of input values."""
assert len(input_values) == 2
return input_values[0] + input_values[1]
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given gradient of add node, return partial adjoint to each input."""
return [output_grad, output_grad]
class AddByConstOp(Op):
"""Op to element-wise add a node by a constant."""
def __call__(self, node_A: Node, const_val: float) -> Node:
return Node(
inputs=[node_A],
op=self,
attrs={"constant": const_val},
name=f"({node_A.name}+{const_val})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the element-wise addition of the input value and the constant."""
assert len(input_values) == 1
return input_values[0] + node.constant
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given gradient of add node, return partial adjoint to the input."""
return [output_grad]
class MulOp(Op):
"""Op to element-wise multiply two nodes."""
def __call__(self, node_A: Node, node_B: Node) -> Node:
return Node(
inputs=[node_A, node_B],
op=self,
name=f"({node_A.name}*{node_B.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the element-wise multiplication of input values."""
assert len(input_values) == 2
return input_values[0] * input_values[1]
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given gradient of multiplication node, return partial adjoint to each input."""
return [output_grad * node.inputs[1], output_grad * node.inputs[0]]
class MulByConstOp(Op):
"""Op to element-wise multiply a node by a constant."""
def __call__(self, node_A: Node, const_val: float) -> Node:
return Node(
inputs=[node_A],
op=self,
attrs={"constant": const_val},
name=f"({node_A.name}*{const_val})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the element-wise multiplication of the input value and the constant."""
assert len(input_values) == 1
return input_values[0] * node.constant
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given gradient of multiplication node, return partial adjoint to the input."""
return [output_grad * node.constant]
class GreaterThanOp(Op):
"""Op to compare if node_A > node_B element-wise."""
def __call__(self, node_A: Node, node_B: Node) -> Node:
return Node(
inputs=[node_A, node_B],
op=self,
name=f"({node_A.name}>{node_B.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return element-wise comparison result as float tensor."""
assert len(input_values) == 2
return (input_values[0] > input_values[1]).float()
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Comparison operations have gradient of 0."""
return [zeros_like(node.inputs[0]), zeros_like(node.inputs[1])]
class SubOp(Op):
"""Op to element-wise subtract two nodes."""
def __call__(self, node_A: Node, node_B: Node) -> Node:
return Node(
inputs=[node_A, node_B],
op=self,
name=f"({node_A.name}-{node_B.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the element-wise subtraction of input values."""
assert len(input_values) == 2
return input_values[0] - input_values[1]
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given gradient of subtraction node, return partial adjoint to each input."""
return [output_grad, mul_by_const(output_grad, -1)]
class ZerosLikeOp(Op):
"""Zeros-like op that returns an all-zero array with the same shape as the input."""
def __call__(self, node_A: Node) -> Node:
return Node(inputs=[node_A], op=self, name=f"ZerosLike({node_A.name})")
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return an all-zero tensor with the same shape as input."""
assert len(input_values) == 1
return torch.zeros_like(input_values[0])
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
return [zeros_like(node.inputs[0])]
class OnesLikeOp(Op):
"""Ones-like op that returns an all-one array with the same shape as the input."""
def __call__(self, node_A: Node) -> Node:
return Node(inputs=[node_A], op=self, name=f"OnesLike({node_A.name})")
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return an all-one tensor with the same shape as input."""
assert len(input_values) == 1
return torch.ones_like(input_values[0])
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
return [zeros_like(node.inputs[0])]
class SumOp(Op):
"""
Op to compute sum along specified dimensions.
Note: This is a reference implementation for SumOp.
If it does not work in your case, you can modify it.
"""
def __call__(self, node_A: Node, dim: tuple, keepdim: bool = False) -> Node:
return Node(
inputs=[node_A],
op=self,
attrs={"dim": dim, "keepdim": keepdim},
name=f"Sum({node_A.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
assert len(input_values) == 1
return input_values[0].sum(dim=node.dim, keepdim=node.keepdim)
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
dim = node.attrs['dim']
keepdim = node.attrs["keepdim"]
if keepdim:
return [output_grad]
else:
reshape_grad = expand_as_3d(output_grad, node.inputs[0])
return [reshape_grad]
class ExpandAsOp(Op):
"""Op to broadcast a tensor to the shape of another tensor.
Note: This is a reference implementation for ExpandAsOp.
If it does not work in your case, you can modify it.
"""
def __call__(self, node_A: Node, node_B: Node) -> Node:
return Node(
inputs=[node_A, node_B],
op=self,
name=f"broadcast({node_A.name} -> {node_B.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the broadcasted tensor."""
assert len(input_values) == 2
input_tensor, target_tensor = input_values
return input_tensor.expand_as(target_tensor)
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given the gradient of the broadcast node, compute partial adjoint to input."""
return [sum_op(output_grad,dim=0), zeros_like(output_grad)]
class ExpandAsOp3d(Op):
"""Op to broadcast a tensor to the shape of another tensor.
Note: This is a reference implementation for ExpandAsOp3d.
If it does not work in your case, you can modify it.
"""
def __call__(self, node_A: Node, node_B: Node) -> Node:
return Node(
inputs=[node_A, node_B],
op=self,
name=f"broadcast({node_A.name} -> {node_B.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the broadcasted tensor."""
assert len(input_values) == 2
input_tensor, target_tensor = input_values
print('expand_op',input_tensor.shape, target_tensor.shape)
return input_tensor.unsqueeze(1).expand_as(target_tensor)
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given the gradient of the broadcast node, compute partial adjoint to input."""
return [sum_op(output_grad,dim=(0, 1)), zeros_like(output_grad)]
class LogOp(Op):
"""Logarithm (natural log) operation."""
def __call__(self, node_A: Node) -> Node:
return Node(
inputs=[node_A],
op=self,
name=f"Log({node_A.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the natural logarithm of the input."""
assert len(input_values) == 1, "Log operation requires one input."
return torch.log(input_values[0])
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given the gradient of the Log node, return the partial adjoint to the input."""
input_node = node.inputs[0]
return [output_grad / input_node]
class BroadcastOp(Op):
def __call__(self, node_A: Node, input_shape: List[int], target_shape: List[int]) -> Node:
return Node(
inputs=[node_A],
op=self,
attrs={"input_shape": input_shape, "target_shape": target_shape},
name=f"Broadcast({node_A.name}, {target_shape})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the broadcasted tensor."""
assert len(input_values) == 1
return input_values[0].expand(node.attrs["target_shape"])
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given gradient of broadcast node, return partial adjoint to input.
For broadcasting, we need to sum out the broadcasted dimensions to get
back to the original shape.
"""
if "input_shape" not in node.attrs:
raise ValueError("Input shape is not set. Make sure compute() is called before gradient()")
input_shape = node.attrs["input_shape"]
output_shape = node.attrs["target_shape"]
dims_to_sum = []
for i, (in_size, out_size) in enumerate(zip(input_shape[::-1], output_shape[::-1])):
if in_size != out_size:
dims_to_sum.append(len(output_shape) - 1 - i)
grad = output_grad
if dims_to_sum:
grad = sum_op(grad, dim=dims_to_sum, keepdim=True)
if len(output_shape) > len(input_shape):
grad = sum_op(grad, dim=list(range(len(output_shape) - len(input_shape))), keepdim=False)
return [grad]
自己实现的算子
DivOP
class DivOp(Op):
"""Op to element-wise divide two nodes."""
def __call__(self, node_A: Node, node_B: Node) -> Node:
return Node(
inputs=[node_A, node_B],
op=self,
name=f"({node_A.name}/{node_B.name})",
)
def compute(self, node: Node, input_values: List[torch.Tensor]) -> torch.Tensor:
"""Return the element-wise division of input values."""
assert len(input_values) == 2
"""TODO: your code here"""
def gradient(self, node: Node, output_grad: Node) -> List[Node]:
"""Given gradient of division node, return partial adjoint to each input."""
"""TODO: your code here"""