Repository: cleanlab/cleanlab

Issue:
color_sentence fails in tutorial notebook
<!-- Briefly summarize the issue. -->

In the notebook, `display_issues` highlights all token issues with a call to `color_sentence`:

https://github.com/cleanlab/cleanlab/blob/1a239922fe195d2a6104d6dc3552d53da16380ce/docs/source/tutorials/token_classification.ipynb?short_path=2ebceca#L369-L379

One of the examples trips everything up with the following error:
```
missing ), unterminated subpattern at position 2
```


# Stack trace

From [failed CI job](https://github.com/cleanlab/cleanlab/actions/runs/2996555945):
<details><summary> Click to toggle stack trace</summary>

```bash
---------------------------------------------------------------------------
error                                     Traceback (most recent call last)
Input In [12], in <module>
----> 1 display_issues(issues,given_words,pred_probs=pred_probs,given_labels=labels,
      2 exclude=[(0,1),(1,0)],class_names=merged_entities)

File ~/work/cleanlab/cleanlab/cleanlab/token_classification/summary.py:81, in display_issues(issues, given_words, pred_probs, given_labels, exclude, class_names, top)
     78     given = class_names[given]
     80 shown += 1
---> 81 print("Sentence %d, token %d: \n%s" % (i, j, color_sentence(sentence,word)))
     82 if given_labels and not pred_probs:
     83     print("Given label: %s\n" % str(given))

File ~/work/cleanlab/cleanlab/cleanlab/internal/token_classification_utils.py:175, in color_sentence(sentence, word)
    158 """
    159 Searches for a given token in the sentence and returns the sentence where the given token is colored red
    160 
   (...)
    172 
    173 """
    174 colored_word = colored(word, "red")
--> 175 colored_sentence, number_of_substitions = re.subn(
    176 r"\b{}\b".format(word),colored_word,sentence
    177 )
    178 if number_of_substitions == 0:
    179     # Use basic string manipulation if regex fails
    180     colored_sentence = sentence.replace(word, colored_word)

File /opt/hostedtoolcache/Python/3.8.13/x64/lib/python3.8/re.py:221, in subn(pattern, repl, string, count, flags)
    212 def subn(pattern, repl, string, count=0, flags=0):
    213     """Return a 2-tuple containing (new_string, number).
    214     new_string is the string obtained by replacing the leftmost
    215     non-overlapping occurrences of the pattern in the source
   (...)
    219     If it is a callable, it's passed the Match object and must
    220     return a replacement string to be used."""
--> 221     return _compile(pattern,flags).subn(repl, string, count)

File /opt/hostedtoolcache/Python/3.8.13/x64/lib/python3.8/re.py:304, in _compile(pattern, flags)
    302 if not sre_compile.isstring(pattern):
    303     raise TypeError("first argument must be string or compiled pattern")
--> 304 p = sre_compile.compile(pattern,flags)
    305 if not (flags & DEBUG):
    306     if len(_cache) >= _MAXCACHE:
    307         # Drop the oldest item

File /opt/hostedtoolcache/Python/3.8.13/x64/lib/python3.8/sre_compile.py:764, in compile(p, flags)
    762 if isstring(p):
    763     pattern = p
--> 764     p = sre_parse.parse(p,flags)
    765 else:
    766     pattern = None

File /opt/hostedtoolcache/Python/3.8.13/x64/lib/python3.8/sre_parse.py:948, in parse(str, flags, state)
    945 state.str = str
    947 try:
--> 948     p = _parse_sub(source,state,flags&SRE_FLAG_VERBOSE,0)
    949 except Verbose:
    950     # the VERBOSE flag was switched on inside the pattern.  to be
    951     # on the safe side, we'll parse the whole thing again...
    952     state = State()

File /opt/hostedtoolcache/Python/3.8.13/x64/lib/python3.8/sre_parse.py:443, in _parse_sub(source, state, verbose, nested)
    441 start = source.tell()
    442 while True:
--> 443     itemsappend(_parse(source,state,verbose,nested+1,
    444 notnestedandnotitems))
    445     if not sourcematch("|"):
    446         break

File /opt/hostedtoolcache/Python/3.8.13/x64/lib/python3.8/sre_parse.py:836, in _parse(source, state, verbose, nested, first)
    834 p = _parse_sub(source, state, sub_verbose, nested + 1)
    835 if not source.match(")"):
--> 836     raise source.error("missing ), unterminated subpattern",
    837                        source.tell() - start)
    838 if group is not None:
    839     state.closegroup(group, p)

error: missing ), unterminated subpattern at position 2
```

</details>



Discussion:
This is the example that returns an error in `color_sentence`.

```
# Sentence
1990 - British historian Alan John Percivale (A.j.p.) Taylor died.

# Token
(
```
Looks like `"("`(`word`) isn't escaped properly in the call to `re.subn`:

https://github.com/cleanlab/cleanlab/blob/1a239922fe195d2a6104d6dc3552d53da16380ce/cleanlab/internal/token_classification_utils.py#L175-L177

Resolving patch:
diff --git a/cleanlab/internal/token_classification_utils.py b/cleanlab/internal/token_classification_utils.py
--- a/cleanlab/internal/token_classification_utils.py
+++ b/cleanlab/internal/token_classification_utils.py
@@ -173,7 +173,7 @@ def color_sentence(sentence: str, word: str) -> str:
     """
     colored_word = colored(word, "red")
     colored_sentence, number_of_substitions = re.subn(
-        r"\b{}\b".format(word), colored_word, sentence
+        r"\b{}\b".format(re.escape(word)), colored_word, sentence
     )
     if number_of_substitions == 0:
         # Use basic string manipulation if regex fails


Test patch:
diff --git a/tests/test_token_classification.py b/tests/test_token_classification.py
--- a/tests/test_token_classification.py
+++ b/tests/test_token_classification.py
@@ -141,6 +141,11 @@ def test_merge_probs_with_normalization():
         ("A good reason for a test", "a", f"A good reason for {C_L}a{C_R} test"),
         ("ab ab a b ab", "ab a", f"ab {C_L}ab a{C_R} b ab"),
         ("ab ab ab ab", "ab a", f"{C_L}ab a{C_R}b {C_L}ab a{C_R}b"),
+        (
+            "Alan John Percivale (A.j.p.) Taylor died",
+            "(",
+            f"Alan John Percivale {C_L}({C_R}A.j.p.) Taylor died",
+        ),
     ],
     ids=[
         "single_word",
@@ -151,6 +156,7 @@ def test_merge_probs_with_normalization():
         "case_sensitive",
         "only_word_boundary",
         "non_overlapping_substrings",
+        "issue_403-escape_special_regex_characters",
     ],
 )
 def test_color_sentence(sentence, word, expected):