582 lines
26 KiB
Python
582 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import time
|
|
from typing import TYPE_CHECKING, Any, Literal
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import scipy.sparse
|
|
|
|
from .. import explainers, links, maskers, models
|
|
from .._explanation import Explanation
|
|
from .._serializable import Deserializer, Serializable, Serializer
|
|
from ..utils import safe_isinstance, show_progress
|
|
from ..utils._exceptions import InvalidAlgorithmError
|
|
from ..utils.transformers import is_transformers_lm
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
class Explainer(Serializable):
|
|
"""Uses Shapley values to explain any machine learning model or python function.
|
|
|
|
This is the primary explainer interface for the SHAP library. It takes any combination
|
|
of a model and masker and returns a callable subclass object that implements
|
|
the particular estimation algorithm that was chosen.
|
|
"""
|
|
|
|
model: Any
|
|
masker: Any
|
|
output_names: list[str] | None
|
|
feature_names: list[str] | list[list[str]] | None
|
|
link: Callable[..., Any]
|
|
linearize_link: bool
|
|
|
|
def __init__(
|
|
self,
|
|
model: Any,
|
|
masker: Any = None,
|
|
link: Callable[..., Any] = links.identity,
|
|
algorithm: Literal["auto", "permutation", "partition", "tree", "linear", "deep", "exact", "additive"] = "auto",
|
|
output_names: list[str] | None = None,
|
|
feature_names: list[str] | list[list[str]] | None = None,
|
|
linearize_link: bool = True,
|
|
seed: int | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Build a new explainer for the passed model.
|
|
|
|
Parameters
|
|
----------
|
|
model : object or function
|
|
User supplied function or model object that takes a dataset of samples and
|
|
computes the output of the model for those samples.
|
|
|
|
masker : function, numpy.array, pandas.DataFrame, tokenizer, None, or a list of these for each model input
|
|
The function used to "mask" out hidden features of the form `masked_args = masker(*model_args, mask=mask)`.
|
|
It takes input in the same form as the model, but for just a single sample with a binary
|
|
mask, then returns an iterable of masked samples. These
|
|
masked samples will then be evaluated using the model function and the outputs averaged.
|
|
As a shortcut for the standard masking using by SHAP you can pass a background data matrix
|
|
instead of a function and that matrix will be used for masking. Domain specific masking
|
|
functions are available in shap such as shap.ImageMasker for images and shap.TokenMasker
|
|
for text. In addition to determining how to replace hidden features, the masker can also
|
|
constrain the rules of the cooperative game used to explain the model. For example
|
|
shap.TabularMasker(data, hclustering="correlation") will enforce a hierarchical clustering
|
|
of coalitions for the game (in this special case the attributions are known as the Owen values).
|
|
|
|
link : function
|
|
The link function used to map between the output units of the model and the SHAP value units. By
|
|
default it is shap.links.identity, but shap.links.logit can be useful so that expectations are
|
|
computed in probability units while explanations remain in the (more naturally additive) log-odds
|
|
units. For more details on how link functions work see any overview of link functions for generalized
|
|
linear models.
|
|
|
|
algorithm : "auto", "permutation", "partition", "tree", or "linear"
|
|
The algorithm used to estimate the Shapley values. There are many different algorithms that
|
|
can be used to estimate the Shapley values (and the related value for constrained games), each
|
|
of these algorithms have various tradeoffs and are preferable in different situations. By
|
|
default the "auto" options attempts to make the best choice given the passed model and masker,
|
|
but this choice can always be overridden by passing the name of a specific algorithm. The type of
|
|
algorithm used will determine what type of subclass object is returned by this constructor, and
|
|
you can also build those subclasses directly if you prefer or need more fine grained control over
|
|
their options.
|
|
|
|
output_names : None or list of strings
|
|
The names of the model outputs. For example if the model is an image classifier, then output_names would
|
|
be the names of all the output classes. This parameter is optional. When output_names is None then
|
|
the Explanation objects produced by this explainer will not have any output_names, which could effect
|
|
downstream plots.
|
|
|
|
seed: None or int
|
|
seed for reproducibility
|
|
|
|
"""
|
|
self.model = model
|
|
self.output_names = output_names
|
|
self.feature_names = feature_names
|
|
|
|
# wrap the incoming masker object as a shap.Masker object
|
|
if isinstance(masker, pd.DataFrame) or (
|
|
(isinstance(masker, np.ndarray) or scipy.sparse.issparse(masker)) and len(masker.shape) == 2
|
|
):
|
|
if algorithm == "partition":
|
|
self.masker = maskers.Partition(masker)
|
|
else:
|
|
self.masker = maskers.Independent(masker)
|
|
elif safe_isinstance(
|
|
masker, ["transformers.PreTrainedTokenizer", "transformers.tokenization_utils_base.PreTrainedTokenizerBase"]
|
|
):
|
|
if is_transformers_lm(self.model):
|
|
# auto assign text infilling if model is a transformer model with lm head
|
|
self.masker = maskers.Text(masker, mask_token="...", collapse_mask_token=True)
|
|
else:
|
|
self.masker = maskers.Text(masker)
|
|
elif (masker is list or masker is tuple) and masker[0] is not str:
|
|
self.masker = maskers.Composite(*masker)
|
|
elif isinstance(masker, dict) and ("mean" in masker):
|
|
self.masker = maskers.Independent(masker)
|
|
elif masker is None and isinstance(self.model, models.TransformersPipeline):
|
|
return self.__init__( # type: ignore[misc]
|
|
self.model,
|
|
self.model.inner_model.tokenizer,
|
|
link=link,
|
|
algorithm=algorithm,
|
|
output_names=output_names,
|
|
feature_names=feature_names,
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
else:
|
|
self.masker = masker
|
|
|
|
# Check for transformer pipeline objects and wrap them
|
|
if safe_isinstance(self.model, "transformers.pipelines.Pipeline"):
|
|
if is_transformers_lm(self.model.model):
|
|
return self.__init__( # type: ignore[misc]
|
|
self.model.model,
|
|
self.model.tokenizer if self.masker is None else self.masker,
|
|
link=link,
|
|
algorithm=algorithm,
|
|
output_names=output_names,
|
|
feature_names=feature_names,
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
else:
|
|
return self.__init__( # type: ignore[misc]
|
|
models.TransformersPipeline(self.model),
|
|
self.masker,
|
|
link=link,
|
|
algorithm=algorithm,
|
|
output_names=output_names,
|
|
feature_names=feature_names,
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
|
|
# wrap self.masker and self.model for output text explanation algorithm
|
|
if is_transformers_lm(self.model):
|
|
self.model = models.TeacherForcing(self.model, self.masker.tokenizer)
|
|
self.masker = maskers.OutputComposite(self.masker, self.model.text_generate)
|
|
elif safe_isinstance(self.model, "shap.models.TeacherForcing") and safe_isinstance(
|
|
self.masker, ["shap.maskers.Text", "shap.maskers.Image"]
|
|
):
|
|
self.masker = maskers.OutputComposite(self.masker, self.model.text_generate)
|
|
elif safe_isinstance(self.model, "shap.models.TopKLM") and safe_isinstance(self.masker, "shap.maskers.Text"):
|
|
self.masker = maskers.FixedComposite(self.masker)
|
|
|
|
# self._brute_force_fallback = explainers.BruteForce(self.model, self.masker)
|
|
|
|
# validate and save the link function
|
|
if callable(link):
|
|
self.link = link
|
|
else:
|
|
raise TypeError("The passed link function needs to be callable!")
|
|
self.linearize_link = linearize_link
|
|
|
|
# if we are called directly (as opposed to through super()) then we convert ourselves to the subclass
|
|
# that implements the specific algorithm that was chosen
|
|
if self.__class__ is Explainer:
|
|
# do automatic algorithm selection
|
|
# from .. import explainers
|
|
if algorithm == "auto":
|
|
# use implementation-aware methods if possible
|
|
if explainers.LinearExplainer.supports_model_with_masker(model, self.masker):
|
|
algorithm = "linear"
|
|
elif explainers.TreeExplainer.supports_model_with_masker(
|
|
model, self.masker
|
|
): # TODO: check for Partition?
|
|
algorithm = "tree"
|
|
elif explainers.AdditiveExplainer.supports_model_with_masker(model, self.masker):
|
|
algorithm = "additive"
|
|
|
|
# otherwise use a model agnostic method
|
|
elif callable(self.model):
|
|
if issubclass(type(self.masker), maskers.Independent):
|
|
if self.masker.shape[1] <= 10:
|
|
algorithm = "exact"
|
|
else:
|
|
algorithm = "permutation"
|
|
elif issubclass(type(self.masker), maskers.Partition):
|
|
if self.masker.shape[1] <= 32:
|
|
algorithm = "exact"
|
|
else:
|
|
algorithm = "permutation"
|
|
elif (
|
|
getattr(self.masker, "text_data", False) or getattr(self.masker, "image_data", False)
|
|
) and hasattr(self.masker, "clustering"):
|
|
algorithm = "partition"
|
|
else:
|
|
algorithm = "permutation"
|
|
|
|
# if we get here then we don't know how to handle what was given to us
|
|
else:
|
|
raise TypeError(
|
|
"The passed model is not callable and cannot be analyzed directly with the given masker! Model: "
|
|
+ str(model)
|
|
)
|
|
|
|
# build the right subclass
|
|
if algorithm == "exact":
|
|
self.__class__ = explainers.ExactExplainer
|
|
explainers.ExactExplainer.__init__(
|
|
self, # type: ignore[arg-type]
|
|
self.model,
|
|
self.masker,
|
|
link=self.link,
|
|
feature_names=self.feature_names, # type: ignore[arg-type]
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
elif algorithm == "permutation":
|
|
self.__class__ = explainers.PermutationExplainer
|
|
explainers.PermutationExplainer.__init__(
|
|
self, # type: ignore[arg-type]
|
|
self.model,
|
|
self.masker,
|
|
link=self.link,
|
|
feature_names=self.feature_names, # type: ignore[arg-type]
|
|
linearize_link=linearize_link,
|
|
seed=seed,
|
|
**kwargs,
|
|
)
|
|
elif algorithm == "partition":
|
|
self.__class__ = explainers.PartitionExplainer
|
|
explainers.PartitionExplainer.__init__(
|
|
self, # type: ignore[arg-type]
|
|
self.model,
|
|
self.masker,
|
|
link=self.link,
|
|
feature_names=self.feature_names, # type: ignore[arg-type]
|
|
linearize_link=linearize_link,
|
|
output_names=self.output_names,
|
|
**kwargs,
|
|
)
|
|
elif algorithm == "tree":
|
|
self.__class__ = explainers.TreeExplainer
|
|
explainers.TreeExplainer.__init__(
|
|
self, # type: ignore[arg-type]
|
|
self.model,
|
|
self.masker,
|
|
link=self.link,
|
|
feature_names=self.feature_names, # type: ignore[arg-type]
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
elif algorithm == "additive":
|
|
self.__class__ = explainers.AdditiveExplainer
|
|
explainers.AdditiveExplainer.__init__(
|
|
self, # type: ignore[arg-type]
|
|
self.model,
|
|
self.masker,
|
|
link=self.link,
|
|
feature_names=self.feature_names, # type: ignore[arg-type]
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
elif algorithm == "linear":
|
|
self.__class__ = explainers.LinearExplainer
|
|
explainers.LinearExplainer.__init__(
|
|
self, # type: ignore[arg-type]
|
|
self.model,
|
|
self.masker,
|
|
link=self.link,
|
|
feature_names=self.feature_names, # type: ignore[arg-type]
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
elif algorithm == "deep":
|
|
self.__class__ = explainers.DeepExplainer
|
|
explainers.DeepExplainer.__init__( # type: ignore[call-arg]
|
|
self, # type: ignore[arg-type]
|
|
self.model,
|
|
self.masker,
|
|
link=self.link,
|
|
feature_names=self.feature_names, # type: ignore[arg-type]
|
|
linearize_link=linearize_link,
|
|
**kwargs,
|
|
)
|
|
else:
|
|
raise InvalidAlgorithmError(f"Unknown algorithm type passed: {algorithm}!")
|
|
|
|
def __call__(
|
|
self,
|
|
*args: Any,
|
|
max_evals: int | Literal["auto"] = "auto",
|
|
main_effects: bool = False,
|
|
error_bounds: bool = False,
|
|
batch_size: int | Literal["auto"] = "auto",
|
|
outputs: Any = None,
|
|
silent: bool = False,
|
|
**kwargs: Any,
|
|
) -> Explanation | list[Explanation]:
|
|
"""Explains the output of model(*args), where args is a list of parallel iterable datasets.
|
|
|
|
Note this default version could be an abstract method that is implemented by each algorithm-specific
|
|
subclass of Explainer. Descriptions of each subclasses' __call__ arguments
|
|
are available in their respective doc-strings.
|
|
"""
|
|
# if max_evals == "auto":
|
|
# self._brute_force_fallback
|
|
|
|
start_time = time.time()
|
|
|
|
if issubclass(type(self.masker), maskers.OutputComposite) and len(args) == 2:
|
|
self.masker.model = models.TextGeneration(target_sentences=args[1])
|
|
args = args[:1]
|
|
# parse our incoming arguments
|
|
num_rows = None
|
|
args = list(args) # type: ignore[assignment]
|
|
if self.feature_names is None:
|
|
feature_names = [None for _ in range(len(args))]
|
|
elif issubclass(type(self.feature_names[0]), (list, tuple)):
|
|
feature_names = copy.deepcopy(self.feature_names) # type: ignore[arg-type, assignment]
|
|
else:
|
|
feature_names = [copy.deepcopy(self.feature_names)] # type: ignore[list-item, assignment]
|
|
for i in range(len(args)):
|
|
# try and see if we can get a length from any of the for our progress bar
|
|
if num_rows is None:
|
|
try:
|
|
num_rows = len(args[i])
|
|
except Exception:
|
|
pass
|
|
|
|
# convert DataFrames to numpy arrays
|
|
if isinstance(args[i], pd.DataFrame):
|
|
feature_names[i] = list(args[i].columns) # type: ignore[call-overload, index]
|
|
args[i] = args[i].to_numpy() # type: ignore[index]
|
|
|
|
# convert nlp Dataset objects to lists
|
|
if safe_isinstance(args[i], "nlp.arrow_dataset.Dataset"):
|
|
args[i] = args[i]["text"] # type: ignore[index]
|
|
elif issubclass(type(args[i]), dict) and "text" in args[i]:
|
|
args[i] = args[i]["text"] # type: ignore[index]
|
|
|
|
if batch_size == "auto":
|
|
if hasattr(self.masker, "default_batch_size"):
|
|
batch_size = self.masker.default_batch_size
|
|
else:
|
|
batch_size = 10
|
|
|
|
# loop over each sample, filling in the values array
|
|
values = []
|
|
output_indices = []
|
|
expected_values = []
|
|
mask_shapes = []
|
|
main_effects = [] # type: ignore[var-annotated, assignment]
|
|
hierarchical_values = []
|
|
clustering = []
|
|
output_names = []
|
|
error_std = []
|
|
if callable(getattr(self.masker, "feature_names", None)):
|
|
feature_names = [[] for _ in range(len(args))] # type: ignore[misc, assignment]
|
|
for row_args in show_progress(zip(*args), num_rows, self.__class__.__name__ + " explainer", silent):
|
|
row_result = self.explain_row(
|
|
*row_args,
|
|
max_evals=max_evals,
|
|
main_effects=main_effects,
|
|
error_bounds=error_bounds,
|
|
batch_size=batch_size,
|
|
outputs=outputs,
|
|
silent=silent,
|
|
**kwargs,
|
|
)
|
|
values.append(row_result.get("values", None))
|
|
output_indices.append(row_result.get("output_indices", None))
|
|
expected_values.append(row_result.get("expected_values", None))
|
|
mask_shapes.append(row_result["mask_shapes"])
|
|
main_effects.append(row_result.get("main_effects", None)) # type: ignore[attr-defined, arg-type]
|
|
clustering.append(row_result.get("clustering", None))
|
|
hierarchical_values.append(row_result.get("hierarchical_values", None))
|
|
tmp = row_result.get("output_names", None)
|
|
output_names.append(tmp(*row_args) if callable(tmp) else tmp)
|
|
error_std.append(row_result.get("error_std", None))
|
|
if callable(getattr(self.masker, "feature_names", None)):
|
|
row_feature_names = self.masker.feature_names(*row_args)
|
|
for i in range(len(row_args)):
|
|
feature_names[i].append(row_feature_names[i]) # type: ignore[attr-defined, index]
|
|
|
|
# split the values up according to each input
|
|
arg_values = [[] for a in args] # type: ignore[var-annotated, assignment]
|
|
for i in range(len(values)):
|
|
pos = 0
|
|
for j in range(len(args)):
|
|
mask_length = np.prod(mask_shapes[i][j])
|
|
arg_values[j].append(values[i][pos : pos + mask_length]) # type: ignore[index]
|
|
pos += mask_length
|
|
|
|
# collapse the arrays as possible
|
|
expected_values = pack_values(expected_values) # type: ignore[assignment]
|
|
main_effects = pack_values(main_effects) # type: ignore[assignment]
|
|
output_indices = pack_values(output_indices) # type: ignore[assignment]
|
|
main_effects = pack_values(main_effects) # type: ignore[assignment]
|
|
hierarchical_values = pack_values(hierarchical_values) # type: ignore[assignment]
|
|
error_std = pack_values(error_std) # type: ignore[assignment]
|
|
clustering = pack_values(clustering) # type: ignore[assignment]
|
|
|
|
# getting output labels
|
|
ragged_outputs = False
|
|
if output_indices is not None:
|
|
ragged_outputs = not all(len(x) == len(output_indices[0]) for x in output_indices) # type: ignore[arg-type, index]
|
|
if self.output_names is None:
|
|
if None not in output_names:
|
|
if not ragged_outputs:
|
|
sliced_labels = np.array(output_names)
|
|
else:
|
|
sliced_labels = [ # type: ignore[assignment]
|
|
np.array(output_names[i])[index_list] for i, index_list in enumerate(output_indices)
|
|
]
|
|
else:
|
|
sliced_labels = None
|
|
else:
|
|
assert output_indices is not None, (
|
|
"You have passed a list for output_names but the model seems to not have multiple outputs!"
|
|
)
|
|
labels = np.array(self.output_names)
|
|
sliced_labels = [labels[index_list] for index_list in output_indices] # type: ignore[assignment]
|
|
if not ragged_outputs:
|
|
sliced_labels = np.array(sliced_labels)
|
|
|
|
if isinstance(sliced_labels, np.ndarray) and len(sliced_labels.shape) == 2:
|
|
if np.all(sliced_labels[0, :] == sliced_labels):
|
|
sliced_labels = sliced_labels[0]
|
|
|
|
# allow the masker to transform the input data to better match the masking pattern
|
|
# (such as breaking text into token segments)
|
|
if hasattr(self.masker, "data_transform"):
|
|
new_args = []
|
|
for row_args in zip(*args):
|
|
new_args.append([pack_values(v) for v in self.masker.data_transform(*row_args)])
|
|
args = list(zip(*new_args)) # type: ignore[assignment]
|
|
|
|
# build the explanation objects
|
|
out = []
|
|
for j, data in enumerate(args):
|
|
# reshape the attribution values using the mask_shapes
|
|
tmp = []
|
|
for i, v in enumerate(arg_values[j]):
|
|
if np.prod(mask_shapes[i][j]) != np.prod(v.shape): # see if we have multiple outputs
|
|
tmp.append(v.reshape(*mask_shapes[i][j], -1))
|
|
else:
|
|
tmp.append(v.reshape(*mask_shapes[i][j]))
|
|
arg_values[j] = pack_values(tmp) # type: ignore[assignment]
|
|
|
|
if feature_names[j] is None:
|
|
feature_names[j] = ["Feature " + str(i) for i in range(data.shape[1])] # type: ignore[call-overload]
|
|
|
|
# build an explanation object for this input argument
|
|
out.append(
|
|
Explanation(
|
|
arg_values[j],
|
|
expected_values,
|
|
data,
|
|
feature_names=feature_names[j],
|
|
main_effects=main_effects, # type: ignore[arg-type]
|
|
clustering=clustering,
|
|
hierarchical_values=hierarchical_values,
|
|
output_names=sliced_labels, # self.output_names
|
|
error_std=error_std, # type: ignore[arg-type]
|
|
compute_time=time.time() - start_time,
|
|
# output_shape=output_shape,
|
|
# lower_bounds=v_min, upper_bounds=v_max
|
|
)
|
|
)
|
|
return out[0] if len(out) == 1 else out
|
|
|
|
def explain_row(
|
|
self,
|
|
*row_args: Any,
|
|
max_evals: int | Literal["auto"],
|
|
main_effects: bool,
|
|
error_bounds: bool,
|
|
outputs: Any,
|
|
silent: bool,
|
|
**kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
"""Explains a single row and returns the tuple (row_values, row_expected_values, row_mask_shapes, main_effects).
|
|
|
|
This is an abstract method meant to be implemented by each subclass.
|
|
|
|
Returns
|
|
-------
|
|
tuple
|
|
A tuple of (row_values, row_expected_values, row_mask_shapes), where row_values is an array of the
|
|
attribution values for each sample, row_expected_values is an array (or single value) representing
|
|
the expected value of the model for each sample (which is the same for all samples unless there
|
|
are fixed inputs present, like labels when explaining the loss), and row_mask_shapes is a list
|
|
of all the input shapes (since the row_values is always flattened),
|
|
|
|
"""
|
|
return {}
|
|
|
|
@staticmethod
|
|
def supports_model_with_masker(model: Any, masker: Any) -> bool:
|
|
"""Determines if this explainer can handle the given model.
|
|
|
|
This is an abstract static method meant to be implemented by each subclass.
|
|
"""
|
|
return False
|
|
|
|
def save(
|
|
self,
|
|
out_file: Any,
|
|
model_saver: str | Callable[..., Any] = ".save",
|
|
masker_saver: str | Callable[..., Any] = ".save",
|
|
) -> None:
|
|
"""Write the explainer to the given file stream."""
|
|
super().save(out_file)
|
|
with Serializer(out_file, "shap.Explainer", version=0) as s:
|
|
s.save("model", self.model, model_saver)
|
|
if hasattr(self, "masker"):
|
|
s.save("masker", self.masker, masker_saver)
|
|
if hasattr(self, "data"):
|
|
s.save("data", self.data)
|
|
s.save("link", self.link)
|
|
|
|
@classmethod
|
|
def load( # type: ignore[override]
|
|
cls,
|
|
in_file: Any,
|
|
model_loader: Callable[..., Any] | None = None,
|
|
masker_loader: Callable[..., Any] | None = None,
|
|
instantiate: bool = True,
|
|
) -> Explainer | dict[str, Any]:
|
|
"""Load an Explainer from the given file stream.
|
|
|
|
Parameters
|
|
----------
|
|
in_file : The file stream to load objects from.
|
|
|
|
"""
|
|
if instantiate:
|
|
return cls._instantiated_load(in_file, model_loader=model_loader, masker_loader=masker_loader)
|
|
|
|
kwargs = super().load(in_file, instantiate=False)
|
|
with Deserializer(in_file, "shap.Explainer", min_version=0, max_version=0) as s:
|
|
kwargs["model"] = s.load("model", model_loader)
|
|
if cls.__name__ == "KernelExplainer":
|
|
kwargs["data"] = s.load("data")
|
|
else:
|
|
kwargs["masker"] = s.load("masker", masker_loader)
|
|
kwargs["link"] = s.load("link")
|
|
return kwargs
|
|
|
|
|
|
def pack_values(values: Any) -> np.ndarray | None | Any:
|
|
"""Used the clean up arrays before putting them into an Explanation object."""
|
|
if not hasattr(values, "__len__"):
|
|
return values
|
|
|
|
# collapse the values if we didn't compute them
|
|
if values is None or values[0] is None:
|
|
return None
|
|
|
|
# convert to a single numpy matrix when the array is not ragged
|
|
elif np.issubdtype(type(values[0]), np.number) or len(np.unique([len(v) for v in values])) == 1:
|
|
return np.array(values)
|
|
else:
|
|
return np.array(values, dtype=object)
|