Files
lm-evaluation-harness/lm_eval/tasks/bbh/cot_zeroshot/utils.py
T
e54687eac0 fix(bbh): guard find_match against an all-empty capture tuple (#4125)
* fix(bbh): guard find_match against an all-empty capture tuple

`ExtendedRegexFilter.find_match` narrowed a capture tuple with
`[m for m in match if m][0]`, which raises IndexError when every group in
the tuple is empty. A regex whose groups are all optional and whose matched
text lies outside them -- e.g. `x(foo)?(bar)?` against "x" -- makes
`findall` return `[("", "")]`, and the run aborts instead of falling
through to the filter's fallback.

`lm_eval/filters/extraction.py` already guards the same narrowing in
`MultiChoiceRegexFilter.find_match`; these two files are copies that
predate that fix. Adopt the upstream shape verbatim so an all-empty tuple
is treated as a non-match.

No shipped bbh task can currently reach this: every configured
`regex_pattern` has a single capture group, and the two-group regex
`MapRegexFilter` builds by joining its `regex_pattern_to_value` keys always
fills exactly one group when it matches. The crash is reachable from a
user-supplied filter config, so this is a latent fix -- no task's score
changes, and no `metadata.version` bump is warranted.

Co-Authored-By: Claude Opus 5 <[email protected]>

* chore(bbh): fix pre-existing ruff violations blocking Linters CI

The Linters job runs pre-commit with `--from-ref`/`--to-ref`, which scopes
to changed *files* rather than changed lines. Touching these two files
therefore surfaces every existing violation in them, so the preceding fix
cannot go green without clearing these first. Same situation, and same
remedy, as #3999.

Twelve violations, six mirrored in each file:

- `mutable-argument-default` (x4) -- `convert_dict={}` on `find_match` and
  `regex_pattern_to_value={}` on `MapRegexFilter.__init__`. These are not
  unrelated cleanup: `find_match` is the function the preceding commit
  fixes, and `lm_eval/filters/extraction.py` already carries exactly this
  change (`convert_dict: dict[str, str] | None = None` plus an in-body
  None check). Adopting it closes the rest of the same divergence.

- `empty-docstring` (x2) -- a vestigial `""" """` on `WordSortFilter`.
  Removed rather than filled; `D10` (undocumented-public-*) is ignored
  repo-wide, so no docstring is the expected state.

- `zip-without-explicit-strict` (x6) -- made explicit with `strict=False`,
  which is the pre-3.10 default and so preserves behaviour exactly.
  `extraction.py` uses `strict=True` for the corresponding
  `zip(resps, docs)`, but adopting that here would change behaviour on a
  length mismatch (silent truncation becomes a raised error). That is a
  real question and deserves its own change, not a smuggled one inside a
  lint commit.

No behaviour changes. Both files remain byte-identical to each other.

Co-Authored-By: Claude Opus 5 <[email protected]>

* nits

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Baber Abbasi <[email protected]>
2026-09-14 15:48:32 +05:00

232 lines
9.1 KiB
Python

import re
import sys
import unicodedata
from lm_eval.filters.extraction import Filter, RegexFilter
class ExtendedRegexFilter(RegexFilter):
punct_tbl = dict.fromkeys(
i for i in range(sys.maxunicode) if unicodedata.category(chr(i)).startswith("P")
)
def __init__(
self,
regex_pattern: str = r"#### (\-?[0-9\.\,]+)",
group_select=0,
fallback: str = "[invalid]",
ignore_case=False,
ignore_punctuation=False,
regexes_to_ignore=None,
) -> None:
super().__init__(regex_pattern, group_select, fallback)
self.ignore_case = ignore_case
self.ignore_punctuation = ignore_punctuation
self.regexes_to_ignore = regexes_to_ignore
def filter_ignores(self, st):
if self.regexes_to_ignore is not None:
for s in self.regexes_to_ignore:
st = re.sub(s, "", st)
if self.ignore_case:
st = st.lower()
if self.ignore_punctuation:
# https://stackoverflow.com/a/266162
st = st.translate(self.punct_tbl)
return st
def find_match(self, regex, resp, convert_dict: dict[str, str] | None = None):
if convert_dict is None:
convert_dict = {}
match = regex.findall(resp)
if match:
match = match[self.group_select]
if isinstance(match, tuple):
non_empty = [m for m in match if m]
if not non_empty:
return ""
match = non_empty[0]
match = match.strip()
if match and match in convert_dict:
match = convert_dict[match]
return match
class MapRegexFilter(ExtendedRegexFilter):
def __init__(
self,
regex_pattern_to_value: dict | None = None,
group_select=0,
fallback: str = "[invalid]",
ignore_case=False,
ignore_punctuation=False,
regexes_to_ignore=None,
) -> None:
"""Map regex matches to fixed values.
regex_pattern_to_value: Match the regex pattern and change the result into the value
group_select: Selects the (group_select)th match from the findall result. We use the whole regex_patterns, concatenated by |
ignore_case: Lowers the case of response before matching with the given regex
ignore_punctuation: Remove the punctuation before matching with the given regex
regexes_to_ignore: Remove these regexes before matching with the given regex
"""
if regex_pattern_to_value is None:
regex_pattern_to_value = {}
super().__init__(
"|".join(list(regex_pattern_to_value.keys())),
group_select,
fallback,
ignore_case,
ignore_punctuation,
regexes_to_ignore,
)
self.regex_to_value = {
re.compile(r): v for r, v in regex_pattern_to_value.items()
}
def apply(self, resps, docs):
filtered_resps = []
for r in resps:
filtered = []
for resp in r:
whole_match_considering_group_select = self.find_match(
self.regex, self.filter_ignores(resp)
)
if whole_match_considering_group_select:
for regex, mapped_value in self.regex_to_value.items():
match = self.find_match(
regex,
self.filter_ignores(whole_match_considering_group_select),
)
if match:
match = mapped_value
break
if not whole_match_considering_group_select or not match:
match = self.fallback
filtered.append(match)
filtered_resps.append(filtered)
return filtered_resps
class NumberParseRegexFilter(ExtendedRegexFilter):
def apply(self, resps, docs):
# here, we assume we have a list, in which each element is
# a list of model responses for some particular input/target pair.
# so we process each of these (same input/target response sets)
# independently (and keep them a list.)
filtered_resps = []
import regex
from word2number import w2n
# https://www.reddit.com/r/regex/comments/11a38uk/parsing_numbers_written_out_as_english_words
english_number_regex = regex.compile(
"((?:(?:zero|one|two|three|four|five|(?:twen|thir|for|fif|six|seven|nine)(?|teen|ty)|eight(?:|een|y)|ten|eleven|twelve|fourteen|hundred|thousand|(?:m|b|tr)illion)(?:zero|one|two|three|four|five|(?:twen|thir|for|fif|six|seven|nine)(?:|teen|ty)|eight(?|een|y)|ten|eleven|twelve|fourteen|hundred|thousand|(?:m|b|tr)illion|[^\\S\r\n]|,|and|&)+)?(?:zero|one|two|three|four|five|(?:twen|thir|for|fif|six|seven|nine)(?|teen|ty)|eight(?|een|y)|ten|eleven|twelve|fourteen|hundred|thousand|(?:m|b|tr)illion))"
)
for r in resps:
filtered = []
for resp in r:
match = self.find_match(self.regex, resp)
if not match:
match = self.find_match(english_number_regex, resp.lower())
if match:
match = str(w2n.word_to_num(match))
if not match:
match = self.fallback
filtered.append(match)
filtered_resps.append(filtered)
return filtered_resps
class WordSortFilter(Filter):
def apply(self, resps, docs):
filtered_resps = []
for r, doc in zip(resps, docs, strict=True):
words = doc["input"].split("List:")[1].strip().split()
regex = re.compile("|".join([f"\\b{w}\\b" for w in words]))
filtered = []
for resp in r:
match = regex.findall(resp)
ordered_words = reversed(dict.fromkeys(reversed(match)))
filtered.append(" ".join(ordered_words))
filtered_resps.append(filtered)
return filtered_resps
class MultiChoiceRegexFilter(ExtendedRegexFilter):
def __init__(self, *args, **kwargs):
r"""Extract a multiple-choice answer letter from a response.
regex_pattern: The basic regex pattern to use. If fails to match, we will use the customized match procedure
- step 1 : We parse the choices between ([A-Z])s then try to find these choices in the response.
- step 2 : We parse the choice with regex :[\s]*([A-?]), where ? varies by number of choices.
group_select: Selects the (group_select)th match from the findall result.
ignore_case: Ignores the case during step 1 matching
ignore_punctuation: Remove the punctuation during step 1 matching
regexes_to_ignore: Remove these regexes during step 1 matching
"""
super().__init__(*args, **kwargs)
def apply(self, resps, docs):
# here, we assume we have a list, in which each element is
# a list of model responses for some particular input/target pair.
# so we process each of these (same input/target response sets)
# independently (and keep them a list.)
filtered_resps = []
for r, doc in zip(resps, docs, strict=False):
fallback_regexes = []
choice_to_alpha = {}
next_alpha = "A"
without_paren_fallback_regexes = []
without_paren_to_target = {}
multiple_choices_regex = re.compile(r"\([A-Z]\)([^\n^(]*)")
match = multiple_choices_regex.findall(doc["input"])
for m in match:
m = self.filter_ignores(m.strip())
fallback_regexes.append(f"{re.escape(m)}")
choice_to_alpha[m] = f"({next_alpha})"
without_paren_fallback_regexes.append(next_alpha)
without_paren_to_target[next_alpha] = f"({next_alpha})"
next_alpha = chr(ord(next_alpha) + 1)
# Longest-first, so a choice that is a prefix of another cannot shadow it
# via leftmost-wins alternation. Mirrors lm_eval/filters/extraction.py.
fallback_regex = re.compile(
"|".join(sorted(fallback_regexes, key=len, reverse=True))
)
without_paren_fallback_regex = "|".join(without_paren_fallback_regexes)
without_paren_fallback_regex = re.compile(
rf":[\s]*({without_paren_fallback_regex})"
)
filtered = []
for resp in r:
match = self.find_match(self.regex, resp)
if not match:
match = self.find_match(
fallback_regex, self.filter_ignores(resp), choice_to_alpha
)
if not match:
match = self.find_match(
without_paren_fallback_regex, resp, without_paren_to_target
)
if not match:
match = self.fallback
filtered.append(match)
filtered_resps.append(filtered)
return filtered_resps