Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app/docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Valid params include `atol` and `rtol`, which can be used in combination, or alo
is_correct = abs(res - ans) <= (atol + rtol*abs(ans))
```

Alternatively, `sig_figs` (or `significant_figures`) can be supplied to round both `res` and `ans` to N significant figures and require them to be equal:

```python
is_correct = round_to_sig_figs(res, sig_figs) == round_to_sig_figs(ans, sig_figs)
```

`sig_figs` cannot be combined with `atol`/`rtol` — supplying both raises an exception.

## Inputs

```json
Expand All @@ -16,7 +24,8 @@ is_correct = abs(res - ans) <= (atol + rtol*abs(ans))
"answer": "<number>",
"params": {
"atol": "<number>",
Comment thread
m-messer marked this conversation as resolved.
"rtol": "<number>"
"rtol": "<number>",
"sig_figs": "<int>"
}
}
```
Expand All @@ -29,6 +38,10 @@ Absolute tolerance parameter

Relative tolerance parameter

### `sig_figs`

Significant-figures parameter. Mutually exclusive with `atol`/`rtol`.

## Outputs
```json
{
Expand Down
16 changes: 15 additions & 1 deletion app/docs/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The left-hand side is the absolute difference between the student's response and

## Parameters

Both parameters default to `0` (exact match required) and can be used individually or together.
`atol` and `rtol` both default to `0` (exact match required) and can be used individually or together. `sig_figs` is an alternative comparison mode and cannot be used together with `atol`/`rtol`.

### `atol` — Absolute tolerance

Expand All @@ -22,6 +22,10 @@ Specifies a fixed margin around the answer, regardless of its magnitude. Use thi

Specifies an acceptable error as a fraction of the answer's magnitude. Use this when the answer is very large or very small and a percentage-based margin makes more sense than a fixed one.

### `sig_figs` — Significant figures

Rounds both the response and the answer to the given number of significant figures and requires them to match. Use this when correctness is defined in terms of precision (e.g. "correct to 3 significant figures") rather than a fixed or proportional margin. Cannot be combined with `atol`/`rtol` — supplying both raises an error.

## Examples

### Exact match (default)
Expand Down Expand Up @@ -52,8 +56,18 @@ With answer `6.674e-11`, accepts any response within **1%** of the answer. Good

Both tolerances contribute: with answer `9.81`, the allowed difference is `0.01 + 0.005 × 9.81 ≈ 0.059`. Useful when you want a minimum floor (`atol`) plus a proportional allowance (`rtol`).

### Significant figures

```json
{ "sig_figs": 3 }
```

With answer `3.14159`, accepts any response that rounds to `3.14` (e.g. `3.136`–`3.144`). Unlike `atol`/`rtol`, this cannot be combined with tolerance params — `{ "sig_figs": 3, "atol": 0.01 }` will raise an error rather than be evaluated.

## Notes

**Note:** If the answer is not a number, all responses will generate an error.

**Note:** If the response is not a number, a feedback message asking the student to submit a number will be returned.

**Note:** `sig_figs` and `atol`/`rtol` are mutually exclusive; supplying both will generate an error.
86 changes: 62 additions & 24 deletions app/evaluation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
from numpy import spacing


def _is_number(value):
return isinstance(value, int) or isinstance(value, float)


def _round_to_sig_figs(value, sig_figs):
if value == 0:
return 0.0
return float(f"{value:.{sig_figs}g}")


def _result(is_correct, real_diff, allowed_diff, feedback=""):
return {
"is_correct": bool(is_correct),
"real_diff": real_diff,
"allowed_diff": allowed_diff,
"feedback": feedback,
}


def _evaluate_sig_figs(response, answer, sig_figs):
if not _is_number(response):
return _result(False, None, None, "Please enter a number.")

rounded_answer = _round_to_sig_figs(answer, sig_figs)
rounded_response = _round_to_sig_figs(response, sig_figs)
is_correct = abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to discuss with Phil what we mean by sig figs.

Example:
answer = 0.123
response = 0.1231

Is the response correct to 3 sig fig? Is this a scientific question of expressing a number to 3 sig fig? If so then no, the response is not correct. It should have been rounded to 0.123. If we're asking for the two numbers to be similar after rounding to 3 s.f. that's a different criterion?

Similarly 0.1230 could be interpreted as equal to 0.123, or not equal because it implies a different level of precision. (e.g. https://ccnmtl.columbia.edu/projects/mmt/frontiers/web/chapter_5/6665.html)

I suspect that we need two types of sig fig checking? The computational type and the scientific type? The latter would need to operate on the string before it is converted to a float or integer as a prelude.

I also wonder if we should incorporate feedback when there is a level of known equality (this point applies to the wider isSimilar), e.g. 'correct within the allowed tolerance'.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback, I am happy to discuss with Phil further. However, I think the link you gave provides the detail that I missed in my first implementation. Currently, the implementation doesn't support trailing 0s for decimal numbers as the conversion will truncate them.

I think adding feedback throughout would also be beneficial. I'll make the edits.


return _result(is_correct, abs(response - answer), None)


def _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance):
allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) + spacing(answer)

if not _is_number(response):
return _result(False, None, allowed_diff, "Please enter a number.")

real_diff = abs(response - answer)

return _result(real_diff <= allowed_diff, real_diff, allowed_diff)


def evaluation_function(response, answer, params) -> dict:
"""
Function used to grade a student response.
Expand All @@ -21,31 +63,27 @@ def evaluation_function(response, answer, params) -> dict:
to output the grading response.
"""

