diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c2ad1c23..876179e1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,6 +13,11 @@ repos: - id: check-ast - id: check-json - id: debug-statements + - repo: https://github.com/compilerla/conventional-pre-commit + rev: 'v2.2.0' + hooks: + - id: conventional-pre-commit + stages: [commit-msg] - repo: https://github.com/ikamensh/flynt/ rev: '0.78' hooks: @@ -31,3 +36,4 @@ repos: rev: 'v1.2.0' hooks: - id: mypy + args: [--ignore-missing-imports] diff --git a/skll/config/__init__.py b/skll/config/__init__.py index 3d91a40a..b21181ff 100644 --- a/skll/config/__init__.py +++ b/skll/config/__init__.py @@ -21,7 +21,7 @@ import ruamel.yaml as yaml from skll.data.readers import safe_float -from skll.types import FoldMapping, PathOrStr +from skll.types import ClassMap, FoldMapping, LabelType, PathOrStr from skll.utils.constants import ( PROBABILISTIC_METRICS, VALID_FEATURE_SCALING_OPTIONS, @@ -86,7 +86,7 @@ def __init__(self) -> None: "random_folds": "False", "results": "", "sampler": "", - "sampler_parameters": "[]", + "sampler_parameters": "{}", "save_cv_folds": "True", "save_cv_models": "False", "shuffle": "False", @@ -296,7 +296,7 @@ def parse_config_file( bool, bool, str, - str, + Optional[LabelType], str, int, str, @@ -317,7 +317,7 @@ def parse_config_file( str, str, bool, - Optional[Dict[str, List[str]]], + Optional[ClassMap], str, str, List[int], @@ -407,9 +407,8 @@ def parse_config_file( results_path : str Path to store result files in. - pos_label : str - The string label for the positive class in the binary - classification setting. + pos_label : Optional[LabelType] + The label for the positive class in the binary classification setting. feature_scaling : str How to scale features (e.g. 'with_mean'). @@ -484,7 +483,7 @@ def parse_config_file( ids_to_floats : bool Whether to convert IDs to floats. - class_map : Optional[Dict[str, List[str]]] + class_map : Optional[ClassMap] A class map collapsing several labels into one. The keys are the collapsed labels and each key's value is the list of labels to be collapsed into said label. @@ -704,9 +703,8 @@ def parse_config_file( param_grid_list = yaml.safe_load(fix_json(config.get("Tuning", "param_grids"))) # read and normalize the value of `pos_label` - pos_label = safe_float(config.get("Tuning", "pos_label")) - if pos_label == "": - pos_label = None + pos_label_string = safe_float(config.get("Tuning", "pos_label")) + pos_label: Optional[LabelType] = pos_label_string if pos_label_string else None # ensure that feature_scaling is specified only as one of the # four available choices diff --git a/skll/data/featureset.py b/skll/data/featureset.py index faadad38..f7436789 100644 --- a/skll/data/featureset.py +++ b/skll/data/featureset.py @@ -9,37 +9,38 @@ """ from copy import deepcopy +from typing import Collection, List, Optional, Tuple, Union import numpy as np import scipy.sparse as sp +from pandas import DataFrame from sklearn.feature_extraction import DictVectorizer, FeatureHasher -from skll.data.dict_vectorizer import DictVectorizer as NewDictVectorizer +from skll.data.dict_vectorizer import DictVectorizer as SkllDictVectorizer +from skll.types import FeatGenerator, FeatureDictList, IdType, LabelType, SparseFeatureMatrix class FeatureSet(object): - """ - Encapsulation of all of the features, values, and metadata about a given - set of data. This replaces ``ExamplesTuple`` from older versions of SKLL. + Encapsulate features, labels, and metadata for a given dataset. Parameters ---------- name : str The name of this feature set. - ids : np.array of shape (n_ids,) + ids :list or np.array of shape (n_ids,) Example IDs for this set. labels : np.array of shape (n_labels,), default=None labels for this set. - features : list of dict or an array-like of shape (n_samples, n_features), default=None + features : Optional[Union[FeatureDictList, np.ndarray]], default=None The features for each instance represented as either a list of dictionaries or an array-like (if ``vectorizer`` is also specified). - vectorizer : DictVectorizer or FeatureHasher, default=None + vectorizer : Union[DictVectorizer, FeatureHasher], default=None Vectorizer which will be used to generate the feature matrix. Warnings @@ -57,26 +58,49 @@ class FeatureSet(object): each array must be equal. """ - def __init__(self, name, ids, labels=None, features=None, vectorizer=None): + def __init__( + self, + name: str, + ids: Union[List[str], np.ndarray], + labels: Optional[Union[List[str], np.ndarray]] = None, + features: Optional[Union[FeatureDictList, SparseFeatureMatrix]] = None, + vectorizer: Optional[Union[DictVectorizer, FeatureHasher]] = None, + ): + """Initialize a FeatureSet instance.""" super(FeatureSet, self).__init__() + + # clearly define the attribute types + self.ids: np.ndarray + self.labels: Optional[np.ndarray] + self.features: Optional[SparseFeatureMatrix] + self.vectorizer: Optional[Union[DictVectorizer, FeatureHasher]] + self.name = name + if isinstance(ids, list): - ids = np.array(ids) - self.ids = ids + self.ids = np.array(ids) + elif isinstance(ids, np.ndarray): + self.ids = ids + else: + raise ValueError("Ids must be a list or numpy array.") + if isinstance(labels, list): labels = np.array(labels) self.labels = labels - self.features = features + self.vectorizer = vectorizer - # Convert list of dicts to numpy array - if isinstance(self.features, list): + + # convert features from list of dictionaries to sparse array, if needed + if isinstance(features, list): if self.vectorizer is None: - self.vectorizer = NewDictVectorizer(sparse=True) - self.features = self.vectorizer.fit_transform(self.features) + self.vectorizer = SkllDictVectorizer(sparse=True) + features_array: SparseFeatureMatrix = self.vectorizer.fit_transform(features) + self.features = features_array + else: + self.features = features + if self.features is not None: num_feats = self.features.shape[0] - if self.ids is None: - raise ValueError("A list of IDs is required") num_ids = self.ids.shape[0] if num_feats != num_ids: raise ValueError( @@ -113,12 +137,16 @@ def __eq__(self, other): other : skll.data.FeatureSet The other ``FeatureSet`` to check equivalence with. + Returns + ------- + bool + ``True`` if they are the same, ``False`` otherwise. + Note ---- We consider feature values to be equal if any differences are in the sixth decimal place or higher. """ - return ( self.ids.shape == other.ids.shape and self.labels.shape == other.labels.shape @@ -132,9 +160,7 @@ def __eq__(self, other): ) def __iter__(self): - """ - Iterate through (ID, label, feature_dict) tuples in feature set. - """ + """Iterate through (ID, label, feature_dict) tuples in feature set.""" if self.features is not None: if not isinstance(self.vectorizer, DictVectorizer): raise ValueError( @@ -154,27 +180,36 @@ def __iter__(self): else: return - def __len__(self): - """ - The number of rows in the ``FeatureSet`` instance. - """ - return self.features.shape[0] + def __len__(self) -> int: + """Return number of rows in the ``FeatureSet`` instance.""" + return self.features.shape[0] if self.features is not None else 0 - def __add__(self, other): + def __add__(self, other: "FeatureSet") -> "FeatureSet": """ - Combine two feature sets to create a new one. This is done assuming - they both have the same instances with the same IDs in the same order. + Combine two feature sets to create a new one. + + The combination is done assuming they both have the same instances + with the same IDs in the same order. Parameters ---------- other : skll.data.FeatureSet The other ``FeatureSet`` to add to this one. + Returns + ------- + skll.data.FeatureSet + The combined feature set. + Raises ------ ValueError If IDs are not in the same order in each ``FeatureSet`` instance. + ValueError + If either the 'features' or 'vectorizer' attributes are + ``None`` for either of the two ``FeatureSet`` instances. + ValueError If vectorizers are different between the two ``FeatureSet`` instances. @@ -184,7 +219,6 @@ def __add__(self, other): ValueError If there are conflicting labels. """ - # Check that the sets of IDs are equal if set(self.ids) != set(other.ids): raise ValueError("IDs are not in the same order in each " "feature set") @@ -196,7 +230,19 @@ def __add__(self, other): # Initialize the new feature set with a name and the IDs. new_set = FeatureSet("+".join(sorted([self.name, other.name])), deepcopy(self.ids)) - # Combine feature matrices and vectorizers. + # Make sure that features and vectorizer in either feature set are not None + if ( + self.vectorizer is None + or other.vectorizer is None + or self.features is None + or other.features is None + ): + raise ValueError( + "Cannot combine FeatureSets since either the vectorizer " + "or the features are not defined." + ) + + # Make sure the two vectorizers are the same type if not isinstance(self.vectorizer, type(other.vectorizer)): raise ValueError( "Cannot combine FeatureSets because they are " @@ -204,59 +250,72 @@ def __add__(self, other): "vectorizer (e.g., DictVectorizer, " "FeatureHasher)" ) - uses_feature_hasher = isinstance(self.vectorizer, FeatureHasher) - if uses_feature_hasher: - if self.vectorizer.n_features != other.vectorizer.n_features: - raise ValueError( - "Cannot combine FeatureSets that uses " - "FeatureHashers with different values of " - "n_features setting." - ) - else: - # Check for duplicate feature names. - if set(self.vectorizer.feature_names_) & set(other.vectorizer.feature_names_): - raise ValueError( - "Cannot combine FeatureSets because they " "have duplicate feature names." - ) - num_feats = self.features.shape[1] - - new_set.features = sp.hstack([self.features, other.features[relative_order]], "csr") - new_set.vectorizer = deepcopy(self.vectorizer) - if not uses_feature_hasher: - for feat_name, index in other.vectorizer.vocabulary_.items(): - new_set.vectorizer.vocabulary_[feat_name] = index + num_feats - other_names = other.vectorizer.feature_names_ - new_set.vectorizer.feature_names_.extend(other_names) - - # If either set has labels, check that they don't conflict. - if self.has_labels: - # labels should be the same for each FeatureSet, so store once. - if other.has_labels and not np.all(self.labels == other.labels[relative_order]): - raise ValueError( - "Feature sets have conflicting labels for " "examples with the same ID." - ) - new_set.labels = deepcopy(self.labels) + # they have to be the same types in this block else: - new_set.labels = deepcopy(other.labels[relative_order]) + uses_feature_hasher = isinstance(self.vectorizer, FeatureHasher) + if uses_feature_hasher: + if self.vectorizer.n_features != other.vectorizer.n_features: + raise ValueError( + "Cannot combine FeatureSets that use " + "FeatureHashers with different values of " + "n_features setting." + ) + else: + # Check for duplicate feature names. + if set(self.vectorizer.feature_names_) & set(other.vectorizer.feature_names_): + raise ValueError( + "Cannot combine FeatureSets because they have duplicate feature names." + ) + num_feats = self.features.shape[1] + + new_set.features = sp.hstack([self.features, other.features[relative_order]], "csr") + new_set.vectorizer = deepcopy(self.vectorizer) + if not uses_feature_hasher: + for feat_name, index in other.vectorizer.vocabulary_.items(): + new_set.vectorizer.vocabulary_[feat_name] = index + num_feats + other_names = other.vectorizer.feature_names_ + new_set.vectorizer.feature_names_.extend(other_names) + + # If either set has labels, check that they don't conflict. + if self.has_labels: + # labels should be the same for each FeatureSet, so store once. + conflicts = not np.all(self.labels == other.labels[relative_order]) # type: ignore + if other.has_labels and conflicts: + raise ValueError( + "Feature sets have conflicting labels for examples with the same ID." + ) + new_set.labels = deepcopy(self.labels) + else: + labels = other.labels + if other.has_labels: + labels = deepcopy(other.labels[relative_order]) # type: ignore + new_set.labels = labels return new_set - def filter(self, ids=None, labels=None, features=None, inverse=False): + def filter( + self, + ids: Optional[List[IdType]] = None, + labels: Optional[List[LabelType]] = None, + features: Optional[List[str]] = None, + inverse: bool = False, + ) -> None: """ - Removes or keeps features and/or examples from the `Featureset` depending - on the parameters. Filtering is done in-place. + Remove or keep features and/or examples from the ``Featureset``. + + Filtering is done in-place. Parameters ---------- - ids : list of str/float, default=None + ids : Optional[List[FloatOrStr]], default=None Examples to keep in the FeatureSet. If ``None``, no ID filtering takes place. - labels : list of str/float, default=None + labels : Optional[List[LabelType]], default=None Labels that we want to retain examples for. If ``None``, no label filtering takes place. - features : list of str, default=None + features : Optional[List[str]], default=None Features to keep in the FeatureSet. To help with filtering string-valued features that were converted to sequences of boolean features when read in, any @@ -279,9 +338,9 @@ def filter(self, ids=None, labels=None, features=None, inverse=False): """ # Construct mask that indicates which examples to keep mask = np.ones(len(self), dtype=bool) - if ids is not None: + if ids: mask = np.logical_and(mask, np.in1d(self.ids, ids)) - if labels is not None: + if labels and self.labels is not None: mask = np.logical_and(mask, np.in1d(self.labels, labels)) if inverse and (labels is not None or ids is not None): @@ -289,14 +348,16 @@ def filter(self, ids=None, labels=None, features=None, inverse=False): # Remove examples not in mask self.ids = self.ids[mask] - self.labels = self.labels[mask] - self.features = self.features[mask, :] + if self.labels is not None: + self.labels = self.labels[mask] + if self.features is not None: + self.features = self.features[mask, :] # Filter features - if features is not None: + if features and self.features is not None and self.vectorizer is not None: if isinstance(self.vectorizer, FeatureHasher): raise ValueError( - "FeatureSets with FeatureHasher vectorizers" " cannot be filtered by feature." + "FeatureSets with FeatureHasher vectorizers cannot be filtered by feature." ) columns = np.array( sorted( @@ -313,22 +374,27 @@ def filter(self, ids=None, labels=None, features=None, inverse=False): self.features = self.features[:, columns] self.vectorizer.restrict(columns, indices=True) - def filtered_iter(self, ids=None, labels=None, features=None, inverse=False): + def filtered_iter( + self, + ids: Optional[List[IdType]] = None, + labels: Optional[List[LabelType]] = None, + features: Optional[Collection[str]] = None, + inverse: bool = False, + ) -> FeatGenerator: """ - A version of `__iter__` that retains only the specified features - and/or examples from the output. + Retain only the specified features and/or examples from the output. Parameters ---------- - ids : list of str/float, default=None + ids : Optional[List[IdType]], default=None Examples to keep in the ``FeatureSet``. If ``None``, no ID filtering takes place. - labels : list of str/float, default=None + labels : Optional[List[LabelType]], default=None Labels that we want to retain examples for. If ``None``, no label filtering takes place. - features : list of str, default=None + features : Optional[Collection[str]], default=None Features to keep in the ``FeatureSet``. To help with filtering string-valued features that were converted to sequences of boolean features when read in, any @@ -345,13 +411,13 @@ def filtered_iter(self, ids=None, labels=None, features=None, inverse=False): Yields ------ - id_ : str + id_ : IdType The ID of the example. - label_ : str + label_ : LabelType The label of the example. - feat_dict : dict + feat_dict : FeatureDict The feature dictionary, with feature name as the key and example value as the value. @@ -359,6 +425,10 @@ def filtered_iter(self, ids=None, labels=None, features=None, inverse=False): ------ ValueError If the vectorizer is not a ``DictVectorizer``. + + ValueError + If any of the "labels", "features", or "vectorizer" attribute + is ``None``. """ if self.features is not None and not isinstance(self.vectorizer, DictVectorizer): raise ValueError( @@ -367,32 +437,34 @@ def filtered_iter(self, ids=None, labels=None, features=None, inverse=False): "vectorizer." ) - for id_, label_, feats in zip(self.ids, self.labels, self.features): - # Skip instances with IDs not in filter - if ids is not None and (id_ in ids) == inverse: - continue - # Skip instances with labels not in filter - if labels is not None and (label_ in labels) == inverse: - continue - - # reshape to a 2D matrix if we are not using a sparse matrix - # to store the features - feats = feats.reshape(1, -1) if not sp.issparse(feats) else feats - feat_dict = self.vectorizer.inverse_transform(feats)[0] - if features is not None: - feat_dict = { - name: value - for name, value in feat_dict.items() - if (inverse != (name in features or name.split("=", 1)[0] in features)) - } - elif not inverse: - feat_dict = {} - yield id_, label_, feat_dict - - def __sub__(self, other): + if self.labels is None or self.features is None or self.vectorizer is None: + raise ValueError("Cannot filter featureset with no labels, features, or vectorizer.") + else: + for id_, label_, feats in zip(self.ids, self.labels, self.features): + # Skip instances with IDs not in filter + if ids is not None and (id_ in ids) == inverse: + continue + # Skip instances with labels not in filter + if labels is not None and (label_ in labels) == inverse: + continue + + # reshape to a 2D matrix if we are not using a sparse matrix + # to store the features + feats = feats.reshape(1, -1) if not sp.issparse(feats) else feats + feat_dict = self.vectorizer.inverse_transform(feats)[0] + if features is not None: + feat_dict = { + name: value + for name, value in feat_dict.items() + if (inverse != (name in features or name.split("=", 1)[0] in features)) + } + elif not inverse: + feat_dict = {} + yield id_, label_, feat_dict + + def __sub__(self, other: "FeatureSet") -> "FeatureSet": """ - Subset ``FeatureSet`` instance by removing all the features from the - other ``FeatureSet`` instance. + Subset ``FeatureSet`` instance by removing all features from ``other`` instance. Parameters ---------- @@ -402,10 +474,12 @@ def __sub__(self, other): Returns ------- - A copy of ``self`` with all features in ``other`` removed. + FeatureSet: + A copy of ``self`` with all features in ``other`` removed. """ new_set = deepcopy(self) - new_set.filter(features=other.vectorizer.feature_names_, inverse=True) + if other.vectorizer: + new_set.filter(features=other.vectorizer.feature_names_, inverse=True) return new_set @property @@ -429,31 +503,44 @@ def has_labels(self): def __str__(self): """ + Return a string representation of ``FeatureSet``. + Returns ------- - A string representation of ``FeatureSet``. + str: + A string representation of ``FeatureSet``. """ return str(self.__dict__) def __repr__(self): """ + Return a string representation of ``FeatureSet``. + Returns ------- - A string representation of ``FeatureSet``. + str: + A string representation of ``FeatureSet``. """ return repr(self.__dict__) - def __getitem__(self, value): + def __getitem__( + self, value: Union[int, slice] + ) -> Union["FeatureSet", Tuple[IdType, LabelType, FeatureDictList]]: """ + Get new feature subset or specific example. + Parameters ---------- - value - The value to retrieve. + value: Union[int, slice] + The value to use for retrieval. This can either be a slice or + an index. Returns ------- - A specific example by row number or, if given a slice, - a new ``FeatureSet`` instance containing a subset of the data. + Union["FeatureSet", Tuple[IdType, LabelType, FeatureDictList]] + If `value` is a slice, then return a new ``FeatureSet`` instance + containing a subset of the data. If it's an index, return the + specific example by row number. """ # Check if we're slicing if isinstance(value, slice): @@ -468,32 +555,36 @@ def __getitem__(self, value): vectorizer=self.vectorizer, ) else: - label = self.labels[value] if self.labels is not None else None - feats = self.features[value, :] - features = ( - self.vectorizer.inverse_transform(feats)[0] if self.features is not None else {} - ) + label = self.labels[value] if self.labels is not None else "" + if self.features is not None and self.vectorizer: + submatrix = self.features[value, :] + features = self.vectorizer.inverse_transform(submatrix)[0] + else: + features = [{}] return self.ids[value], label, features @staticmethod - def split_by_ids(fs, ids_for_split1, ids_for_split2=None): + def split_by_ids( + fs: "FeatureSet", ids_for_split1: List[int], ids_for_split2: Optional[List[int]] = None + ) -> Tuple["FeatureSet", "FeatureSet"]: """ - Split the ``FeatureSet`` into two new ``FeatureSet`` instances based on - the given IDs for the two splits. + Split ``FeatureSet`` into two new ``FeatureSet`` instances. + + The splitting is done based on the given IDs for the two splits. Parameters ---------- fs : skll.data.FeatureSet The ``FeatureSet`` instance to split. - ids_for_split1 : list of int + ids_for_split1 : List[int] A list of example IDs which will be split out into the first ``FeatureSet`` instance. Note that the FeatureSet instance will respect the order of the specified IDs. - ids_for_split2 : list of int, default=None - An optional ist of example IDs which will be + ids_for_split2 : Optional[List[int]], default=None + An optional list of example IDs which will be split out into the second ``FeatureSet`` instance. Note that the ``FeatureSet`` instance will respect the order of the specified IDs. If this is @@ -509,7 +600,6 @@ def split_by_ids(fs, ids_for_split1, ids_for_split2=None): fs2 : skll.data.FeatureSet The second ``FeatureSet``. """ - # Note: an alternative way to implement this is to make copies # of the given FeatureSet instance and then use the `filter()` # method but that wastes too much memory since it requires making @@ -517,16 +607,18 @@ def split_by_ids(fs, ids_for_split1, ids_for_split2=None): # the current implementation, we are creating new objects but # they should be much smaller than the original FeatureSet. ids1 = fs.ids[ids_for_split1] - labels1 = fs.labels[ids_for_split1] - features1 = fs.features[ids_for_split1] + labels1 = fs.labels[ids_for_split1] if fs.labels is not None else None + features1 = fs.features[ids_for_split1] if fs.features is not None else None if ids_for_split2 is None: ids2 = fs.ids[~np.in1d(fs.ids, ids_for_split1)] - labels2 = fs.labels[~np.in1d(fs.ids, ids_for_split1)] - features2 = fs.features[~np.in1d(fs.ids, ids_for_split1)] + labels2 = fs.labels[~np.in1d(fs.ids, ids_for_split1)] if fs.labels is not None else None + features2 = ( + fs.features[~np.in1d(fs.ids, ids_for_split1)] if fs.features is not None else None + ) else: ids2 = fs.ids[ids_for_split2] - labels2 = fs.labels[ids_for_split2] - features2 = fs.features[ids_for_split2] + labels2 = fs.labels[ids_for_split2] if fs.labels is not None else None + features2 = fs.features[ids_for_split2] if fs.features is not None else None fs1 = FeatureSet( f"{fs.name}_1", ids1, labels=labels1, features=features1, vectorizer=fs.vectorizer @@ -537,24 +629,30 @@ def split_by_ids(fs, ids_for_split1, ids_for_split2=None): return fs1, fs2 @staticmethod - def from_data_frame(df, name, labels_column=None, vectorizer=None): + def from_data_frame( + df: DataFrame, + name: str, + labels_column: Optional[str] = None, + vectorizer: Optional[Union[DictVectorizer, FeatureHasher]] = None, + ) -> "FeatureSet": """ - Helper function to create a ``FeatureSet`` instance from a `pandas.DataFrame`. + Create a ``FeatureSet`` instance from a `pandas.DataFrame`. + Will raise an Exception if pandas is not installed in your environment. The ``ids`` in the ``FeatureSet`` will be the index from the given frame. Parameters ---------- - df : pd.DataFrame + df : pandas.DataFrame The pandas.DataFrame object to use as a ``FeatureSet``. name : str The name of the output ``FeatureSet`` instance. - labels_column : str, default=None + labels_column : Optional[str], default=None The name of the column containing the labels (data to predict). - vectorizer : DictVectorizer or FeatureHasher, default=None + vectorizer : Optional[Union[DictVectorizer, FeatureHasher]], default=None Vectorizer which will be used to generate the feature matrix. Returns diff --git a/skll/data/readers.py b/skll/data/readers.py index c3067db7..b520cbde 100644 --- a/skll/data/readers.py +++ b/skll/data/readers.py @@ -47,10 +47,11 @@ import logging import re import sys -from csv import DictReader from io import StringIO from itertools import chain +from numbers import Number from pathlib import Path +from typing import IO, Any, Dict, List, Optional, Tuple, Union import numpy as np import pandas as pd @@ -59,6 +60,9 @@ from skll.data import FeatureSet from skll.data.dict_vectorizer import DictVectorizer +from skll.types import ClassMap, FeatGenerator, FeatureDictList, IdType, LabelType, PathOrStr + +# define some custom types for readability class Reader(object): @@ -67,7 +71,7 @@ class Reader(object): Parameters ---------- - path_or_list : Union[str, Path, List[Dict[str, Any]] + path_or_list : Union[PathOrStr, List[Dict[str, Any]] Path or a list of example dictionaries. quiet : bool, default=True @@ -88,10 +92,12 @@ class Reader(object): If no column with that name exists, or ``None`` is specified, example IDs will be automatically generated. - class_map : dict, default=None + class_map : Optional[ClassMap], default=None Mapping from original class labels to new ones. This is mainly used for collapsing multiple labels into a single class. Anything not in the mapping will be kept the same. + The keys are the new labels and the list of values for each + key is the labels to be collapsed to said new label. sparse : bool, default=True Whether or not to store the features in a numpy CSR @@ -115,12 +121,12 @@ class Reader(object): def __init__( self, - path_or_list, + path_or_list: Union[PathOrStr, FeatureDictList], quiet=True, ids_to_floats=False, label_col="y", id_col="id", - class_map=None, + class_map: Optional[ClassMap] = None, sparse=True, feature_hasher=False, num_features=None, @@ -137,6 +143,7 @@ def __init__( self._progress_msg = "" self._use_pandas = False + self.vectorizer: Union[DictVectorizer, FeatureHasher] if feature_hasher: self.vectorizer = FeatureHasher(n_features=num_features) else: @@ -144,7 +151,7 @@ def __init__( self.logger = logger if logger else logging.getLogger(__name__) @classmethod - def for_path(cls, path_or_list, **kwargs): + def for_path(cls, path_or_list: Union[PathOrStr, FeatureDictList], **kwargs) -> "Reader": """ Instantiate Reader sub-class based on the file extension. @@ -153,10 +160,10 @@ def for_path(cls, path_or_list, **kwargs): Parameters ---------- - path_or_list : Union[str, Path, List[Dict[str, Any]] + path_or_list : Union[PathOrStr, FeatureDictList] A path or list of example dictionaries. - kwargs : dict, optional + kwargs : Dict[str, Any], optional The arguments to the Reader object being instantiated. Returns @@ -208,7 +215,7 @@ def _sub_read(self, file): """ raise NotImplementedError - def _print_progress(self, progress_num, end="\r"): + def _print_progress(self, progress_num: Union[int, str], end="\r"): r""" Print out progress numbers in proper format. @@ -216,7 +223,7 @@ def _print_progress(self, progress_num, end="\r"): Parameters ---------- - progress_num + progress_num: int Progress indicator value. Usually either a line number or a percentage. Must be able to convert to string. @@ -229,7 +236,7 @@ def _print_progress(self, progress_num, end="\r"): print(f"{self._progress_msg}{progress_num:>15}", end=end, file=sys.stderr) sys.stderr.flush() - def _sub_read_rows(self, file): + def _sub_read_rows(self, file: PathOrStr) -> Tuple[np.ndarray, np.ndarray, FeatureDictList]: """ Read the file in row-by-row. @@ -240,8 +247,8 @@ def _sub_read_rows(self, file): Parameters ---------- - file : Union[str, Path] - The path to a file. + file : PathOrStr + The path to the input file. Returns ------- @@ -251,7 +258,7 @@ def _sub_read_rows(self, file): labels : np.array of shape (n_labels,) The labels array. - features : list of dicts + features : FeatureDictList The features dictionary. Raises @@ -266,8 +273,8 @@ def _sub_read_rows(self, file): If the example IDs are not unique. """ # Get labels and IDs - ids = [] - labels = [] + ids_list: List[IdType] = [] + labels_list: List[LabelType] = [] ex_num = 0 with open(file, encoding="utf-8") as f: for ex_num, (id_, class_, _) in enumerate(self._sub_read(f), start=1): @@ -281,8 +288,8 @@ def _sub_read_rows(self, file): f"ID {id_} could not be converted to" f" float in {self.path_or_list}" ) - ids.append(id_) - labels.append(class_) + ids_list.append(id_) + labels_list.append(class_) if ex_num % 100 == 0: self._print_progress(ex_num) self._print_progress(ex_num) @@ -293,8 +300,8 @@ def _sub_read_rows(self, file): raise ValueError("No features found in possibly empty file " f"'{self.path_or_list}'.") # Convert everything to numpy arrays - ids = np.array(ids) - labels = np.array(labels) + ids = np.array(ids_list) + labels = np.array(labels_list) def feat_dict_generator(): with open(self.path_or_list, encoding="utf-8") as f: @@ -309,7 +316,14 @@ def feat_dict_generator(): return ids, labels, features - def _parse_dataframe(self, df, id_col, label_col, replace_blanks_with=None, drop_blanks=False): + def _parse_dataframe( + self, + df: pd.DataFrame, + id_col: Optional[str], + label_col: Optional[str], + replace_blanks_with: Optional[Union[Number, Dict[str, Number]]] = None, + drop_blanks: Optional[bool] = False, + ) -> Tuple[np.ndarray, np.ndarray, FeatureDictList]: """ Parse the data frame into ids, labels, and features. @@ -321,21 +335,22 @@ def _parse_dataframe(self, df, id_col, label_col, replace_blanks_with=None, drop Parameters ---------- - df : pd.DataFrame + df : pandas.DataFrame The pandas data frame to parse. - id_col : str or None + id_col : Optional[str] The id column. - label_col : str or None + label_col : Optional[str] The label column. - replace_blanks_with : Number, dict, or None, default=None + replace_blanks_with : Optional[Union[Number, Dict[str, Number]]], default=None Specifies a new value with which to replace blank values. Options are: - ``Number`` : A (numeric) value with which to replace blank values. - - ``dict`` : A dictionary specifying the replacement value for each column. + - ``Dict[str, Number]`` : A dictionary specifying the replacemen + value for each column. - ``None`` : Blank values will be left as blanks, and not replaced. drop_blanks : bool, default=False @@ -350,7 +365,7 @@ def _parse_dataframe(self, df, id_col, label_col, replace_blanks_with=None, drop labels : np.array of shape (n_labels,) The labels for the feature set. - features : list of dicts + features : FeatureDictList The features for the feature set. """ if df.empty: @@ -424,7 +439,7 @@ def _parse_dataframe(self, df, id_col, label_col, replace_blanks_with=None, drop return ids, labels, features - def read(self): + def read(self) -> FeatureSet: """ Load examples from various file formats. @@ -433,7 +448,7 @@ def read(self): Returns ------- - feature_set : skll.data.FeatureSet + skll.data.FeatureSet ``FeatureSet`` instance representing the input file. Raises @@ -454,6 +469,9 @@ def read(self): print(self._progress_msg, end="\r", file=sys.stderr) sys.stderr.flush() + # if we are in this method, self.path_or_file must be a path + assert isinstance(self.path_or_list, (str, Path)), "file path or path object required" + if self._use_pandas: ids, labels, features = self._sub_read(self.path_or_list) else: @@ -487,7 +505,7 @@ class DictListReader(Reader): a path to a file. """ - def read(self): + def read(self) -> FeatureSet: """ Read examples from list of dictionaries. @@ -496,11 +514,17 @@ def read(self): feature_set : skll.data.FeatureSet FeatureSet representing the list of dictionaries we read in. """ - ids = [] - labels = [] - feat_dicts = [] + # if we are in this method, `self.path_or_list` must be a + # list of dictionaries + assert isinstance(self.path_or_list, list) + + # initialize some variables + ids_list: List[IdType] = [] + labels_list: List[Optional[LabelType]] = [] + feat_dicts: FeatureDictList = [] + for example_num, example in enumerate(self.path_or_list): - curr_id = str(example.get("id", f"EXAMPLE_{example_num}")) + curr_id: Union[float, str] = str(example.get("id", f"EXAMPLE_{example_num}")) if self.ids_to_floats: try: curr_id = float(curr_id) @@ -525,8 +549,8 @@ def read(self): f"{curr_id} could not be converted to " f"float in {self.path_or_list}" ) - ids.append(curr_id) - labels.append(class_name) + ids_list.append(curr_id) + labels_list.append(class_name) feat_dicts.append(example) # Print out status @@ -534,8 +558,8 @@ def read(self): self._print_progress(example_num) # Convert lists to numpy arrays - ids = np.array(ids) - labels = np.array(labels) + ids = np.array(ids_list) + labels = np.array(labels_list) features = self.vectorizer.fit_transform(feat_dicts) return FeatureSet( @@ -551,7 +575,7 @@ class NDJReader(Reader): must be specified as the "id" key in each JSON dictionary. """ - def _sub_read(self, file): + def _sub_read(self, file) -> FeatGenerator: """ Iterate through the rows of the file buffer. @@ -562,14 +586,14 @@ def _sub_read(self, file): Yields ------ - curr_id : str + curr_id : IdType The current ID for the example. - class_name : float or str + class_name : Optional[LabelType] The name of the class label for the example. - example : dict - The example valued in dictionary format, with 'x' + example : FeatureDict + The example value in dictionary format, with 'x' as list of features. Raises @@ -590,8 +614,8 @@ def _sub_read(self, file): example = json.loads(line) # Convert all IDs to strings initially, # for consistency with csv formats. - curr_id = str(example.get("id", f"EXAMPLE_{example_num}")) - class_name = ( + curr_id: IdType = str(example.get("id", f"EXAMPLE_{example_num}")) + class_name: Optional[Union[float, str]] = ( safe_float(example["y"], replace_dict=self.class_map) if "y" in example else None ) example = example["x"] @@ -665,7 +689,7 @@ def _pair_to_tuple(pair, feat_map): value = safe_float(value) return (name, value) - def _sub_read(self, file): + def _sub_read(self, file: IO[str]) -> FeatGenerator: """ Parse rows of LibSVM file. @@ -676,13 +700,13 @@ def _sub_read(self, file): Yields ------ - curr_id : str + curr_id : IdType The current ID for the example. - class_name : float or str + class_ : LabelType The name of the class label for the example. - example : dict + example : FeatureDict The example valued in dictionary format, with 'x' as list of features. @@ -723,20 +747,26 @@ def _sub_read(self, file): if not curr_id: curr_id = f"EXAMPLE_{example_num}" + # the class can be either a float, an int, or a string; + # so we can use our `LabelType` type alias for this + class_: LabelType + + # get the class number class_num = match.group("label_num") + # If we have a mapping from class numbers to labels, get label if label_map: - class_name = label_map[class_num] + class_ = label_map[class_num] else: - class_name = class_num - class_name = safe_float(class_name, replace_dict=self.class_map) + class_ = class_num + class_ = safe_float(class_, replace_dict=self.class_map) curr_info_dict = dict( self._pair_to_tuple(pair, feat_map) for pair in match.group("features").strip().split() ) - yield curr_id, class_name, curr_info_dict + yield curr_id, class_, curr_info_dict class CSVReader(Reader): @@ -793,13 +823,13 @@ def __init__( self._engine = self._pandas_kwargs.pop("engine", "c") self._use_pandas = True - def _sub_read(self, file): + def _sub_read(self, file: PathOrStr) -> Tuple[np.ndarray, np.ndarray, FeatureDictList]: """ Parse rows of CSV file. Parameters ---------- - file : Union[str, Path] + file : PathOrStr The path to the CSV file. Returns @@ -810,8 +840,8 @@ def _sub_read(self, file): labels : np.array of shape (n_labels,) The labels for the feature set. - features : list of dicts - The features for the features set. + features : FeatureDictList + The list of feature dictionaries for the feature set. """ df = pd.read_csv(file, sep=self._sep, engine=self._engine, **self._pandas_kwargs) return self._parse_dataframe( @@ -879,89 +909,7 @@ def __init__( self._sep = str("\t") -class DelimitedReader(Reader): - """ - Create a ``FeatureSet`` instance from a delimited (CSV/TSV) file. - - If example/instance IDs are included in the files, they - must be specified in the ``id`` column. - For ARFF, CSV, and TSV files, there must be a column with the - name specified by ``label_col`` if the data is labeled. For ARFF files, - this column must also be the final one (as it is in Weka). - - Parameters - ---------- - path_or_list : str - The path to a delimited file. - - dialect : str, default='excel-tab' - The dialect of to pass on to the underlying CSV reader. - - kwargs : dict, optional - Other arguments to the Reader object. - """ - - def __init__(self, path_or_list, **kwargs): - """Initialize DelimitedReader class.""" - self.dialect = kwargs.pop("dialect", "excel-tab") - super(DelimitedReader, self).__init__(path_or_list, **kwargs) - - def _sub_read(self, file): - """ - Parse rows in delimited file. - - Parameters - ---------- - file : file buffer - A file buffer for an delimited file. - - Yields - ------ - curr_id : str - The current ID for the example. - - class_name : float or str - The name of the class label for the example. - - example : dict - The example valued in dictionary format, with 'x' - as list of features. - """ - reader = DictReader(file, dialect=self.dialect) - for example_num, row in enumerate(reader): - if self.label_col is not None and self.label_col in row: - class_name = safe_float(row[self.label_col], replace_dict=self.class_map) - del row[self.label_col] - else: - class_name = None - - if self.id_col not in row: - curr_id = f"EXAMPLE_{example_num}" - else: - curr_id = row[self.id_col] - del row[self.id_col] - - # Convert features to floats and if a feature is 0 - # then store the name of the feature so we can - # delete it later since we don't need to explicitly - # store zeros in the feature hash - columns_to_delete = [] - for fname, fval in row.items(): - fval_float = safe_float(fval) - # we don't need to explicitly store zeros - if fval_float: - row[fname] = fval_float - else: - columns_to_delete.append(fname) - - # remove the columns with zero values - for cname in columns_to_delete: - del row[cname] - - yield curr_id, class_name, row - - -class ARFFReader(DelimitedReader): +class ARFFReader(Reader): """ Create a ``FeatureSet`` instance from an ARFF file. @@ -981,13 +929,15 @@ class ARFFReader(DelimitedReader): def __init__(self, path_or_list, **kwargs): """Initialize ARFFReader class.""" - kwargs["dialect"] = "arff" super(ARFFReader, self).__init__(path_or_list, **kwargs) + self.dialect = "arff" self.relation = "" self.regression = False @staticmethod - def split_with_quotes(string, delimiter=" ", quote_char="'", escape_char="\\"): + def split_with_quotes( + string: str, delimiter=" ", quote_char="'", escape_char="\\" + ) -> List[str]: r""" Split strings but not on split delimiters enclosed in quotes. @@ -1009,24 +959,24 @@ def split_with_quotes(string, delimiter=" ", quote_char="'", escape_char="\\"): csv.reader([string], delimiter=delimiter, quotechar=quote_char, escapechar=escape_char) ) - def _sub_read(self, file): + def _sub_read(self, file: IO[str]) -> FeatGenerator: """ Parse rows of ARFF file. Parameters ---------- - file : file buffer + file : IO[str] A file buffer for the ARFF file. Yields ------ - curr_id : str + curr_id : IdType The current ID for the example. - class_name : float or str + class_name : LabelType The name of the class label for the example. - example : dict + example : FeatureDict The example valued in dictionary format, with 'x' as list of features. """ @@ -1071,11 +1021,47 @@ def _sub_read(self, file): if self.label_col != field_names[-1]: self.label_col = None - # Process data as CSV file - return super(ARFFReader, self)._sub_read(chain([field_str], file)) + # Process iterator as a CSV file + csv_file_buffer = chain([field_str], file) + reader = csv.DictReader(csv_file_buffer, dialect=self.dialect) + for example_num, row in enumerate(reader): + if self.label_col is not None and self.label_col in row: + class_name = safe_float(row[self.label_col], replace_dict=self.class_map) + del row[self.label_col] + else: + class_name = None + + if self.id_col not in row: + curr_id = f"EXAMPLE_{example_num}" + else: + curr_id = row[self.id_col] + del row[self.id_col] + + # Convert features to floats and if a feature is 0 + # then store the name of the feature so we can + # delete it later since we don't need to explicitly + # store zeros in the feature hash + columns_to_delete = [] + for fname, fval in row.items(): + fval_float = safe_float(fval) + # we don't need to explicitly store zeros + if fval_float: + row[fname] = fval_float + else: + columns_to_delete.append(fname) + + # remove the columns with zero values + for cname in columns_to_delete: + del row[cname] + + yield curr_id, class_name, row -def safe_float(text, replace_dict=None, logger=None): +def safe_float( + text: Any, + replace_dict: Optional[Dict[str, List[str]]] = None, + logger: Optional[logging.Logger] = None, +) -> Union[float, int, str]: """ Convert string to a float. @@ -1084,24 +1070,25 @@ def safe_float(text, replace_dict=None, logger=None): Parameters ---------- - text : str + text : Any The text to convert. - replace_dict : dict, default=None + replace_dict : Optional[Dict[str, List[str]]], default=None Mapping from text to replacement text values. This is mainly used for collapsing multiple labels into a single class. Replacing happens before conversion to floats. Anything not in the mapping will be kept the same. - logger : logging.Logger, default=None + logger : Optional[logging.Logger], default=None The Logger instance to use to log messages. Used instead of creating a new Logger instance by default. Returns ------- - text : int or float or str - The text value converted to int or float, if possible + Union[float, int, str] + The text value converted to int or float, if possible. Otherwise + it's a string. """ # convert to str to be "Safe"! text = str(text) diff --git a/skll/data/writers.py b/skll/data/writers.py index d8db15fc..6ccd21b0 100644 --- a/skll/data/writers.py +++ b/skll/data/writers.py @@ -15,10 +15,15 @@ from csv import DictWriter from decimal import Decimal from pathlib import Path +from typing import IO, Any, Dict, List, Optional, Set, Tuple, Union +import numpy as np import pandas as pd from scipy.sparse import issparse -from sklearn.feature_extraction import FeatureHasher +from sklearn.feature_extraction import DictVectorizer, FeatureHasher + +from skll.data import FeatureSet +from skll.types import FeatGenerator, FeatureDict, IdType, LabelType, PathOrStr class Writer(object): @@ -27,7 +32,7 @@ class Writer(object): Parameters ---------- - path : Union[str, Path] + path : PathOrStr A path to the feature file we would like to create. The suffix to this filename must be ``.arff``, ``.csv``, ``.jsonlines``, ``.libsvm``, ``.ndj``, or ``.tsv``. If ``subsets`` @@ -42,7 +47,7 @@ class Writer(object): quiet : bool, default=True Do not print "Writing..." status message to stderr. - subsets : dict (str to list of str), default=None + subsets : Optional[Dict[str, List[str]]], default=None A mapping from subset names to lists of feature names that are included in those sets. If given, a feature file will be written for every subset (with the name @@ -60,7 +65,7 @@ class Writer(object): a new one by default. """ - def __init__(self, path, feature_set, **kwargs): + def __init__(self, path: PathOrStr, feature_set: FeatureSet, **kwargs): """Initialize base Writer class.""" super(Writer, self).__init__() @@ -88,13 +93,13 @@ def __init__(self, path, feature_set, **kwargs): raise ValueError("Passed extra keyword arguments to Writer " f"constructor: {kwargs}") @classmethod - def for_path(cls, path, feature_set, **kwargs): + def for_path(cls, path: PathOrStr, feature_set: FeatureSet, **kwargs) -> "Writer": """ Retrieve object of ``Writer`` sub-class appropriate for given path. Parameters ---------- - path : Union[str, Path] + path : PathOrStr A path to the feature file we would like to create. The suffix to this filename must be ``.arff``, ``.csv``, ``.jsonlines``, ``.libsvm``, ``.ndj``, or @@ -107,13 +112,13 @@ def for_path(cls, path, feature_set, **kwargs): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance to dump to the output file. - kwargs : dict + kwargs : Dict[str, Any], optional The keyword arguments for ``for_path`` are the same as the initializer for the desired ``Writer`` subclass. Returns ------- - writer : skll.data.writers.Writer + writer : skll.data.Writer New instance of the Writer sub-class that is appropriate for the given path. """ @@ -130,7 +135,7 @@ def for_path(cls, path, feature_set, **kwargs): ext = suffix.lower() return EXT_TO_WRITER[ext](path, feature_set, **kwargs) - def write(self): + def write(self) -> None: """Write out this Writer's ``FeatureSet`` to a file in its format.""" if isinstance(self.feat_set.vectorizer, FeatureHasher): raise ValueError( @@ -139,7 +144,7 @@ def write(self): # Write one feature file if we weren't given a dict of subsets if self.subsets is None: - self._write_subset(self.path, None) + self._write_subset(self.path) # Otherwise write one feature file per subset else: @@ -148,17 +153,19 @@ def write(self): sub_path = self.root / f"{subset_name}{self.ext}" self._write_subset(sub_path, set(filter_features)) - def _write_subset(self, sub_path, filter_features): + def _write_subset( + self, sub_path: PathOrStr, filter_features: Optional[Set[str]] = None + ) -> None: """ Write out given ``FeatureSet`` instance to a file in this class's format. Parameters ---------- - sub_path : Union[str, Path] + sub_path : PathOrStr The path to the file we want to create for this subset of our data. - filter_features : set of str + filter_features : Optional[Set[str]], default=None Set of features to include in current feature file. """ self.logger.debug(f"sub_path: {sub_path}") @@ -172,7 +179,7 @@ def _write_subset(self, sub_path, filter_features): if not self._use_pandas: # Apply filtering - filtered_set = ( + filtered_set: Union[FeatGenerator, FeatureSet] = ( self.feat_set.filtered_iter(features=filter_features) if filter_features is not None else self.feat_set @@ -207,10 +214,10 @@ def _write_header(self, feature_set, output_file, filter_features): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance being written to a file. - output_file : file buffer + output_file : IO[str] The file being written to. - filter_features : set of str + filter_features : Set[str] If only writing a subset of the features in the FeatureSet to ``output_file``, these are the features to include in this file. @@ -223,16 +230,16 @@ def _write_line(self, id_, label_, feat_dict, output_file): Parameters ---------- - id_ : str + id_ : IdType The ID for the current instance. label_ : str The label for the current instance. - feat_dict : dict + feat_dict : FeatureDict The feature dictionary for the current instance. - output_file : file buff + output_file : IO[str] The file being written to. Raises @@ -250,10 +257,10 @@ def _write_data(self, feature_set, output_file, filter_features): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance being written to a file. - output_file : file buffer + output_file : IO[str] The file being written to. - filter_features : set of str + filter_features : Set[str] If only writing a subset of the features in the FeatureSet to ``output_file``, these are the features to include in this file. @@ -264,7 +271,9 @@ def _write_data(self, feature_set, output_file, filter_features): """ raise NotImplementedError - def _get_column_names_and_indexes(self, feature_set, filter_features=None): + def _get_column_names_and_indexes( + self, feature_set: FeatureSet, filter_features: Optional[Set[str]] = None + ) -> Tuple[List[str], List[int]]: """ Get names of columns and associated indices for (possibly filtered) features. @@ -273,43 +282,86 @@ def _get_column_names_and_indexes(self, feature_set, filter_features=None): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance being written to a file. - filter_features : set of str + filter_features : Optional[Set[str]], default=None If only writing a subset of the features in the FeatureSet to ``output_file``, these are the features to include in this file. Returns ------- - column_names : list of str - A list of the (possibly - filtered) column names. + column_names : List[str] + A list of the (possibly filtered) column names. - column_indexes : list of int - A list of the (possibly - filtered) column indexes. + column_indexes : List[int] + A list of the (possibly filtered) column indexes. """ # if we're not doing filtering, # then just take all the feature names self.logger.debug(feature_set) - if filter_features is None: - filter_features = feature_set.vectorizer.feature_names_ - - # create a list of tuples with (column names, column indexes) - # so that we can correctly extract the appropriate columns - columns = sorted( - [ - (col_name, col_idx) - for col_name, col_idx in feature_set.vectorizer.vocabulary_.items() - if (col_name in filter_features or col_name.split("=", 1)[0] in filter_features) - ], - key=lambda x: x[1], - ) + if isinstance(feature_set.vectorizer, DictVectorizer): + if filter_features is None: + filter_features = feature_set.vectorizer.feature_names_ + + # create a list of tuples with (column names, column indexes) + # so that we can correctly extract the appropriate columns + columns = sorted( + [ + (col_name, col_idx) + for col_name, col_idx in feature_set.vectorizer.vocabulary_.items() + if (col_name in filter_features or col_name.split("=", 1)[0] in filter_features) + ], + key=lambda x: x[1], + ) + + # then, split the names and indexes into separate lists + column_names, column_indexes = zip(*columns) + return list(column_names), list(column_indexes) + else: + return [], [] + + +class CSVWriter(Writer): + """ + Writer for writing out ``FeatureSet`` instances as CSV files. + + Parameters + ---------- + path : PathOrStr + A path to the feature file we would like to create. + If ``subsets`` is not ``None``, this is assumed to be a string + containing the path to the directory to write the feature + files with an additional file extension specifying the file + type. For example ``/foo/.csv``. + + feature_set : skll.data.FeatureSet + The ``FeatureSet`` instance to dump to the output file. - # then, split the names and indexes into separate lists - column_names, column_indexes = zip(*columns) - return list(column_names), list(column_indexes) + pandas_kwargs : Optional[Dict[str], Any], default=None + Arguments that will be passed directly to the `pandas` I/O reader. - def _build_dataframe_with_features(self, feature_set, filter_features=None): + kwargs : Optional[Dict[str, Any]], optional + The arguments to the ``Writer`` object being instantiated. + """ + + def __init__( + self, + path: PathOrStr, + feature_set: FeatureSet, + pandas_kwargs: Optional[Dict[str, Any]] = None, + **kwargs, + ): + """Initialize the CSVWriter class.""" + self.label_col = kwargs.pop("label_col", "y") + self.id_col = kwargs.pop("id_col", "id") + super(CSVWriter, self).__init__(path, feature_set, **kwargs) + self._pandas_kwargs = {} if pandas_kwargs is None else pandas_kwargs + self._sep = self._pandas_kwargs.pop("sep", ",") + self._index = self._pandas_kwargs.pop("index", False) + self._use_pandas = True + + def _build_dataframe_with_features( + self, feature_set: FeatureSet, filter_features: Optional[Set[str]] = None + ) -> pd.DataFrame: """ Create and filter data frame from features in given feature set. @@ -318,16 +370,16 @@ def _build_dataframe_with_features(self, feature_set, filter_features=None): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance being written to a file. - filter_features : set of str, default=None + filter_features : Optional[Set[str]], default=None If only writing a subset of the features in the FeatureSet to ``output_file``, these are the features to include in this file. Returns ------- - df_features : pd.DataFrame - The data frame constructed from - the feature set. + df_features : pandas.DataFrame + The data frame constructed from the feature set. The frame may be + empty are not features in the feature set. Raises ------ @@ -343,15 +395,23 @@ def _build_dataframe_with_features(self, feature_set, filter_features=None): # create the data frame from the feature set; # then, select only the columns that we want, # and give the columns their correct names - if issparse(feature_set.features): - df_features = pd.DataFrame(feature_set.features.toarray()) + if feature_set.features is not None: + if issparse(feature_set.features): + df_features = pd.DataFrame(feature_set.features.toarray()) + else: + df_features = pd.DataFrame(feature_set.features) + df_features = df_features.iloc[:, column_idxs].copy() + df_features.columns = column_names + return df_features else: - df_features = pd.DataFrame(feature_set.features) - df_features = df_features.iloc[:, column_idxs].copy() - df_features.columns = column_names - return df_features - - def _build_dataframe(self, feature_set, filter_features=None, df_features=None): + return pd.DataFrame() + + def _build_dataframe( + self, + feature_set: FeatureSet, + filter_features: Optional[Set[str]] = None, + df_features: Optional[pd.DataFrame] = None, + ) -> pd.DataFrame: """ Create and filter data frame with features in given feature set. @@ -364,12 +424,12 @@ def _build_dataframe(self, feature_set, filter_features=None, df_features=None): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance being written to a file. - filter_features : set of str, default=None + filter_features : Optional[Set[str]], default=None If only writing a subset of the features in the FeatureSet to ``output_file``, these are the features to include in this file. - df_features : pd.DataFrame, default=None + df_features : Optional[pandas.DataFrame], default=None If the data frame with features already exists, then we use it and add IDs and labels; otherwise, the feature data frame will be created from the feature set. @@ -377,8 +437,7 @@ def _build_dataframe(self, feature_set, filter_features=None, df_features=None): Returns ------- df_features : pd.DataFrame - The data frame constructed from - the feature set. + The data frame constructed from the feature set. Raises ------ @@ -394,7 +453,7 @@ def _build_dataframe(self, feature_set, filter_features=None, df_features=None): # if the id column is already in the data frame, # then raise an error; otherwise, just add the ids if self.id_col in df_features: - raise ValueError(f'ID column name "{self.id_col}" already used as' " feature name.") + raise ValueError(f"ID column name {self.id_col} already used as feature name.") df_features[self.id_col] = feature_set.ids # if the the labels should exist but the column is already @@ -408,42 +467,12 @@ def _build_dataframe(self, feature_set, filter_features=None, df_features=None): return df_features - -class CSVWriter(Writer): - """ - Writer for writing out ``FeatureSet`` instances as CSV files. - - Parameters - ---------- - path : str - A path to the feature file we would like to create. - If ``subsets`` is not ``None``, this is assumed to be a string - containing the path to the directory to write the feature - files with an additional file extension specifying the file - type. For example ``/foo/.csv``. - - feature_set : skll.data.FeatureSet - The ``FeatureSet`` instance to dump to the output file. - - pandas_kwargs : dict, default=None - Arguments that will be passed directly - to the `pandas` I/O reader. - - kwargs : dict, optional - The arguments to the ``Writer`` object being instantiated. - """ - - def __init__(self, path, feature_set, pandas_kwargs=None, **kwargs): - """Initialize the CSVWriter class.""" - self.label_col = kwargs.pop("label_col", "y") - self.id_col = kwargs.pop("id_col", "id") - super(CSVWriter, self).__init__(path, feature_set, **kwargs) - self._pandas_kwargs = {} if pandas_kwargs is None else pandas_kwargs - self._sep = self._pandas_kwargs.pop("sep", ",") - self._index = self._pandas_kwargs.pop("index", False) - self._use_pandas = True - - def _write_data(self, feature_set, output_file, filter_features): + def _write_data( + self, + feature_set: FeatureSet, + output_file: PathOrStr, + filter_features: Optional[Set[str]] = None, + ) -> None: """ Write the data in CSV format. @@ -452,15 +481,15 @@ def _write_data(self, feature_set, output_file, filter_features): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance being written to a file. - output_file : Union[str, Path] + output_file : PathOrStr The path of the file being written to - filter_features : set of str + filter_features : Optional[Set[str]], default=None If only writing a subset of the features in the FeatureSet to ``output_file``, these are the features to include in this file. """ - df = self._build_dataframe(feature_set, filter_features) + df = self._build_dataframe(feature_set, filter_features=filter_features) df.to_csv(output_file, sep=self._sep, index=self._index, **self._pandas_kwargs) @@ -470,7 +499,7 @@ class TSVWriter(CSVWriter): Parameters ---------- - path : str + path : PathOrStr A path to the feature file we would like to create. If ``subsets`` is not ``None``, this is assumed to be a string containing the path to the directory to write the feature @@ -480,15 +509,21 @@ class TSVWriter(CSVWriter): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance to dump to the output file. - pandas_kwargs : dict, default=None + pandas_kwargs : Optional[Dict[str, Any]], default=None Arguments that will be passed directly to the `pandas` I/O reader. - kwargs : dict, optional + kwargs : Optional[Dict[str, Any]], optional The arguments to the ``Writer`` object being instantiated. """ - def __init__(self, path, feature_set, pandas_kwargs=None, **kwargs): + def __init__( + self, + path: PathOrStr, + feature_set: FeatureSet, + pandas_kwargs: Optional[Dict[str, Any]] = None, + **kwargs, + ): """Initialize the TSVWriter class.""" super(TSVWriter, self).__init__(path, feature_set, pandas_kwargs, **kwargs) self._sep = str("\t") @@ -500,7 +535,7 @@ class ARFFWriter(Writer): Parameters ---------- - path : str + path : PathOrStr A path to the feature file we would like to create. If ``subsets`` is not ``None``, this is assumed to be a string containing the path to the directory to write the feature @@ -516,11 +551,11 @@ class ARFFWriter(Writer): regression : bool, default=False Is this an ARFF file to be used for regression? - kwargs : dict, optional + kwargs : Optional[Dict[str, Any]], optional The arguments to the ``Writer`` object being instantiated. """ - def __init__(self, path, feature_set, **kwargs): + def __init__(self, path: PathOrStr, feature_set: FeatureSet, **kwargs): """Initialize the ARFFWRiter class.""" self.relation = kwargs.pop("relation", "skll_relation") self.regression = kwargs.pop("regression", False) @@ -528,9 +563,9 @@ def __init__(self, path, feature_set, **kwargs): self.label_col = kwargs.pop("label_col", "y") self.id_col = kwargs.pop("id_col", "id") super(ARFFWriter, self).__init__(path, feature_set, **kwargs) - self._dict_writer = None + self._dict_writer: Optional[DictWriter[str]] = None - def _write_header(self, feature_set, output_file, filter_features): + def _write_header(self, feature_set: FeatureSet, output_file: IO[str], filter_features) -> None: """ Write headers to ARFF file. @@ -566,7 +601,8 @@ def _write_header(self, feature_set, output_file, filter_features): print(f"@attribute {self.label_col} numeric", file=output_file) else: if self.feat_set.has_labels: - labels_str = ",".join(list(map(str, sorted(set(self.feat_set.labels))))) + sorted_features = sorted(set(self.feat_set.labels)) # type: ignore + labels_str = ",".join([str(feat) for feat in sorted_features]) labels_str = "{" + labels_str + "}" print(f"@attribute {self.label_col} {labels_str}", file=output_file) if self.label_col: @@ -581,22 +617,24 @@ def _write_header(self, feature_set, output_file, filter_features): # Finish header and start data section print("\n@data", file=output_file) - def _write_line(self, id_, label_, feat_dict, output_file): + def _write_line( + self, id_: IdType, label_: LabelType, feat_dict: FeatureDict, output_file: IO[str] + ) -> None: """ Write the current line in the file in this Writer's format. Parameters ---------- - id_ : str + id_ : IdType The ID for the current instance. - label_ : str + label_ : LabelType The label for the current instance. - feat_dict : dict + feat_dict : FeatureDict The feature dictionary for the current instance. - output_file : file buffer + output_file : IO[str] The file being written to. Raises @@ -620,8 +658,10 @@ def _write_line(self, id_, label_, feat_dict, output_file): feat_dict[self.id_col] = id_ else: raise ValueError(f'ID column name "{self.id_col}" already used as' " feature name.") + # Write out line - self._dict_writer.writerow(feat_dict) + if self._dict_writer: + self._dict_writer.writerow(feat_dict) class NDJWriter(Writer): @@ -630,7 +670,7 @@ class NDJWriter(Writer): Parameters ---------- - path : Union[str, Path] + path : PathOrStr A path to the feature file we would like to create. If ``subsets`` is not ``None``, this is assumed to be a string containing the path to the directory to write the feature @@ -640,48 +680,54 @@ class NDJWriter(Writer): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance to dump to the output file. - kwargs : dict, optional + kwargs : Optional[Dict[str, Any]], optional The arguments to the ``Writer`` object being instantiated. """ - def __init__(self, path, feature_set, **kwargs): + def __init__(self, path: PathOrStr, feature_set: FeatureSet, **kwargs): """Initialize the NDJWriter class.""" super(NDJWriter, self).__init__(path, feature_set, **kwargs) - def _write_line(self, id_, label_, feat_dict, output_file): + def _write_line( + self, + id_: IdType, + label_: Union[LabelType, np.int64, np.float64], + feat_dict: FeatureDict, + output_file: IO[str], + ) -> None: """ Write the current line in the file in NDJ format. Parameters ---------- - id_ : str + id_ : IdType The ID for the current instance. - label_ : str + label_ : LabelType The label for the current instance. - feat_dict : dict + feat_dict : FeatureDict The feature dictionary for the current instance. - output_file : file buffer + output_file : IO[str] The file being written to. """ - example_dict = {} + example_dict: FeatureDict = {} # Don't try to add class column if this is label-less data # Try to convert the label to a scalar assuming it'a numpy # non-scalar type (e.g., int64) but if that doesn't work # then use it as is if self.feat_set.has_labels: - try: + if hasattr(label_, "item"): example_dict["y"] = label_.item() - except AttributeError: + else: example_dict["y"] = label_ # Try to convert the ID to a scalar assuming it'a numpy # non-scalar type (e.g., int64) but if that doesn't work # then use it as is - try: + if hasattr(id_, "item"): example_dict["id"] = id_.item() - except AttributeError: + else: example_dict["id"] = id_ example_dict["x"] = feat_dict print(json.dumps(example_dict, sort_keys=True), file=output_file) @@ -693,7 +739,7 @@ class LibSVMWriter(Writer): Parameters ---------- - path : str + path : PathOrStr A path to the feature file we would like to create. If ``subsets`` is not ``None``, this is assumed to be a string containing the path to the directory to write the feature @@ -703,7 +749,7 @@ class LibSVMWriter(Writer): feature_set : skll.data.FeatureSet The ``FeatureSet`` instance to dump to the output file. - kwargs : dict, optional + kwargs : Optional[Dict[str, Any]], optional The arguments to the ``Writer`` object being instantiated. """ @@ -715,88 +761,98 @@ class LibSVMWriter(Writer): "|": "\u2223", } - def __init__(self, path, feature_set, **kwargs): + def __init__(self, path: PathOrStr, feature_set: FeatureSet, **kwargs): """Initialize the LibSVMWriter class.""" self.label_map = kwargs.pop("label_map", None) super(LibSVMWriter, self).__init__(path, feature_set, **kwargs) if self.label_map is None: - self.label_map = {} - if feature_set.has_labels: - self.label_map = { - label: num - for num, label in enumerate( - sorted( - { - label - for label in feature_set.labels - if not isinstance(label, (int, float)) - } - ) + fs_labels = feature_set.labels if feature_set.has_labels else np.array([]) + self.label_map = { + label: num + for num, label in enumerate( + sorted( + { + label + for label in fs_labels # type: ignore + if not isinstance(label, (int, float)) + } ) - } + ) + } # Add fake item to vectorizer for None self.label_map[None] = "00000" @staticmethod - def _sanitize(name): + def _sanitize(name: Union[IdType, LabelType]) -> Union[IdType, LabelType]: """ Sanitize feature names for older feature formats. - Replace illegal characters in class names with close unicode + Replace special characters in names with close unicode equivalents to make things loadable in by LibSVM, LibLinear, or SVMLight. Parameters ---------- - name : str - The class names to replace with unicode equivalents. + name : Union[IdType, LabelType] + Input name in which special characters are replaced with unicode + equivalents. Returns ------- - name : str - The class names with unicode equivalent replacements. + Union[IdType, LabelType] + The sanitized name with special characters replaced. """ - if isinstance(name, str): + sanitized_name = name + if isinstance(sanitized_name, str): for orig, replacement in LibSVMWriter.LIBSVM_REPLACE_DICT.items(): - name = name.replace(orig, replacement) - return name + sanitized_name = sanitized_name.replace(orig, replacement) + return sanitized_name - def _write_line(self, id_, label_, feat_dict, output_file): + def _write_line( + self, id_: IdType, label_: LabelType, feat_dict: FeatureDict, output_file: IO[str] + ) -> None: """ Write the current line in the file in this Writer's format. Parameters ---------- - id_ : str + id_ : IdType The ID for the current instance. - label_ : str + label_ : LabelType The label for the current instance. - feat_dict : dict + feat_dict : FeatureDict The feature dictionary for the current instance. - output_file : file buffer + output_file : IO[str] The file being written to. """ - field_values = sorted( - [ - (self.feat_set.vectorizer.vocabulary_[field] + 1, value) - for field, value in feat_dict.items() - if Decimal(value) != 0 - ] + field_values = ( + sorted( + [ + (self.feat_set.vectorizer.vocabulary_[field] + 1, value) + for field, value in feat_dict.items() + if Decimal(value) != 0 + ] + ) + if self.feat_set.vectorizer + else [] ) + # Print label if label_ in self.label_map: print(self.label_map[label_], end=" ", file=output_file) else: print(label_, end=" ", file=output_file) + # Print features print( " ".join((f"{field}:{value}" for field, value in field_values)), end=" ", file=output_file, ) + # Print comment with id and mappings print("#", end=" ", file=output_file) print(self._sanitize(id_), end="", file=output_file) @@ -810,10 +866,15 @@ def _write_line(self, id_, label_, feat_dict, output_file): ) else: print(" |", end=" ", file=output_file) - line = " ".join( - f"{self.feat_set.vectorizer.vocabulary_[field] + 1}=" f"{self._sanitize(field)}" - for field, value in feat_dict.items() - if Decimal(value) != 0 + + line = ( + " ".join( + f"{self.feat_set.vectorizer.vocabulary_[field] + 1}=" f"{self._sanitize(field)}" + for field, value in feat_dict.items() + if Decimal(value) != 0 + ) + if self.feat_set.vectorizer + else "" ) print(line, file=output_file) diff --git a/skll/types.py b/skll/types.py index e8dba2d9..30bc61ba 100644 --- a/skll/types.py +++ b/skll/types.py @@ -4,13 +4,39 @@ :author: Nitin Madnani (nmadnani@ets.org) """ - from pathlib import Path -from typing import Dict, Union +from typing import Any, Dict, Generator, List, Optional, Tuple, Union -# a string path or Path object -PathOrStr = Union[Path, str] +from scipy.sparse import csr_matrix + +# a class map that maps new labels (string) +# to list of old labels (list of string) +ClassMap = Dict[str, List[str]] + +# list of feature dictionaries +FeatureDict = Dict[str, Any] +FeatureDictList = List[FeatureDict] # a mapping from example ID to fold ID; # the example ID may be a float or a str FoldMapping = Dict[Union[float, str], str] + +# a float or a string; this is useful +# for SKLL IDs that can be both +IdType = Union[float, str] + +# a float, int, or a string; this is useful +# for SKLL labels that can be both +LabelType = Union[float, int, str] + +# a generator that yields a three-tuple: +# - an example ID (float or str) +# - a label (int, float, or str) +# - a feature dictionary +FeatGenerator = Generator[Tuple[IdType, Optional[LabelType], FeatureDict], None, None] + +# a string path or Path object +PathOrStr = Union[Path, str] + +# a sparse matrix for features +SparseFeatureMatrix = csr_matrix diff --git a/tests/test_featureset.py b/tests/test_featureset.py index 71216d7a..27db7915 100644 --- a/tests/test_featureset.py +++ b/tests/test_featureset.py @@ -10,7 +10,6 @@ import itertools from collections import OrderedDict -from io import StringIO from shutil import rmtree import numpy as np @@ -50,12 +49,23 @@ def tearDown(): file_names = [ f"{x}.jsonlines" for x in ["test_string_ids", "test_string_ids_df", "test_string_labels_df"] + ] + [ + "test_read_csv_tsv_drop_blanks.csv", + "test_read_csv_tsv_drop_blanks.tsv", + "test_read_csv_tsv_fill_blanks.csv", + "test_read_csv_tsv_fill_blanks.tsv", + "test_read_csv_tsv_fill_blanks_dict.csv", + "test_read_csv_tsv_fill_blanks_dict.tsv", + "test_drop_blanks_error.csv", ] + for file_name in file_names: unlink(other_dir / file_name) for dir_name in ["test_conversion", "test_merging"]: - rmtree(train_dir / dir_name) + path = train_dir / dir_name + if path.exists(): + rmtree(train_dir / dir_name) def _create_empty_file(filetype): @@ -64,6 +74,11 @@ def _create_empty_file(filetype): return filepath +def _create_test_file(filepath, contents): + with open(filepath, "w") as filefh: + filefh.write(contents) + + @raises(ValueError) def test_empty_ids(): """Test to ensure that an error is raised if ids is None.""" @@ -233,6 +248,51 @@ def test_vectorizer_inequality(): assert_not_equal(v, [1.0, 2.0, 3.0]) +@raises(ValueError) +def test_merge_no_vectorizers(): + """Test to ensure rejection of merging featuresets with no labels.""" + # create a featureset each with a DictVectorizer + fs1, _ = make_classification_data( + num_examples=100, num_features=4, num_labels=3, train_test_ratio=1.0 + ) + + # create another featureset using hashing + fs2, _ = make_classification_data( + num_examples=100, + num_features=4, + feature_prefix="g", + num_labels=3, + train_test_ratio=1.0, + use_feature_hashing=True, + ) + fs2.vectorizer = None + + # This should raise a ValueError + fs1 + fs2 + + +@raises(ValueError) +def test_merge_no_features(): + """Test to ensure rejection of merging featuresets with no labels.""" + # create a featureset each with a DictVectorizer + fs1, _ = make_classification_data( + num_examples=100, num_features=4, num_labels=3, train_test_ratio=1.0 + ) + fs1.features = None + + # create another featureset using hashing + fs2, _ = make_classification_data( + num_examples=100, + num_features=4, + feature_prefix="g", + num_labels=3, + train_test_ratio=1.0, + use_feature_hashing=True, + ) + # This should raise a ValueError + fs1 + fs2 + + @raises(ValueError) def test_merge_different_vectorizers(): """Test to ensure rejection of merging featuresets with different vectorizers.""" @@ -1054,10 +1114,17 @@ def test_reading_csv_and_tsv_with_drop_blanks(): fs_expected = FeatureSet.from_data_frame(expected, "test", labels_column="L") - fs_csv = CSVReader(StringIO(test_csv), drop_blanks=True, pandas_kwargs=kwargs).read() + # write out the test data + csv_path = other_dir / "test_read_csv_tsv_drop_blanks.csv" + _create_test_file(csv_path, test_csv) + + tsv_path = other_dir / "test_read_csv_tsv_drop_blanks.tsv" + _create_test_file(tsv_path, test_tsv) + + fs_csv = CSVReader(csv_path, drop_blanks=True, pandas_kwargs=kwargs).read() fs_csv.name = "test" - fs_tsv = TSVReader(StringIO(test_tsv), drop_blanks=True, pandas_kwargs=kwargs).read() + fs_tsv = TSVReader(tsv_path, drop_blanks=True, pandas_kwargs=kwargs).read() fs_tsv.name = "test" eq_(fs_csv, fs_expected) @@ -1092,10 +1159,17 @@ def test_reading_csv_and_tsv_with_fill_blanks(): fs_expected = FeatureSet.from_data_frame(expected, "test", labels_column="L") - fs_csv = CSVReader(StringIO(test_csv), replace_blanks_with=4.5, pandas_kwargs=kwargs).read() + # write out the test data + csv_path = other_dir / "test_read_csv_tsv_fill_blanks.csv" + _create_test_file(csv_path, test_csv) + + tsv_path = other_dir / "test_read_csv_tsv_fill_blanks.tsv" + _create_test_file(tsv_path, test_tsv) + + fs_csv = CSVReader(csv_path, replace_blanks_with=4.5, pandas_kwargs=kwargs).read() fs_csv.name = "test" - fs_tsv = TSVReader(StringIO(test_tsv), replace_blanks_with=4.5, pandas_kwargs=kwargs).read() + fs_tsv = TSVReader(tsv_path, replace_blanks_with=4.5, pandas_kwargs=kwargs).read() fs_tsv.name = "test" eq_(fs_csv, fs_expected) @@ -1130,15 +1204,18 @@ def test_reading_csv_and_tsv_with_fill_blanks_with_dictionary(): fs_expected = FeatureSet.from_data_frame(expected, "test", labels_column="L") + # write out the test data + csv_path = other_dir / "test_read_csv_tsv_fill_blanks_dict.csv" + _create_test_file(csv_path, test_csv) + + tsv_path = other_dir / "test_read_csv_tsv_fill_blanks_dict.tsv" + _create_test_file(tsv_path, test_tsv) + replacement_dict = {"A": 4.5, "B": 2.5, "C": 1} - fs_csv = CSVReader( - StringIO(test_csv), replace_blanks_with=replacement_dict, pandas_kwargs=kwargs - ).read() + fs_csv = CSVReader(csv_path, replace_blanks_with=replacement_dict, pandas_kwargs=kwargs).read() fs_csv.name = "test" - fs_tsv = TSVReader( - StringIO(test_tsv), replace_blanks_with=replacement_dict, pandas_kwargs=kwargs - ).read() + fs_tsv = TSVReader(tsv_path, replace_blanks_with=replacement_dict, pandas_kwargs=kwargs).read() fs_tsv.name = "test" eq_(fs_csv, fs_expected) @@ -1148,4 +1225,6 @@ def test_reading_csv_and_tsv_with_fill_blanks_with_dictionary(): @raises(ValueError) def test_drop_blanks_and_replace_blanks_with_raises_error(): test_csv = "1,1,6\n2,,2\n3,9,3\n,,\n,5,\n,,\n2,7,7" - CSVReader(StringIO(test_csv), replace_blanks_with=4.5, drop_blanks=True).read() + csv_path = other_dir / "test_drop_blanks_error.csv" + _create_test_file(csv_path, test_csv) + CSVReader(csv_path, replace_blanks_with=4.5, drop_blanks=True).read()