Implementation¶
Note
A full API documentation is avalable here: autodiff
External dependencies¶
The autodiff
package proper requires only numpy
. Running tests requires pytest
and codecov
, while generating this documentation requires sphinx
(version 1.7.9).
Core data structures and classes¶
Currently, the autodiff
package has two core data structure, Number
and Array
. A Number
is a scalar that stores a value and a derivative. Array
subclasses the numpy.ndarray
to support functions with vector inputs. It holds a 1-d array of Number
objects.
Important attributes of the Number
class¶
The Number
class has only two attributes, a value (val
) and a dict
of partial derivatives (_deriv
). It is intialized as follows:
def __init__(self, val, deriv=None):
self.val = val
if deriv is None:
self._deriv = {
self: 1
}
elif isinstance(deriv, dict):
self._deriv = deriv
#keep also a copy of the derivative w.r.t. itself
self._deriv[self] = 1
else:
self._deriv = {
self: deriv
}
The _deriv
dict is meant to not be accessable to the user directly. It is only stored for internal reference. To access partial derivatives, the user can call .jacobian()
method, with a list of elements (or a single element) that the user wants to take partial derivatives with respect to. .jacobian()
method takes elements out of the _deiv
dict to display for the user
>>> from autodiff.structures import Number
>>> x = Number(2)
>>> y = Number(3)
>>> def f(x, y, a=3):
>>> return a * x * y
>>> q = f(x, y, a=3)
>>> q.jacobian(x)
9
>>> q.jacobian(y)
6
>>> q._deriv
{Number(value=2): 9, Number(value=3): 6}
The Array
class inherits from the np.array. It stores a _data
attribute internally to hold a list of Number objects.
def __init__(self, iterable):
self._data = np.array(iterable, dtype=np.object)
Methods and name attributes¶
The Number
class overloads the following common elementary operations:
+
-
*
/
**
We have also included the following elementary operations, all of which use their numpy counterparts and live in the autodiff.operations
module.
autodiff.operations.sin()
autodiff.operations.cos()
autodiff.operations.tan()
autodiff.operations.asin()
autodiff.operations.acos()
autodiff.operations.atan()
autodiff.operations.log()
autodiff.operations.exp()
autodiff.operations.sqrt()
Defining custom elementary functions is straightforward, using the elementary
decorator (this is the same method we use internally). The decorator takes one input, a function with the same arguments as the elementary operation, but calculates the derivative of the operation rather than the value. We call this derivative function internally.
To perform the derivatives, we wrote an elementary
decorator that will also support using all these operations on Array
objects by looping through each element:
def elementary(deriv_func):
def inner(func):
@wraps(func)
def inner_func(*args, **kwargs):
# Check if args[0] has len. If so, apply the function elementwise and return an array
# rather than a Number
try:
value = func(*args, **kwargs)
deriv = deriv_func(*args, **kwargs)
return Number(value, deriv)
except AttributeError:
vals = [func(element, *args[1:], **kwargs) for element in args[0]]
derivs = [deriv_func(element, *args[1:], **kwargs) for element in args[0]]
numbers = [Number(val, deriv) for val, deriv in zip(vals, derivs)]
return Array(numbers)
return inner_func
return inner
Then, each elementary operation can be defined as follows:
def my_pow_deriv(a, b):
""" Returns the derivative of my_pow at a and b
"""
return b * a ** (b - 1)
@elementary(my_pow_deriv)
def my_pow(a, b):
return pow(a, b)
def sin_deriv(a):
""" Returns the derivative of the sin() elemental operation"""
try:
return a.deriv * np.cos(a.value)
except AttributeError
return np.cos(a)
@elementary(sin_deriv)
def sin(a):
try ...
return np.sin(a)
The Number()
class overloads __add__
and __radd__
, along with other elementary operations as follows. The autodiff.array
class overloads vector operations similarly.
# From autodiff.operations
def add_deriv(x,y):
"""Derivative of additions, one of x and y has to be a Number object
Args:
x: a Number
y: a Number object or an int/float to be added
Returns:
The derivative of the sum of x and y
"""
try:
d={}
for key in x.deriv.keys():
if key in y.deriv.keys():
d[key] = x.deriv[key] + y.deriv[key]
else:
d[key] = x.deriv[key]
for key in y.deriv.keys():
if not key in x.deriv.keys():
d[key] = y.deriv[key]
except AttributeError:
d = x.deriv
return d
@elementary(add_deriv)
def add(x,y):
"""add two numbers together, one of x and y has to be a Number object
Args:
x: a Number object or an int/float
y: a Number object or an int/float to be added
Returns:
value of the sum
"""
try:
s = x.val + y.val
except:
s = x.val + y
return s
# In autodiff.structures
class Number():
...
def __add__(self, other):
'''
Overloads the add method to add a number object to another Number object or an integer/float.
Args:
other: a Number object or an integer or float to be added.
Returns:
another Number object, which is the sum.
'''
return operations.add(self, other)
def __radd__(self, other):
'''
Overloads the right add method to add a number object to another Number object or an integer/float.
Args:
other: a Number object or an integer or float to be added.
Returns:
another Number object, which is the sum.
'''
return operations.add(self, other)
The Array
class overloads the following operations:
+
-
*
/
**
These will either support operations between two Array
objects, or one Array
object and one Number
/integer
/float
object.
Moreover, Array
will support the following operations, which will perform element-wise operations on each element when called:
autodiff.operations.sin()
autodiff.operations.cos()
autodiff.operations.tan()
autodiff.operations.asin()
autodiff.operations.acos()
autodiff.operations.atan()
autodiff.operations.log()
autodiff.operations.exp()
autodiff.operations.sqrt()
You can call these directly on an Array object, as the same case with Number.
To access the derivatives, Array
implements a jacobian method, which will return another Array
object in 2-d, holding each row as an element of the original array, each column as the element of order
to take partial derivatives with respect to.
def jacobian(self, order):
'''
Returns the jacobian matrix by the order specified.
Args:
order: the order to return the jacobian matrix in. Has to be not null
Returns:
a list of partial derivatives specified by the order.
'''
def _partial(deriv, key):
try:
return deriv[key]
except KeyError:
raise ValueError(
f'No derivative with respect to {repr(order)}'
)
j = []
for element in self._lst:
jacobian = []
try:
for key in order:
jacobian.append(_partial(element.deriv, key))
except TypeError:
# The user specified a scalar order
jacobian.append(_partial(element.deriv, order))
j.append(jacobian)
j = Array(j)
return j