rtol = params.get("rtol", 0)
atol = params.get("atol", 0)
sig_figs = params.get("significant_figures", params.get("sig_figs"))
relative_tolerance = params.get("relative_tolerance", params.get("rtol", 0))
absolute_tolerance = params.get("absolute_tolerance", params.get("atol", 0))

is_correct = None
real_diff = None
uses_tolerance = any(
key in params
for key in ("relative_tolerance", "rtol", "absolute_tolerance", "atol")
)

if not (isinstance(answer, int) or isinstance(answer, float)):
raise Exception("Answer must be a number.")
if sig_figs is not None and uses_tolerance:
raise Exception(
"significant_figures/sig_figs cannot be used together with "
"relative_tolerance/rtol or absolute_tolerance/atol."
)

real_diff = None
allowed_diff = atol + rtol * abs(answer)
allowed_diff += spacing(answer)
is_correct = False
feedback = ""
if not (isinstance(response, int) or isinstance(response, float)):
feedback = "Please enter a number."
else:
real_diff = abs(response - answer)
allowed_diff = atol + rtol * abs(answer)
allowed_diff += spacing(answer)
is_correct = bool(real_diff <= allowed_diff)
if sig_figs is not None and (not isinstance(sig_figs, int) or sig_figs < 1):
raise Exception("significant_figures must be a positive integer.")

return {
"is_correct": is_correct,
"real_diff": real_diff,
"allowed_diff": allowed_diff,
"feedback": feedback,
}
if not _is_number(answer):
raise Exception("Answer must be a number.")

if sig_figs is not None:
return _evaluate_sig_figs(response, answer, sig_figs)
return _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance)
202 changes: 202 additions & 0 deletions app/evaluation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,5 +244,207 @@ def test_response_is_not_number(self):
self.assertEqual(response.get("is_correct"), False)
self.assertEqual("Please enter a number." in response.get("feedback"), True)

def test_full_param_correct(self):
body = {
"response": 1e6,
"answer": 1e7,
"params": {
"relative_tolerance": 1e-1,
"absolute_tolerance": 8.2e6
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), True)

def test_full_param_incorrect(self):
body = {
"response": 1e6,
"answer": 2e7,
"params": {
"relative_tolerance": 1e-1,
"absolute_tolerance": 8.2e6
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), False)

def test_sig_figs_correct(self):
body = {
"response": 3.14159,
"answer": 3.14,
"params": {
"sig_figs": 3
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), True)

def test_sig_figs_incorrect(self):
body = {
"response": 3.15,
"answer": 3.14159,
"params": {
"sig_figs": 3
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), False)

def test_sig_figs_negative_numbers(self):
body = {
"response": -3.14159,
"answer": -3.14,
"params": {
"sig_figs": 3
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), True)

def test_sig_figs_zero_answer(self):
body = {
"response": 0,
"answer": 0,
"params": {
"sig_figs": 3
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), True)

def test_sig_figs_invalid_value(self):
body = {
"response": 3.14,
"answer": 3.14159,
"params": {
"sig_figs": 0
},
}

self.assertRaises(
Exception,
evaluation_function,
body["response"],
body["answer"],
body["params"],
)

def test_sig_figs_invalid_type(self):
body = {
"response": 3.14,
"answer": 3.14159,
"params": {
"sig_figs": 3.5
},
}

self.assertRaises(
Exception,
evaluation_function,
body["response"],
body["answer"],
body["params"],
)

def test_sig_figs_and_tolerance_mutually_exclusive(self):
body = {
"response": 3.14,
"answer": 3.14159,
"params": {
"sig_figs": 3,
"atol": 0.1
},
}

self.assertRaises(
Exception,
evaluation_function,
body["response"],
body["answer"],
body["params"],
)

def test_sig_figs_and_tolerance_mutually_exclusive_long_names(self):
body = {
"response": 3.14,
"answer": 3.14159,
"params": {
"significant_figures": 3,
"relative_tolerance": 0.1
},
}

self.assertRaises(
Exception,
evaluation_function,
body["response"],
body["answer"],
body["params"],
)

def test_sig_figs_synonym_key(self):
body = {
"response": 3.14159,
"answer": 3.14,
"params": {
"significant_figures": 3
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), True)

def test_sig_figs_non_numeric_response(self):
body = {
"response": "two",
"answer": 3.14,
"params": {
"sig_figs": 3
},
}

response = evaluation_function(body['response'], body['answer'],
body.get('params', {}))

self.assertEqual(response.get("is_correct"), False)
self.assertEqual("Please enter a number." in response.get("feedback"), True)

def test_sig_figs_non_numeric_answer(self):
body = {
"response": 3.14,
"answer": "pi",
"params": {
"sig_figs": 3
},
}

self.assertRaises(
Exception,
evaluation_function,
body["response"],
body["answer"],
body["params"],
)

if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# IsSimilar

This function checks whether a student's numeric response is within an acceptable tolerance of the correct answer, using absolute (`atol`) and relative (`rtol`) tolerance parameters. The comparison follows the formula: `|response - answer| ≤ atol + rtol × |answer|`. By default both tolerances are 0, requiring an exact match (within floating-point precision).
This function checks whether a student's numeric response matches the correct answer, using either absolute (`atol`) and relative (`rtol`) tolerance parameters, or a `sig_figs` significant-figures check. The tolerance comparison follows the formula: `|response - answer| ≤ atol + rtol × |answer|`. By default both tolerances are 0, requiring an exact match (within floating-point precision). Alternatively, `sig_figs` rounds both the response and answer to N significant figures and requires them to match; it cannot be combined with `atol`/`rtol`.

For more information, look at the docs in `app/docs/`.

Expand Down
Loading