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 StudentFacingErrors.

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:

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:

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