| """ |
| Answer checker API that uses sympy to simplify expressions and check for equality. |
| |
| Call grade_answer(given_answer: str, ground_truth: str). |
| """ |
| import re |
| import sympy |
| from pylatexenc import latex2text |
| from sympy.parsing import sympy_parser |
|
|
| from .math_normalize import normalize_answer |
|
|
|
|
| |
| BAD_SUBSTRINGS = ["^{", "^("] |
| BAD_REGEXES = ["\^[0-9]+\^", "\^[0-9][0-9]+"] |
| TUPLE_CHARS = "()[]" |
|
|
|
|
| def _sympy_parse(expr: str): |
| """Parses an expression with sympy.""" |
| py_expr = expr.replace("^", "**") |
| return sympy_parser.parse_expr( |
| py_expr, |
| transformations=( |
| sympy_parser.standard_transformations |
| + (sympy_parser.implicit_multiplication_application,) |
| ), |
| ) |
|
|
|
|
| def _parse_latex(expr: str) -> str: |
| """Attempts to parse latex to an expression sympy can read.""" |
| expr = expr.replace("\\tfrac", "\\frac") |
| expr = expr.replace("\\dfrac", "\\frac") |
| expr = expr.replace("\\frac", " \\frac") |
| expr = latex2text.LatexNodes2Text().latex_to_text(expr) |
|
|
| |
| expr = expr.replace("√", "sqrt") |
| expr = expr.replace("π", "pi") |
| expr = expr.replace("∞", "inf") |
| expr = expr.replace("∪", "U") |
| expr = expr.replace("·", "*") |
| expr = expr.replace("×", "*") |
|
|
| return expr.strip() |
|
|
|
|
| def _is_float(num: str) -> bool: |
| try: |
| float(num) |
| return True |
| except ValueError: |
| return False |
|
|
|
|
| def _is_int(x: float) -> bool: |
| try: |
| return abs(x - int(round(x))) <= 1e-7 |
| except: |
| return False |
|
|
|
|
| def _is_frac(expr: str) -> bool: |
| return bool(re.search(r"^-?[0-9]+.?/0*[1-9][0-9]*.?$", expr)) |
|
|
|
|
| def _str_is_int(x: str) -> bool: |
| try: |
| x = _strip_properly_formatted_commas(x) |
| x = float(x) |
| return abs(x - int(round(x))) <= 1e-7 |
| except: |
| return False |
|
|
|
|
| def _str_to_int(x: str) -> bool: |
| x = x.replace(",", "") |
| x = float(x) |
| return int(x) |
|
|
|
|
| def _inject_implicit_mixed_number(step: str): |
| """ |
| Automatically make a mixed number evalable |
| e.g. 7 3/4 => 7+3/4 |
| """ |
| p1 = re.compile("([0-9]) +([0-9])") |
| step = p1.sub("\\1+\\2", step) |
| return step |
|
|
|
|
| def _strip_properly_formatted_commas(expr: str): |
| |
| p1 = re.compile("(\d)(,)(\d\d\d)($|\D)") |
| while True: |
| next_expr = p1.sub("\\1\\3\\4", expr) |
| if next_expr == expr: |
| break |
| expr = next_expr |
| return next_expr |
|
|
|
|
| def _normalize(expr: str) -> str: |
| """Normalize answer expressions.""" |
| if expr is None: |
| return None |
|
|
| |
| m = re.search("^\\\\text\{(?P<text>.+?)\}$", expr) |
| if m is not None: |
| expr = m.group("text") |
|
|
| expr = expr.replace("\\%", "%") |
| expr = expr.replace("\\$", "$") |
| expr = expr.replace("$", "") |
| expr = expr.replace("%", "") |
| expr = expr.replace(" or ", " , ") |
| expr = expr.replace(" and ", " , ") |
|
|
| expr = expr.replace("million", "*10^6") |
| expr = expr.replace("billion", "*10^9") |
| expr = expr.replace("trillion", "*10^12") |
|
|
| for unit in [ |
| "degree", |
| "cm", |
| "centimeter", |
| "meter", |
| "mile", |
| "second", |
| "minute", |
| "hour", |
| "day", |
| "week", |
| "month", |
| "year", |
| "foot", |
| "feet", |
| "inch", |
| "yard", |
| ]: |
| expr = re.sub(f"{unit}(es)?(s)? *(\^[0-9]+)?", "", expr) |
| expr = re.sub(f"\^ *\\\\circ", "", expr) |
|
|
| if len(expr) > 0 and expr[0] == "{" and expr[-1] == "}": |
| expr = expr[1:-1] |
|
|
| expr = re.sub(",\\\\! *", "", expr) |
| if _is_float(expr) and _is_int(float(expr)): |
| expr = str(int(round(float(expr)))) |
| if "\\" in expr: |
| try: |
| expr = _parse_latex(expr) |
| except: |
| pass |
|
|
| |
| expr = re.sub("- *", "-", expr) |
|
|
| expr = _inject_implicit_mixed_number(expr) |
| expr = expr.replace(" ", "") |
|
|
| |
| expr = expr.replace("{", "") |
| expr = expr.replace("}", "") |
|
|
| |
| expr = expr.lower() |
|
|
| if _str_is_int(expr): |
| expr = str(_str_to_int(expr)) |
|
|
| return expr |
|
|
|
|
| def count_unknown_letters_in_expr(expr: str): |
| expr = expr.replace("sqrt", "") |
| expr = expr.replace("frac", "") |
| letters_in_expr = set([x for x in expr if x.isalpha()]) |
| return len(letters_in_expr) |
|
|
|
|
| def should_allow_eval(expr: str): |
| |
| if count_unknown_letters_in_expr(expr) > 2: |
| return False |
|
|
| for bad_string in BAD_SUBSTRINGS: |
| if bad_string in expr: |
| return False |
|
|
| for bad_regex in BAD_REGEXES: |
| if re.search(bad_regex, expr) is not None: |
| return False |
|
|
| return True |
|
|
|
|
| def are_equal_under_sympy(ground_truth_normalized: str, given_normalized: str): |
| are_equal = False |
| try: |
| expr = f"({ground_truth_normalized})-({given_normalized})" |
| if should_allow_eval(expr): |
| sympy_diff = _sympy_parse(expr) |
| simplified = sympy.simplify(sympy_diff) |
| if simplified == 0: |
| are_equal = True |
| except: |
| pass |
| return are_equal |
|
|
|
|
| def split_tuple(expr: str): |
| """ |
| Split the elements in a tuple/interval, while handling well-formatted commas in large numbers |
| """ |
| expr = _strip_properly_formatted_commas(expr) |
| if len(expr) == 0: |
| return [] |
| if ( |
| len(expr) > 2 |
| and expr[0] in TUPLE_CHARS |
| and expr[-1] in TUPLE_CHARS |
| and all([ch not in expr[1:-1] for ch in TUPLE_CHARS]) |
| ): |
| elems = [elem.strip() for elem in expr[1:-1].split(",")] |
| else: |
| elems = [expr] |
| return elems |
|
|
|
|
| def grade_answer(given_answer: str, ground_truth: str) -> bool: |
| """ |
| The answer will be considered correct if: |
| (a) it normalizes to the same string as the ground truth answer |
| OR |
| (b) sympy can simplify the difference between the expressions to 0 |
| """ |
| if given_answer is None: |
| return False |
|
|
| ground_truth_normalized_mathd = normalize_answer(ground_truth) |
| given_answer_normalized_mathd = normalize_answer(given_answer) |
|
|
| |
| if ground_truth_normalized_mathd == given_answer_normalized_mathd: |
| return True |
|
|
| ground_truth_normalized = _normalize(ground_truth) |
| given_normalized = _normalize(given_answer) |
|
|
| if ground_truth_normalized is None: |
| return False |
|
|
| if ground_truth_normalized == given_normalized: |
| return True |
|
|
| if len(given_normalized) == 0: |
| return False |
|
|
| ground_truth_elems = split_tuple(ground_truth_normalized) |
| given_elems = split_tuple(given_normalized) |
|
|
| if len(ground_truth_elems) > 1 and ( |
| ground_truth_normalized[0] != given_normalized[0] |
| or ground_truth_normalized[-1] != given_normalized[-1] |
| ): |
| is_correct = False |
| elif len(ground_truth_elems) != len(given_elems): |
| is_correct = False |
| else: |
| for ground_truth_elem, given_elem in zip(ground_truth_elems, given_elems): |
| if _is_frac(ground_truth_elem) and _is_frac(given_elem): |
| |
| |
| is_correct = ground_truth_elem == given_elem |
| elif _str_is_int(ground_truth_elem) != _str_is_int(given_elem): |
| |
| is_correct = False |
| else: |
| is_correct = are_equal_under_sympy(ground_truth_elem, given_elem) |
| if not is_correct: |
| break |
|
|
| return is_correct |