项目文件夹

文件
2026-07-13 13:22:52 +08:00

548 行
21 KiB
Python

import math
import re
import numpy as np
from .._serializable import Deserializer, Serializer
from ..utils import safe_isinstance
from ..utils.transformers import (
SENTENCEPIECE_TOKENIZERS,
getattr_silent,
parse_prefix_suffix_for_tokenizer,
)
from ._masker import Masker
class Text(Masker):
"""This masks out tokens according to the given tokenizer.
The masked variables are
output_type : "string" (default) or "token_ids"
"""
def __init__(self, tokenizer=None, mask_token=None, collapse_mask_token="auto", output_type="string"):
"""Build a new Text masker given an optional passed tokenizer.
Parameters
----------
tokenizer : callable or None
The tokenizer used to break apart strings during masking. The passed tokenizer must support a minimal
subset of the HuggingFace Transformers PreTrainedTokenizerBase API. This minimal subset means the
tokenizer must return a dictionary with 'input_ids' and then either include
an 'offset_mapping' entry in the same dictionary or provide a .convert_ids_to_tokens or .decode method.
mask_token : string, int, or None
The sub-string or integer token id used to mask out portions of a string. If None it will use the
tokenizer's .mask_token attribute, if defined, or "..." if the tokenizer does not have a .mask_token
attribute.
collapse_mask_token : True, False, or "auto"
If True, when several consecutive tokens are masked only one mask token is used to replace the entire
series of original tokens.
"""
if tokenizer is None:
self.tokenizer = SimpleTokenizer()
elif callable(tokenizer):
self.tokenizer = tokenizer
else:
try:
self.tokenizer = SimpleTokenizer(tokenizer)
except Exception:
raise Exception(
"The passed tokenizer cannot be wrapped as a masker because it does not have a __call__ "
"method, not can it be interpreted as a splitting regexp!"
)
self.output_type = output_type
self.collapse_mask_token = collapse_mask_token
self.input_mask_token = mask_token
self.mask_token = mask_token # could be recomputed later in this function
self.mask_token_id = mask_token if isinstance(mask_token, int) else None
parsed_tokenizer_dict = parse_prefix_suffix_for_tokenizer(self.tokenizer)
self.keep_prefix = parsed_tokenizer_dict["keep_prefix"]
self.keep_suffix = parsed_tokenizer_dict["keep_suffix"]
# self.prefix_strlen = parsed_tokenizer_dict['prefix_strlen']
# self.suffix_strlen = parsed_tokenizer_dict['suffix_strlen']
# null_tokens = parsed_tokenizer_dict['null_tokens']
self.text_data = True
if mask_token is None:
if getattr_silent(self.tokenizer, "mask_token") is not None:
self.mask_token = self.tokenizer.mask_token
self.mask_token_id = getattr_silent(self.tokenizer, "mask_token_id")
if self.collapse_mask_token == "auto":
self.collapse_mask_token = False
else:
self.mask_token = "..."
else:
self.mask_token = mask_token
if self.mask_token_id is None:
self.mask_token_id = self.tokenizer(self.mask_token)["input_ids"][self.keep_prefix]
if self.collapse_mask_token == "auto":
self.collapse_mask_token = True
# assign mask token segment
# if self.keep_suffix > 0:
# self.mask_token_segment = self.token_segments(self.mask_token)[self.keep_prefix:-self.keep_suffix]
# else:
# self.mask_token_segment = self.token_segments(self.mask_token)[self.keep_prefix:]
# note if this masker can use a different background for different samples
self.fixed_background = self.mask_token_id is None
self.default_batch_size = 5
# cache variables
self._s = None
self._tokenized_s_full = None
self._tokenized_s = None
self._segments_s = None
# flag that we return outputs that will not get changed by later masking calls
self.immutable_outputs = True
def __call__(self, mask, s):
mask = self._standardize_mask(mask, s)
self._update_s_cache(s)
# if we have a fixed prefix or suffix then we need to grow the mask to account for that
if self.keep_prefix > 0:
mask = mask.copy()
mask[: self.keep_prefix] = True
if self.keep_suffix > 0:
mask = mask.copy()
mask[-self.keep_suffix :] = True
if self.output_type == "string":
# if self.mask_token == "":
# out = self._segments_s[mask]
# else:
# #out = np.array([self._segments_s[i] if mask[i] else self.mask_token for i in range(len(mask))])
out_parts = []
is_previous_appended_token_mask_token = False
sep_token = getattr_silent(self.tokenizer, "sep_token")
for i, v in enumerate(mask):
# mask ignores separator tokens and keeps them unmasked
if v or sep_token == self._segments_s[i]:
out_parts.append(self._segments_s[i])
is_previous_appended_token_mask_token = False
else:
if not self.collapse_mask_token or (
self.collapse_mask_token and not is_previous_appended_token_mask_token
):
out_parts.append(" " + self.mask_token)
is_previous_appended_token_mask_token = True
out = "".join(out_parts)
# tokenizers which treat spaces like parts of the tokens and dont replace the special token while decoding need further postprocessing
# by replacing whitespace encoded as '_' for sentencepiece tokenizer or 'Ġ' for sentencepiece like encoding (GPT2TokenizerFast)
# with ' '
if safe_isinstance(self.tokenizer, SENTENCEPIECE_TOKENIZERS):
out = out.replace("▁", " ")
# replace sequence of spaces with a single space and strip beginning and end spaces
out = re.sub(
r"[\s]+", " ", out
).strip() # TODOmaybe: should do strip?? (originally because of fast vs. slow tokenizer differences)
else:
if self.mask_token_id is None:
out = self._tokenized_s[mask]
else:
out = np.array([self._tokenized_s[i] if mask[i] else self.mask_token_id for i in range(len(mask))])
# print("mask len", len(out))
# # crop the output if needed
# if self.max_length is not None and len(out) > self.max_length:
# new_out = np.zeros(self.max_length)
# new_out[:] = out[:self.max_length]
# new_out[-self.keep_suffix:] = out[-self.keep_suffix:]
# out = new_out
# for some sentences with strange configurations around the separator tokens, tokenizer encoding/decoding may contain
# extra unnecessary tokens, for example ''. you may want to strip out spaces adjacent to separator tokens. Refer to PR
# for more details.
return (np.array([out]),)
def data_transform(self, s):
"""Called by explainers to allow us to convert data to better match masking (here this means tokenizing)."""
return (self.token_segments(s)[0],)
def token_segments(self, s):
"""Returns the substrings associated with each token in the given string."""
try:
token_data = self.tokenizer(s, return_offsets_mapping=True)
offsets = token_data["offset_mapping"]
offsets = [(0, 0) if o is None else o for o in offsets]
parts = [s[offsets[i][0] : max(offsets[i][1], offsets[i + 1][0])] for i in range(len(offsets) - 1)]
parts.append(s[offsets[len(offsets) - 1][0] : offsets[len(offsets) - 1][1]])
return parts, token_data["input_ids"]
except (NotImplementedError, TypeError): # catch lack of support for return_offsets_mapping
token_ids = self.tokenizer(s)["input_ids"]
if hasattr(self.tokenizer, "convert_ids_to_tokens"):
tokens = self.tokenizer.convert_ids_to_tokens(token_ids)
else:
tokens = [self.tokenizer.decode([id]) for id in token_ids]
if hasattr(self.tokenizer, "get_special_tokens_mask"):
special_tokens_mask = self.tokenizer.get_special_tokens_mask(token_ids, already_has_special_tokens=True)
# avoid masking separator tokens, but still mask beginning of sentence and end of sentence tokens
special_keep = [
getattr_silent(self.tokenizer, "sep_token"),
getattr_silent(self.tokenizer, "mask_token"),
]
for i, v in enumerate(special_tokens_mask):
if v == 1 and (tokens[i] not in special_keep or i + 1 == len(special_tokens_mask)):
tokens[i] = ""
# add spaces to separate the tokens (since we want segments not tokens)
if safe_isinstance(self.tokenizer, SENTENCEPIECE_TOKENIZERS):
for i, v in enumerate(tokens):
if v.startswith("_"):
tokens[i] = " " + tokens[i][1:]
else:
for i, v in enumerate(tokens):
if v.startswith("##"):
tokens[i] = tokens[i][2:]
elif v != "" and i != 0:
tokens[i] = " " + tokens[i]
return tokens, token_ids
def clustering(self, s):
"""Compute the clustering of tokens for the given string."""
self._update_s_cache(s)
special_tokens = []
sep_token = getattr_silent(self.tokenizer, "sep_token")
if sep_token is None:
special_tokens = []
else:
special_tokens = [sep_token]
# convert the text segments to tokens that the partition tree function expects
tokens = []
space_end = re.compile(r"^.*\W$")
letter_start = re.compile(r"^[A-Za-z]")
for i, v in enumerate(self._segments_s):
if (
i > 0
and space_end.match(self._segments_s[i - 1]) is None
and letter_start.match(v) is not None
and tokens[i - 1] != ""
):
tokens.append("##" + v.strip())
else:
tokens.append(v.strip())
pt = partition_tree(tokens, special_tokens)
# use the rescaled size of the clusters as their height since the merge scores are just a
# heuristic and not scaled well
pt[:, 2] = pt[:, 3]
pt[:, 2] /= pt[:, 2].max()
return pt
# unused because restricts meaningful perturbations
# def _mark_uninvertable(self, clustering):
# """ This marks which clusters have non-invertable mappings through the tokenizer when masked.
# It seems like a bug that you can decode and then encode a set of token ids and not get what
# you started with...but this is possible with word endings in the transformers implementation
# of BERT for example. So here we mark such uninvertable clusters with negative values.
# """
# M = len(self._tokenized_s)
# assert len(clustering)+1 == M
# def recursive_mark(ind):
# if ind < M:
# return list(self._tokenized_s[ind:ind+1])
# lind = int(clustering[ind-M, 0])
# rind = int(clustering[ind-M, 1])
# ltokens = recursive_mark(lind)
# rtokens = recursive_mark(rind)
# tmp = ltokens + [self.mask_token_id]
# s2 = self.tokenizer.decode(tmp)
# e2 = self.tokenizer.encode(s2)
# if not np.all(e2[1:-1] == tmp):
# clustering[ind-M, 2] = -1 # set the distance of this cluster negative so it can't be split
# tmp = [self.mask_token_id] + rtokens
# s2 = self.tokenizer.decode(tmp)
# e2 = self.tokenizer.encode(s2)
# if not np.all(e2[1:-1] == tmp):
# clustering[ind-M, 2] = -1 # set the distance of this cluster negative so it can't be split
# return ltokens + rtokens
# recursive_mark(M+len(clustering)-1)
def _update_s_cache(self, s):
if self._s != s:
self._s = s
tokens, token_ids = self.token_segments(s)
self._tokenized_s = np.array(token_ids)
self._segments_s = np.array(tokens)
def shape(self, s):
"""The shape of what we return as a masker.
Note we only return a single sample, so there is no expectation averaging.
"""
self._update_s_cache(s)
return (1, len(self._tokenized_s))
def mask_shapes(self, s):
"""The shape of the masks we expect."""
self._update_s_cache(s)
return [(len(self._tokenized_s),)]
def invariants(self, s):
"""The names of the features for each mask position for the given input string."""
self._update_s_cache(s)
invariants = np.zeros(len(self._tokenized_s), dtype=bool)
if self.keep_prefix > 0:
invariants[: self.keep_prefix] = True
if self.keep_suffix > 0:
invariants[-self.keep_suffix :] = True
# mark separator tokens as invariant
for i, v in enumerate(self._tokenized_s):
if v == getattr_silent(self.tokenizer, "sep_token_id"):
invariants[i] = True
return invariants.reshape(1, -1)
def feature_names(self, s):
"""The names of the features for each mask position for the given input string."""
self._update_s_cache(s)
return [[v.strip() for v in self._segments_s]]
def save(self, out_file):
"""Save a Text masker to a file stream."""
super().save(out_file)
with Serializer(out_file, "shap.maskers.Text", version=0) as s:
s.save("tokenizer", self.tokenizer)
s.save("mask_token", self.input_mask_token)
s.save("collapse_mask_token", self.collapse_mask_token)
s.save("output_type", self.output_type)
@classmethod
def load(cls, in_file, instantiate=True):
"""Load a Text masker from a file stream."""
if instantiate:
return cls._instantiated_load(in_file)
kwargs = super().load(in_file, instantiate=False)
with Deserializer(in_file, "shap.maskers.Text", min_version=0, max_version=0) as s:
kwargs["tokenizer"] = s.load("tokenizer")
kwargs["mask_token"] = s.load("mask_token")
kwargs["collapse_mask_token"] = s.load("collapse_mask_token")
kwargs["output_type"] = s.load("output_type")
return kwargs
class SimpleTokenizer:
"""A basic model agnostic tokenizer."""
def __init__(self, split_pattern=r"\W+"):
"""Create a tokenizer based on a simple splitting pattern."""
self.split_pattern = re.compile(split_pattern)
def __call__(self, s, return_offsets_mapping=True):
"""Tokenize the passed string, optionally returning the offsets of each token in the original string."""
pos = 0
offset_ranges = []
input_ids = []
for m in re.finditer(self.split_pattern, s):
start, end = m.span(0)
offset_ranges.append((pos, start))
input_ids.append(s[pos:start])
pos = end
if pos != len(s):
offset_ranges.append((pos, len(s)))
input_ids.append(s[pos:])
out = {}
out["input_ids"] = input_ids
if return_offsets_mapping:
out["offset_mapping"] = offset_ranges
return out
def post_process_sentencepiece_tokenizer_output(s):
"""Replaces whitespace encoded as '_' with ' ' for sentencepiece tokenizers."""
s = s.replace("▁", " ")
return s
openers = {"(": ")"}
closers = {")": "("}
enders = [".", ","]
connectors = ["but", "and", "or"]
class Token:
"""A token representation used for token clustering."""
def __init__(self, value):
self.s = value
if value in openers or value in closers:
self.balanced = False
else:
self.balanced = True
def __str__(self):
return self.s
def __repr__(self):
if not self.balanced:
return self.s + "!"
return self.s
class TokenGroup:
"""A token group (substring) representation used for token clustering."""
def __init__(self, group, index=None):
self.g = group
self.index = index
def __repr__(self):
return self.g.__repr__()
def __getitem__(self, index):
return self.g[index]
def __add__(self, o):
return TokenGroup(self.g + o.g)
def __len__(self):
return len(self.g)
def merge_score(group1, group2, special_tokens):
"""Compute the score of merging two token groups.
special_tokens: tokens (such as separator tokens) that should be grouped last
"""
score = 0
# ensures special tokens are combined last, so 1st subtree is 1st sentence and 2nd subtree is 2nd sentence
if len(special_tokens) > 0:
if group1[-1].s in special_tokens and group2[0].s in special_tokens:
score -= math.inf # subtracting infinity to create lowest score and ensure combining these groups last
# merge broken-up parts of words first
if group2[0].s.startswith("##"):
score += 20
# merge apostrophe endings next
if group2[0].s == "'" and (len(group2) == 1 or (len(group2) == 2 and group2[1].s in ["t", "s"])):
score += 15
if group1[-1].s == "'" and group2[0].s in ["t", "s"]:
score += 15
start_ctrl = group1[0].s.startswith("[") and group1[0].s.endswith("]")
end_ctrl = group2[-1].s.startswith("[") and group2[-1].s.endswith("]")
if (start_ctrl and not end_ctrl) or (end_ctrl and not start_ctrl):
score -= 1000
if group2[0].s in openers and not group2[0].balanced:
score -= 100
if group1[-1].s in closers and not group1[-1].balanced:
score -= 100
# attach surrounding an openers and closers a bit later
if group1[0].s in openers and group2[-1] not in closers:
score -= 2
# reach across connectors later
if group1[-1].s in connectors or group2[0].s in connectors:
score -= 2
# reach across commas later
if group1[-1].s == ",":
score -= 10
if group2[0].s == ",":
if len(group2) > 1: # reach across
score -= 10
else:
score -= 1
# reach across sentence endings later
if group1[-1].s in [".", "?", "!"]:
score -= 20
if group2[0].s in [".", "?", "!"]:
if len(group2) > 1: # reach across
score -= 20
else:
score -= 1
score -= len(group1) + len(group2)
# print(group1, group2, score)
return score
def merge_closest_groups(groups, special_tokens):
"""Finds the two token groups with the best merge score and merges them."""
scores = [merge_score(groups[i], groups[i + 1], special_tokens) for i in range(len(groups) - 1)]
# print(scores)
ind = np.argmax(scores)
groups[ind] = groups[ind] + groups[ind + 1]
# print(groups[ind][0].s in openers, groups[ind][0])
if groups[ind][0].s in openers and groups[ind + 1][-1].s == openers[groups[ind][0].s]:
groups[ind][0].balanced = True
groups[ind + 1][-1].balanced = True
groups.pop(ind + 1)
def partition_tree(decoded_tokens, special_tokens):
"""Build a heriarchial clustering of tokens that align with sentence structure.
Note that this is fast and heuristic right now.
TODO: Build this using a real constituency parser.
"""
token_groups = [TokenGroup([Token(t)], i) for i, t in enumerate(decoded_tokens)]
# print(token_groups)
M = len(decoded_tokens)
new_index = M
clustm = np.zeros((M - 1, 4))
for i in range(len(token_groups) - 1):
scores = [
merge_score(token_groups[i], token_groups[i + 1], special_tokens) for i in range(len(token_groups) - 1)
]
# print(scores)
ind = np.argmax(scores)
lind = token_groups[ind].index
rind = token_groups[ind + 1].index
clustm[new_index - M, 0] = token_groups[ind].index
clustm[new_index - M, 1] = token_groups[ind + 1].index
clustm[new_index - M, 2] = -scores[ind]
clustm[new_index - M, 3] = (clustm[lind - M, 3] if lind >= M else 1) + (clustm[rind - M, 3] if rind >= M else 1)
token_groups[ind] = token_groups[ind] + token_groups[ind + 1]
token_groups[ind].index = new_index
# track balancing of openers/closers
if token_groups[ind][0].s in openers and token_groups[ind + 1][-1].s == openers[token_groups[ind][0].s]:
token_groups[ind][0].balanced = True
token_groups[ind + 1][-1].balanced = True
token_groups.pop(ind + 1)
new_index += 1
# negative means we should never split a group, so we add 10 to ensure these are very tight groups
# (such as parts of the same word)
clustm[:, 2] = clustm[:, 2] + 10
return clustm