User-Defined Functions: Arguments, Shapes, and Error Messages #
It's possible to construct user-defined functions that take in scalar/vector/matrix arguments, and produce a scalar/vector/matrix appropriately.
Example #
Suppose we have a MatrixGrader
problem in which we want to provide students with a function rot(vector, axis, angle)
that rotates a vector about a given axis by a given angle. We can provide such a function with the user_functions
configuration key.
>>> import numpy as np
>>> from mitxgraders import *
>>> def rot(vec, axis, angle):
... """
... Rotate vec by angle around axis. Implemented by Euler-Rodrigues formula:
... https://en.wikipedia.org/wiki/Euler-Rodrigues_formula
...
... Arguments:
... vec: a 3-component MathArray to rotate
... axis: a 3-component MathArray to rotate around
... angle: a number
... """
... vec = np.array(vec)
... unit_axis = np.array(axis)/np.linalg.norm(axis)
... a = np.cos(angle/2)
... omega = unit_axis * np.sin(angle/2)
... crossed = np.cross(omega, vec)
... result = vec + 2*a*crossed + 2*np.cross(omega, crossed)
... return MathArray(result)
>>> grader_1 = MatrixGrader(
... answers='rot(v, [0, 0, 1], theta)',
... variables=['v', 'theta'],
... sample_from={
... 'v': RealVectors(shape=3),
... },
... user_functions={
... 'rot': rot
... }
... )
The Problem #
Our rot(vec, axis, angle)
function works, but if students supply the function above with arguments of incorrect type, they receive unhelpful error messages:
>>> try:
... grader_1(None, 'rot(v, theta, [0, 0, 1])')
... except StudentFacingError as error:
... print(error)
There was an error evaluating rot(...). Its input does not seem to be in its domain.
The Solution #
To provide students with more useful error messages, we can use specify_domain
, a decorator function imported from mitxgraders
. Decorator Functions are "higher-order functions" that take functions as input and produce functions as output, usually modifying the input function's behavior. In our case, specify_domain
will modify the behavior of rot
so as to provide more helpful StudentFacingError
s.
Here we go:
>>> @specify_domain(input_shapes=[[3], [3], [1]], display_name='rot')
... def rot_with_error_messages(vec, axis, angle):
... # rot(vec, axis, angle) defined above
... return rot(vec, axis, angle)
>>> # Define new grader using rot_with_error_messages
>>> grader_2 = MatrixGrader(
... answers='rot(v, [0, 0, 1], theta)',
... variables=['v', 'theta'],
... sample_from={
... 'v': RealVectors(shape=3),
... },
... user_functions={
... 'rot': rot_with_error_messages
... }
... )
Now if a student calls rot
with incorrect inputs, they receive a more helpful message:
>>> try:
... grader_2(None, 'rot(v, theta, [0, 0, 1])')
... except StudentFacingError as error:
... print(str(error).replace('<br/>', '\n'))
There was an error evaluating function rot(...)
1st input is ok: received a vector of length 3 as expected
2nd input has an error: received a scalar, expected a vector of length 3
3rd input has an error: received a vector of length 3, expected a scalar
Configuring specify_domain #
The decorator specify_domain
accepts optional keyword arguments and should be called in either of two equivalent ways:
>>> @specify_domain(keyword_arguments) # doctest: +SKIP
... def target_function(x, y, z):
... pass # do whatever you want
>>> # or, equivalently:
>>> def target_function(x, y ,z):
... pass # do whatever you want
>>> decorated_function = specify_domain(keyword_arguments)(target_function) # doctest: +SKIP
The keyword arguments are:
input_shapes
: A list that indicates the shape of each input to the target function. This list must have the same length as the number of arguments in the target function. Each list element should be one of the following:1
: indicates input is scalark
(positive integer > 1): indicates input is a k-component vector[k1, k2, ...]
, list of positive integers: means input is an array of shape (k1, k2, ...)(k1, k2, ...)
, tuple of positive integers: equivalent to[k1, k2, ...]
'square'
(string): indicates a square matrix of any dimension
display_name
(str): Function name to be used in error messages. Defaults toNone
, meaning that the function's__name__
attribute is used.
So, for example,
>>> @specify_domain(input_shapes=[1, [3, 2], 4], display_name='myfunc')
... def some_function(x, A, v):
... pass
specifies that the function some_func
must be called with three arguments:
- 1st argument: scalar,
- 2nd argument: 3 by 2 matrix, and a
- 3rd argument: 4-component vector.
Arbitrary Same-Shape Arguments #
Some functions may allow an arbitrary number of arguments to be passed in. For example, consider a user-defined minimum function:
>>> def my_min(*args):
... return min(*args)
To inform specify_domain
that a function should accept arbitrarily many arguments of a certain shape, supply a single shape to input_shapes
, and also pass in a min_length
parameter, to specify the minimum number of arguments required. (If you specify min_length
and have more than one shape in input_shapes
, a ConfigError
will result.) So, our my_min
function can be decorated as follows:
>>> @specify_domain(input_shapes=[1], display_name='min', min_length=2)
... def my_min(*args):
... return min(*args)
>>> my_min(1.5, 2.3, 4.6)
1.5
>>> try:
... my_min(1)
... except StudentFacingError as error:
... print(error)
Wrong number of arguments passed to min(...): Expected at least 2 inputs, but received 1.
>>> try:
... my_min(MathArray([1, 2]), MathArray([3, 4]))
... except StudentFacingError as error:
... print(error)
There was an error evaluating function min(...)
1st input has an error: received a vector of length 2, expected a scalar
2nd input has an error: received a vector of length 2, expected a scalar