shap--shap
548 行
21 KiB
Python
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
|