Files
2026-07-13 13:22:52 +08:00

248 lines
9.8 KiB
Python

from __future__ import annotations
import numpy as np
from .._serializable import Deserializer, Serializer
from ..utils import safe_isinstance
from ._model import Model
class TextGeneration(Model):
"""Generates target sentence/ids using a base model.
It generates target sentence/ids for a model (a pretrained transformer model or a function).
"""
def __init__(self, model=None, tokenizer=None, target_sentences=None, device=None):
"""Create a text generator model from a pretrained transformer model or a function.
For a pretrained transformer model, a tokenizer should be passed.
Parameters
----------
model: object or function
A object of any pretrained transformer model or function for which target sentence/ids are to be generated.
tokenizer: object
A tokenizer object(PreTrainedTokenizer/PreTrainedTokenizerFast) which is used to tokenize sentence.
target_sentences: list
A target sentence for every explanation row.
device: str
By default, it infers if system has a gpu and accordingly sets device. Should be 'cpu' or 'cuda' or pytorch models.
Returns
-------
numpy.ndarray
Array of target sentence/ids.
"""
super().__init__(model)
self.explanation_row = 0
if target_sentences is not None:
self.inner_model = lambda _: np.array([target_sentences[self.explanation_row]])
self.tokenizer = tokenizer
self.device = device
if self.device is None:
self.device = getattr(self.inner_model, "device", None)
self.model_type: str | None # Type hint for mypy
if safe_isinstance(model, "transformers.PreTrainedModel"):
self.model_agnostic = False
self.model_type = "pt"
elif safe_isinstance(model, "transformers.TFPreTrainedModel"):
self.model_agnostic = False
self.model_type = "tf"
else:
self.model_agnostic = True
self.model_type = None
# X is input used to generate target sentence used for caching
self.X = None
# target sentence/ids generated from the model using X
self.target_X = None
def __call__(self, X):
"""Generates target sentence/ids from X.
Parameters
----------
X: str or numpy.ndarray
Input in the form of text or image.
Returns
-------
numpy.ndarray
Array of target sentence/ids.
"""
if (
(self.X is None)
or (isinstance(self.X, np.ndarray) and not np.array_equal(self.X, X))
or (isinstance(self.X, str) and (self.X != X))
):
self.X = X
# wrap text input in a numpy array
if isinstance(X, str):
X = np.array([X])
# generate target sentence ids in model agnostic scenario
if self.model_agnostic:
self.target_X = self.inner_model(X)
else:
self.target_X = self.model_generate(X)
# update explanation row count
self.explanation_row += 1
return np.array(self.target_X)
def get_inputs(self, X, padding_side="right"):
"""The function tokenizes source sentences.
In model agnostic case, the function calls model(X) which is expected to
return a batch of output sentences which is tokenized to compute inputs.
Parameters
----------
X: numpy.ndarray
X is a batch of sentences.
Returns
-------
dict
Dictionary of padded source sentence ids and attention mask as tensors("pt" or "tf" based on model_type).
"""
# set tokenizer padding to prepare inputs for batch inferencing
# padding_side="left" for only decoder models text generation eg. GPT2
self.tokenizer.padding_side = padding_side
inputs = self.tokenizer(X.tolist(), return_tensors=self.model_type, padding=True)
# set tokenizer padding to default
self.tokenizer.padding_side = "right"
return inputs
def model_generate(self, X):
"""This function performs text generation for tensorflow and pytorch models.
Parameters
----------
X: dict
Dictionary of padded source sentence ids and attention mask as tensors.
Returns
-------
numpy.ndarray
Returns target sentence ids.
"""
if (
hasattr(self.inner_model.config, "is_encoder_decoder") and not self.inner_model.config.is_encoder_decoder
) and (hasattr(self.inner_model.config, "is_decoder") and not self.inner_model.config.is_decoder):
pass
# TODOmaybe: Is this okay? I am just assuming we want is_decoder when neither are set
# self.inner_model.config.is_decoder = True
# raise ValueError(
# "Please assign either of is_encoder_decoder or is_decoder to True in model config for extracting target sentence ids"
# )
# check if user assigned any text generation specific kwargs
text_generation_params = {}
if (
self.inner_model.config.__dict__.get("task_specific_params") is not None
and self.inner_model.config.task_specific_params.get("text-generation") is not None
):
text_generation_params = self.inner_model.config.task_specific_params["text-generation"]
if not isinstance(text_generation_params, dict):
raise ValueError(
"Please assign text generation params as a dictionary under task_specific_params with key 'text-generation' "
)
# remove keys that are overridden by params on the model itself
# (this is to mimic how precedence works for transformers pipelines)
for k in list(text_generation_params.keys()):
if hasattr(self.inner_model.config, k):
del text_generation_params[k]
if self.model_type == "pt":
# create torch tensors and move to device
# TODOmaybe: SML: why move the model from where it was? the could mess with the user env (i.e. it breaks pipelines)
# device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') if self.device is None else self.device
# self.inner_model = self.inner_model.to(device)
# self.inner_model.eval()
import torch
with torch.no_grad():
if self.inner_model.config.is_encoder_decoder:
inputs = self.get_inputs(X)
else:
inputs = self.get_inputs(X, padding_side="left")
if self.device is not None:
inputs = inputs.to(self.device)
outputs = self.inner_model.generate(**inputs, **text_generation_params).detach().cpu().numpy()
elif self.model_type == "tf":
if self.inner_model.config.is_encoder_decoder:
inputs = self.get_inputs(X)
else:
inputs = self.get_inputs(X, padding_side="left")
if self.device is None:
outputs = self.inner_model.generate(inputs, **text_generation_params).numpy()
else:
try:
import tensorflow as tf
with tf.device(self.device):
outputs = self.inner_model.generate(inputs, **text_generation_params).numpy()
except RuntimeError as err:
print(err)
if getattr(self.inner_model.config, "is_decoder", True):
# slice the output ids after the input ids
outputs = outputs[:, inputs["input_ids"].shape[1] :]
# parse output ids to find special tokens in prefix and suffix
parsed_tokenizer_dict = self.parse_prefix_suffix_for_model_generate_output(outputs[0, :].tolist())
keep_prefix, keep_suffix = parsed_tokenizer_dict["keep_prefix"], parsed_tokenizer_dict["keep_suffix"]
# extract target sentence ids by slicing off prefix and suffix
if keep_suffix > 0:
target_X = outputs[:, keep_prefix:-keep_suffix]
else:
target_X = outputs[:, keep_prefix:]
return target_X
def parse_prefix_suffix_for_model_generate_output(self, output):
"""Calculates if special tokens are present in the beginning/end of the model generated output.
Parameters
----------
output: list
A list of output token ids.
Returns
-------
dict
Dictionary of prefix and suffix lengths concerning special tokens in output ids.
"""
keep_prefix, keep_suffix = 0, 0
if self.tokenizer.convert_ids_to_tokens(output[0]) in self.tokenizer.special_tokens_map.values():
keep_prefix = 1
if (
len(output) > 1
and self.tokenizer.convert_ids_to_tokens(output[-1]) in self.tokenizer.special_tokens_map.values()
):
keep_suffix = 1
return {"keep_prefix": keep_prefix, "keep_suffix": keep_suffix}
def save(self, out_file):
super().save(out_file)
# Increment the version number when the encoding changes!
with Serializer(out_file, "shap.models.TextGeneration", version=0) as s:
s.save("tokenizer", self.tokenizer)
s.save("device", self.device)
@classmethod
def load(cls, in_file, instantiate=True):
if instantiate:
return cls._instantiated_load(in_file)
kwargs = super().load(in_file, instantiate=False)
with Deserializer(in_file, "shap.models.TextGeneration", min_version=0, max_version=0) as s:
kwargs["tokenizer"] = s.load("tokenizer")
kwargs["device"] = s.load("device")
return kwargs