mirror of
https://github.com/EleutherAI/lm-evaluation-harness.git
synced 2026-10-12 02:02:05 +00:00
* 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]>
232 lines
9.1 KiB
Python
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
|