From 5f974ce719fb1df81871e3fdbe5bb8db6436f766 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 27 Feb 2023 13:23:45 -0800 Subject: [PATCH 01/35] adding linear search, faiss ann search, cached storage, and redis storage. Also refactoring indexer class for easing implementation of indexers that would depend on packages that include both search and storage. --- tensorflow_similarity/base_indexer.py | 435 ++++++++++++++++++ tensorflow_similarity/indexer.py | 344 +------------- tensorflow_similarity/search/__init__.py | 2 + tensorflow_similarity/search/faiss_search.py | 227 +++++++++ tensorflow_similarity/search/linear_search.py | 183 ++++++++ tensorflow_similarity/stores/__init__.py | 2 + tensorflow_similarity/stores/cached_store.py | 228 +++++++++ tensorflow_similarity/stores/redis_store.py | 191 ++++++++ 8 files changed, 1290 insertions(+), 322 deletions(-) create mode 100644 tensorflow_similarity/base_indexer.py create mode 100644 tensorflow_similarity/search/faiss_search.py create mode 100644 tensorflow_similarity/search/linear_search.py create mode 100644 tensorflow_similarity/stores/cached_store.py create mode 100644 tensorflow_similarity/stores/redis_store.py diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py new file mode 100644 index 00000000..ffafcdfe --- /dev/null +++ b/tensorflow_similarity/base_indexer.py @@ -0,0 +1,435 @@ +from abc import ABC, abstractmethod +import numpy as np +import tensorflow as tf +from .types import CalibrationResults, FloatTensor, Lookup, PandasDataFrame, Tensor +from collections.abc import Mapping, MutableMapping, Sequence +from .retrieval_metrics import RetrievalMetric +from .distances import Distance, distance_canonicalizer +from .evaluators import Evaluator, MemoryEvaluator +from .matchers import ClassificationMatch, make_classification_matcher +from .retrieval_metrics import RetrievalMetric +from .utils import unpack_lookup_distances, unpack_lookup_labels +from collections import defaultdict, deque + + +from .classification_metrics import ( + ClassificationMetric, + F1Score, + make_classification_metric, +) +from .matchers import ClassificationMatch, make_classification_matcher +from tabulate import tabulate + + + +class BaseIndexer(ABC): + def __init__(self, distance, embedding_output, embedding_size, evaluator, + stat_buffer_size): + distance = distance_canonicalizer(distance) + self.distance = distance # needed for save()/load() + self.embedding_output = embedding_output + self.embedding_size = embedding_size + + # internal structure naming + # FIXME support custom objects + self.evaluator_type = evaluator + + # stats configuration + self.stat_buffer_size = stat_buffer_size + + # calibration + self.is_calibrated = False + self.calibration_metric: ClassificationMetric = F1Score() + self.cutpoints: Mapping[str, Mapping[str, float | str]] = {} + self.calibration_thresholds: Mapping[str, np.ndarray] = {} + + return + + # evaluation related functions + def evaluate_retrieval( + self, + predictions: FloatTensor, + target_labels: Sequence[int], + retrieval_metrics: Sequence[RetrievalMetric], + verbose: int = 1, + ) -> dict[str, np.ndarray]: + """Evaluate the quality of the index against a test dataset. + + Args: + predictions: TF similarity model predictions, may be a multi-headed + output. + + target_labels: Sequence of the expected labels associated with the + embedded queries. + + retrieval_metrics: list of + [RetrievalMetric()](retrieval_metrics/overview.md) to compute. + + verbose (int, optional): Display results if set to 1 otherwise + results are returned silently. Defaults to 1. + + Returns: + Dictionary of metric results where keys are the metric names and + values are the metrics values. + """ + # Determine the maximum number of neighbors needed by the retrieval + # metrics because we do a single lookup. + k = 1 + for m in retrieval_metrics: + if not isinstance(m, RetrievalMetric): + raise ValueError( + m, + "is not a valid RetrivalMetric(). The " + "RetrivialMetric() must be instantiated with " + "a valid K.", + ) + if m.k > k: + k = m.k + + # Add one more K to handle the case where we drop the closest lookup. + # This ensures that we always have enough lookups in the result set. + k += 1 + + # Find NN + lookups = self.batch_lookup(predictions, k=k, verbose=verbose) + + # Evaluate them + return self.evaluator.evaluate_retrieval( + retrieval_metrics=retrieval_metrics, + target_labels=target_labels, + lookups=lookups, + ) + + def evaluate_classification( + self, + predictions: FloatTensor, + target_labels: Sequence[int], + distance_thresholds: Sequence[float] | FloatTensor, + metrics: Sequence[str | ClassificationMetric] = ["f1"], + matcher: str | ClassificationMatch = "match_nearest", + k: int = 1, + verbose: int = 1, + ) -> dict[str, np.ndarray]: + """Evaluate the classification performance. + + Compute the classification metrics given a set of queries, lookups, and + distance thresholds. + + Args: + predictions: TF similarity model predictions, may be a multi-headed + output. + + target_labels: Sequence of expected labels for the lookups. + + distance_thresholds: A 1D tensor denoting the distances points at + which we compute the metrics. + + metrics: The set of classification metrics. + + matcher: {'match_nearest', 'match_majority_vote'} or + ClassificationMatch object. Defines the classification matching, + e.g., match_nearest will count a True Positive if the query_label + is equal to the label of the nearest neighbor and the distance is + less than or equal to the distance threshold. + + distance_rounding: How many digit to consider to + decide if the distance changed. Defaults to 8. + + verbose: Be verbose. Defaults to 1. + Returns: + A Mapping from metric name to the list of values computed for each + distance threshold. + """ + combined_metrics: list[ClassificationMetric] = [make_classification_metric(m) for m in metrics] + + lookups = self.batch_lookup(predictions, k=k, verbose=verbose) + + # we also convert to np.ndarray first to avoid a slow down if + # convert_to_tensor is called on a list. + query_labels = tf.convert_to_tensor(np.array(target_labels)) + + # TODO(ovallis): The float type should be derived from the model. + lookup_distances = unpack_lookup_distances(lookups, dtype="float32") + lookup_labels = unpack_lookup_labels(lookups, dtype=query_labels.dtype) + thresholds: FloatTensor = tf.cast( + tf.convert_to_tensor(distance_thresholds), + dtype=lookup_distances.dtype, + ) + + results = self.evaluator.evaluate_classification( + query_labels=query_labels, + lookup_labels=lookup_labels, + lookup_distances=lookup_distances, + distance_thresholds=thresholds, + metrics=combined_metrics, + matcher=matcher, + verbose=verbose, + ) + + return results + + def calibrate( + self, + predictions: FloatTensor, + target_labels: Sequence[int], + thresholds_targets: MutableMapping[str, float], + calibration_metric: str | ClassificationMetric = "f1_score", # noqa + k: int = 1, + matcher: str | ClassificationMatch = "match_nearest", + extra_metrics: Sequence[str | ClassificationMetric] = [ + "precision", + "recall", + ], # noqa + rounding: int = 2, + verbose: int = 1, + ) -> CalibrationResults: + """Calibrate model thresholds using a test dataset. + + FIXME: more detailed explanation. + + Args: + predictions: TF similarity model predictions, may be a multi-headed + output. + + target_labels: Sequence of the expected labels associated with the + embedded queries. + + thresholds_targets: Dict of performance targets to (if possible) + meet with respect to the `calibration_metric`. + + calibration_metric: [ClassificationMetric()](metrics/overview.md) + used to evaluate the performance of the index. + + k: How many neighbors to use during the calibration. + Defaults to 1. + + matcher: {'match_nearest', 'match_majority_vote'} or + ClassificationMatch object. Defines the classification matching, + e.g., match_nearest will count a True Positive if the query_label + is equal to the label of the nearest neighbor and the distance is + less than or equal to the distance threshold. + Defaults to 'match_nearest'. + + extra_metrics: list of additional + `tf.similarity.classification_metrics.ClassificationMetric()` to + compute and report. Defaults to ['precision', 'recall']. + + rounding: Metric rounding. Default to 2 digits. + + verbose: Be verbose and display calibration results. Defaults to 1. + + Returns: + CalibrationResults containing the thresholds and cutpoints Dicts. + """ + + # find NN + lookups = self.batch_lookup(predictions, k=k, verbose=verbose) + + # making sure our metrics are all ClassificationMetric objects + calibration_metric = make_classification_metric(calibration_metric) + + combined_metrics: list[ClassificationMetric] = [make_classification_metric(m) for m in extra_metrics] + + # running calibration + calibration_results = self.evaluator.calibrate( + target_labels=target_labels, + lookups=lookups, + thresholds_targets=thresholds_targets, + calibration_metric=calibration_metric, + matcher=matcher, + extra_metrics=combined_metrics, + metric_rounding=rounding, + verbose=verbose, + ) + + # display cutpoint results if requested + if verbose: + headers = ["name", "value", "distance"] # noqa + cutpoints = list(calibration_results.cutpoints.values()) + # dynamically find which metrics we need. We only need to look at + # the first cutpoints dictionary as all subsequent ones will have + # the same metric keys. + for metric_name in cutpoints[0].keys(): + if metric_name not in headers: + headers.append(metric_name) + + rows = [] + for data in cutpoints: + rows.append([data[v] for v in headers]) + print("\n", tabulate(rows, headers=headers)) + + # store info for serialization purpose + self.is_calibrated = True + self.calibration_metric = calibration_metric + self.cutpoints = calibration_results.cutpoints + self.calibration_thresholds = calibration_results.thresholds + return calibration_results + + def match( + self, + predictions: FloatTensor, + no_match_label: int = -1, + k=1, + matcher: str | ClassificationMatch = "match_nearest", + verbose: int = 1, + ) -> dict[str, list[int]]: + """Match embeddings against the various cutpoints thresholds + + Args: + predictions: TF similarity model predictions, may be a multi-headed + output. + + no_match_label: What label value to assign when there is no match. + Defaults to -1. + + k: How many neighboors to use during the calibration. + Defaults to 1. + + matcher: {'match_nearest', 'match_majority_vote'} or + ClassificationMatch object. Defines the classification matching, + e.g., match_nearest will count a True Positive if the query_label + is equal to the label of the nearest neighbor and the distance is + less than or equal to the distance threshold. + + verbose: display progression. Default to 1. + + Notes: + + 1. It is up to the [`SimilarityModel.match()`](similarity_model.md) + code to decide which of cutpoints results to use / show to the + users. This function returns all of them as there is little + performance downside to do so and it makes the code clearer + and simpler. + + 2. The calling function is responsible to return the list of class + matched to allows implementation to use additional criteria if they + choose to. + + Returns: + Dict of cutpoint names mapped to lists of matches. + """ + matcher = make_classification_matcher(matcher) + + lookups = self.batch_lookup(predictions, k=k, verbose=verbose) + + lookup_distances = unpack_lookup_distances(lookups, dtype=predictions.dtype) + # TODO(ovallis): The int type should be derived from the model. + lookup_labels = unpack_lookup_labels(lookups, dtype="int32") + + if verbose: + pb = tqdm( + total=len(lookup_distances) * len(self.cutpoints), + desc="matching embeddings", + ) + + matches: defaultdict[str, list[int]] = defaultdict(list) + for cp_name, cp_data in self.cutpoints.items(): + distance_threshold = float(cp_data["distance"]) + + pred_labels, pred_dist = matcher.derive_match( + lookup_labels=lookup_labels, lookup_distances=lookup_distances + ) + + for label, distance in zip(pred_labels, pred_dist): + if distance <= distance_threshold: + label = int(label) + else: + label = no_match_label + + matches[cp_name].append(label) + + if verbose: + pb.update() + + if verbose: + pb.close() + + return matches + + @abstractmethod + def add( + self, + prediction: FloatTensor, + label: int | None = None, + data: Tensor = None, + build: bool = True, + verbose: int = 1, + ): + """Add a single embedding to the indexer + + Args: + prediction: TF similarity model prediction, may be a multi-headed + output. + + label: Label(s) associated with the + embedding. Defaults to None. + + data: Input data associated with + the embedding. Defaults to None. + + build: Rebuild the index after insertion. + Defaults to True. Set it to false if you would like to add + multiples batches/points and build it manually once after. + + verbose: Display progress if set to 1. + Defaults to 1. + """ + + @abstractmethod + def batch_add( + self, + predictions: FloatTensor, + labels: Sequence[int] | None = None, + data: Tensor | None = None, + build: bool = True, + verbose: int = 1, + ): + """Add a batch of embeddings to the indexer + + Args: + predictions: TF similarity model predictions, may be a multi-headed + output. + + labels: label(s) associated with the embedding. Defaults to None. + + datas: input data associated with the embedding. Defaults to None. + + build: Rebuild the index after insertion. + Defaults to True. Set it to false if you would like to add + multiples batches/points and build it manually once after. + + verbose: Display progress if set to 1. Defaults to 1. + """ + + @abstractmethod + def single_lookup(self, prediction: FloatTensor, k: int = 5) -> list[Lookup]: + """Find the k closest matches of a given embedding + + Args: + prediction: TF similarity model prediction, may be a multi-headed + output. + + k: Number of nearest neighbors to lookup. Defaults to 5. + Returns + list of the k nearest neighbors info: + list[Lookup] + """ + + + @abstractmethod + def batch_lookup(self, predictions: FloatTensor, k: int = 5, verbose: int = 1) -> list[list[Lookup]]: + + """Find the k closest matches for a set of embeddings + + Args: + predictions: TF similarity model predictions, may be a multi-headed + output. + + k: Number of nearest neighbors to lookup. Defaults to 5. + + verbose: Be verbose. Defaults to 1. + + Returns + list of list of k nearest neighbors: + list[list[Lookup]] + """ diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 3fe72247..f2e0518c 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -21,6 +21,18 @@ from collections.abc import Mapping, MutableMapping, Sequence from pathlib import Path from time import time +from .base_indexer import BaseIndexer +from typing import ( + DefaultDict, + Deque, + Dict, + List, + Mapping, + MutableMapping, + Optional, + Sequence, + Union, +) import numpy as np import tensorflow as tf @@ -44,7 +56,7 @@ from .utils import unpack_lookup_distances, unpack_lookup_labels -class Indexer: +class Indexer(BaseIndexer): """Indexing system that allows to efficiently find nearest embeddings by indexing known embeddings and make them searchable using an [Approximate Nearest Neighbors Search] @@ -67,11 +79,11 @@ class Indexer: def __init__( self, embedding_size: int, - distance: Distance | str = "cosine", - search: Search | str = "nmslib", - kv_store: Store | str = "memory", - evaluator: Evaluator | str = "memory", - embedding_output: int | None = None, + distance: Union[Distance, str] = "cosine", + search: Union[Search, str] = "nmslib", + kv_store: Union[Store, str] = "memory", + evaluator: Union[Evaluator, str] = "memory", + embedding_output: int = None, stat_buffer_size: int = 1000, ) -> None: """Index embeddings to make them searchable via KNN @@ -104,26 +116,12 @@ def __init__( Raises: ValueError: Invalid search framework or key value store. """ - distance = distance_canonicalizer(distance) - self.distance = distance # needed for save()/load() - self.embedding_output = embedding_output - self.embedding_size = embedding_size - + super().__init__(distance, embedding_output, embedding_size, evaluator, + stat_buffer_size) # internal structure naming # FIXME support custom objects self.search_type = search self.kv_store_type = kv_store - self.evaluator_type = evaluator - - # stats configuration - self.stat_buffer_size = stat_buffer_size - - # calibration - self.is_calibrated = False - self.calibration_metric: ClassificationMetric = F1Score() - self.cutpoints: Mapping[str, Mapping[str, float | str]] = {} - self.calibration_thresholds: Mapping[str, np.ndarray] = {} - # initialize internal structures self._init_structures() @@ -136,6 +134,8 @@ def _init_structures(self) -> None: if self.search_type == "nmslib": self.search: Search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) + elif self.search_type == "linear": + self.search = LinearSearch(distance=self.distance, dim=embedding_size) elif isinstance(self.search_type, Search): self.search = self.search_type else: @@ -380,306 +380,6 @@ def batch_lookup(self, predictions: FloatTensor, k: int = 5, verbose: int = 1) - return batch_lookups - # evaluation related functions - def evaluate_retrieval( - self, - predictions: FloatTensor, - target_labels: Sequence[int], - retrieval_metrics: Sequence[RetrievalMetric], - verbose: int = 1, - ) -> dict[str, np.ndarray]: - """Evaluate the quality of the index against a test dataset. - - Args: - predictions: TF similarity model predictions, may be a multi-headed - output. - - target_labels: Sequence of the expected labels associated with the - embedded queries. - - retrieval_metrics: list of - [RetrievalMetric()](retrieval_metrics/overview.md) to compute. - - verbose (int, optional): Display results if set to 1 otherwise - results are returned silently. Defaults to 1. - - Returns: - Dictionary of metric results where keys are the metric names and - values are the metrics values. - """ - # Determine the maximum number of neighbors needed by the retrieval - # metrics because we do a single lookup. - k = 1 - for m in retrieval_metrics: - if not isinstance(m, RetrievalMetric): - raise ValueError( - m, - "is not a valid RetrivalMetric(). The " - "RetrivialMetric() must be instantiated with " - "a valid K.", - ) - if m.k > k: - k = m.k - - # Add one more K to handle the case where we drop the closest lookup. - # This ensures that we always have enough lookups in the result set. - k += 1 - - # Find NN - lookups = self.batch_lookup(predictions, k=k, verbose=verbose) - - # Evaluate them - return self.evaluator.evaluate_retrieval( - retrieval_metrics=retrieval_metrics, - target_labels=target_labels, - lookups=lookups, - ) - - def evaluate_classification( - self, - predictions: FloatTensor, - target_labels: Sequence[int], - distance_thresholds: Sequence[float] | FloatTensor, - metrics: Sequence[str | ClassificationMetric] = ["f1"], - matcher: str | ClassificationMatch = "match_nearest", - k: int = 1, - verbose: int = 1, - ) -> dict[str, np.ndarray]: - """Evaluate the classification performance. - - Compute the classification metrics given a set of queries, lookups, and - distance thresholds. - - Args: - predictions: TF similarity model predictions, may be a multi-headed - output. - - target_labels: Sequence of expected labels for the lookups. - - distance_thresholds: A 1D tensor denoting the distances points at - which we compute the metrics. - - metrics: The set of classification metrics. - - matcher: {'match_nearest', 'match_majority_vote'} or - ClassificationMatch object. Defines the classification matching, - e.g., match_nearest will count a True Positive if the query_label - is equal to the label of the nearest neighbor and the distance is - less than or equal to the distance threshold. - - distance_rounding: How many digit to consider to - decide if the distance changed. Defaults to 8. - - verbose: Be verbose. Defaults to 1. - Returns: - A Mapping from metric name to the list of values computed for each - distance threshold. - """ - combined_metrics: list[ClassificationMetric] = [make_classification_metric(m) for m in metrics] - - lookups = self.batch_lookup(predictions, k=k, verbose=verbose) - - # we also convert to np.ndarray first to avoid a slow down if - # convert_to_tensor is called on a list. - query_labels = tf.convert_to_tensor(np.array(target_labels)) - - lookup_distances = unpack_lookup_distances(lookups, dtype=tf.keras.backend.floatx()) - lookup_labels = unpack_lookup_labels(lookups, dtype=query_labels.dtype) - thresholds: FloatTensor = tf.cast( - tf.convert_to_tensor(distance_thresholds), - dtype=tf.keras.backend.floatx(), - ) - - results = self.evaluator.evaluate_classification( - query_labels=query_labels, - lookup_labels=lookup_labels, - lookup_distances=lookup_distances, - distance_thresholds=thresholds, - metrics=combined_metrics, - matcher=matcher, - verbose=verbose, - ) - - return results - - def calibrate( - self, - predictions: FloatTensor, - target_labels: Sequence[int], - thresholds_targets: MutableMapping[str, float], - calibration_metric: str | ClassificationMetric = "f1_score", # noqa - k: int = 1, - matcher: str | ClassificationMatch = "match_nearest", - extra_metrics: Sequence[str | ClassificationMetric] = [ - "precision", - "recall", - ], # noqa - rounding: int = 2, - verbose: int = 1, - ) -> CalibrationResults: - """Calibrate model thresholds using a test dataset. - - FIXME: more detailed explanation. - - Args: - predictions: TF similarity model predictions, may be a multi-headed - output. - - target_labels: Sequence of the expected labels associated with the - embedded queries. - - thresholds_targets: Dict of performance targets to (if possible) - meet with respect to the `calibration_metric`. - - calibration_metric: [ClassificationMetric()](metrics/overview.md) - used to evaluate the performance of the index. - - k: How many neighbors to use during the calibration. - Defaults to 1. - - matcher: {'match_nearest', 'match_majority_vote'} or - ClassificationMatch object. Defines the classification matching, - e.g., match_nearest will count a True Positive if the query_label - is equal to the label of the nearest neighbor and the distance is - less than or equal to the distance threshold. - Defaults to 'match_nearest'. - - extra_metrics: list of additional - `tf.similarity.classification_metrics.ClassificationMetric()` to - compute and report. Defaults to ['precision', 'recall']. - - rounding: Metric rounding. Default to 2 digits. - - verbose: Be verbose and display calibration results. Defaults to 1. - - Returns: - CalibrationResults containing the thresholds and cutpoints Dicts. - """ - - # find NN - lookups = self.batch_lookup(predictions, k=k, verbose=verbose) - - # making sure our metrics are all ClassificationMetric objects - calibration_metric = make_classification_metric(calibration_metric) - - combined_metrics: list[ClassificationMetric] = [make_classification_metric(m) for m in extra_metrics] - - # running calibration - calibration_results = self.evaluator.calibrate( - target_labels=target_labels, - lookups=lookups, - thresholds_targets=thresholds_targets, - calibration_metric=calibration_metric, - matcher=matcher, - extra_metrics=combined_metrics, - metric_rounding=rounding, - verbose=verbose, - ) - - # display cutpoint results if requested - if verbose: - headers = ["name", "value", "distance"] # noqa - cutpoints = list(calibration_results.cutpoints.values()) - # dynamically find which metrics we need. We only need to look at - # the first cutpoints dictionary as all subsequent ones will have - # the same metric keys. - for metric_name in cutpoints[0].keys(): - if metric_name not in headers: - headers.append(metric_name) - - rows = [] - for data in cutpoints: - rows.append([data[v] for v in headers]) - print("\n", tabulate(rows, headers=headers)) - - # store info for serialization purpose - self.is_calibrated = True - self.calibration_metric = calibration_metric - self.cutpoints = calibration_results.cutpoints - self.calibration_thresholds = calibration_results.thresholds - return calibration_results - - def match( - self, - predictions: FloatTensor, - no_match_label: int = -1, - k=1, - matcher: str | ClassificationMatch = "match_nearest", - verbose: int = 1, - ) -> dict[str, list[int]]: - """Match embeddings against the various cutpoints thresholds - - Args: - predictions: TF similarity model predictions, may be a multi-headed - output. - - no_match_label: What label value to assign when there is no match. - Defaults to -1. - - k: How many neighboors to use during the calibration. - Defaults to 1. - - matcher: {'match_nearest', 'match_majority_vote'} or - ClassificationMatch object. Defines the classification matching, - e.g., match_nearest will count a True Positive if the query_label - is equal to the label of the nearest neighbor and the distance is - less than or equal to the distance threshold. - - verbose: display progression. Default to 1. - - Notes: - - 1. It is up to the [`SimilarityModel.match()`](similarity_model.md) - code to decide which of cutpoints results to use / show to the - users. This function returns all of them as there is little - performance downside to do so and it makes the code clearer - and simpler. - - 2. The calling function is responsible to return the list of class - matched to allows implementation to use additional criteria if they - choose to. - - Returns: - Dict of cutpoint names mapped to lists of matches. - """ - matcher = make_classification_matcher(matcher) - - lookups = self.batch_lookup(predictions, k=k, verbose=verbose) - - lookup_distances = unpack_lookup_distances(lookups, dtype=tf.keras.backend.floatx()) - # TODO(ovallis): The int type should be derived from the model. - lookup_labels = unpack_lookup_labels(lookups, dtype="int32") - - if verbose: - pb = tqdm( - total=len(lookup_distances) * len(self.cutpoints), - desc="matching embeddings", - ) - - matches: defaultdict[str, list[int]] = defaultdict(list) - for cp_name, cp_data in self.cutpoints.items(): - distance_threshold = float(cp_data["distance"]) - - pred_labels, pred_dist = matcher.derive_match( - lookup_labels=lookup_labels, lookup_distances=lookup_distances - ) - - for label, distance in zip(pred_labels, pred_dist): - if distance <= distance_threshold: - label = int(label) - else: - label = no_match_label - - matches[cp_name].append(label) - - if verbose: - pb.update() - - if verbose: - pb.close() - - return matches - def save(self, path: str, compression: bool = True): """Save the index to disk diff --git a/tensorflow_similarity/search/__init__.py b/tensorflow_similarity/search/__init__.py index d1ac0b30..38466f2c 100644 --- a/tensorflow_similarity/search/__init__.py +++ b/tensorflow_similarity/search/__init__.py @@ -37,6 +37,8 @@ # Disable the INFO logging from NMSLIB logging.getLogger("nmslib").setLevel(logging.WARNING) +from .faiss_search import FaissSearch # noqa +from .linear_search import LinearSearch from .nmslib_search import NMSLibSearch # noqa from .search import Search # noqa from .utils import make_search # noqa diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py new file mode 100644 index 00000000..e4ac89b1 --- /dev/null +++ b/tensorflow_similarity/search/faiss_search.py @@ -0,0 +1,227 @@ +"""The module to handle FAISS search.""" + +from collections.abc import Mapping, Sequence +from termcolor import cprint +from .search import Search +import faiss +import numpy as np +from tensorflow_similarity.distances import Distance +from tensorflow_similarity.types import FloatTensor +from pathlib import Path +from typing import Any + + +class FaissSearch(Search): + """This class implements the Faiss ANN interface. + + It implements the Search interface. + """ + + def __init__( + self, + distance: Distance | str, + dim: int, + verbose: int = 0, + name: str | None = None, + algo="ivfpq", + m=8, + nbits=8, + nlist=1024, + nprobe=1, + normalize=True, + ): + """Initiate FAISS indexer + + Args: + d: number of dimensions + m: number of centroid IDs in final compressed vectors. d must be divisible + by m + nbits: number of bits in each centroid + nlist: how many Voronoi cells (must be greater than or equal to 2**nbits) + nprobe: how many of the nearest cells to include in search + """ + super().__init__(distance=distance, dim=dim, verbose=verbose, name=name) + self.algo = algo + self.m = m # number of bits per subquantizer + self.nbits = nbits + self.nlist = nlist + self.nprobe = nprobe + self.normalize = normalize + self.built = False + + if verbose: + t_msg = [ + "\n|-Initialize NMSLib Index", + f"| - algo: {self.algo}", + f"| - m: {self.m}", + f"| - nbits: {self.nbits}", + f"| - nlist: {self.nlist}", + f"| - nprobe: {self.nprobe}", + f"| - normalize: {self.normalize}", + f"| - query_params: {self.query_params}", + ] + cprint("\n".join(t_msg) + "\n", "green") + + if self.algo == "ivfpq": + assert dim % m == 0, f"dim={dim}, m={m}" + if self.algo == "ivfpq": + metric = faiss.METRIC_L2 + prefix = "" + if distance == "cosine": + prefix = "L2norm," + metric = faiss.METRIC_INNER_PRODUCT + # this distance requires both the input and query vectors to be normalized + ivf_string = f"IVF{nlist}," + pq_string = f"PQ{m}x{nbits}" + factory_string = prefix + ivf_string + pq_string + self.index = faiss.index_factory(dim, factory_string, metric) + # quantizer = faiss.IndexFlatIP( + # dim + # ) # we keep the same L2 distance flat index + # self.index = faiss.IndexIVFPQ( + # quantizer, dim, nlist, m, nbits, metric=faiss.METRIC_INNER_PRODUCT + # ) + # else: + # quantizer = faiss.IndexFlatL2( + # dim + # ) # we keep the same L2 distance flat index + # self.index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, nbits) + self.index.nprobe = nprobe # set how many of nearest cells to search + elif algo == "flat": + if distance == "cosine": + # this is exact match using cosine/dot-product Distance + self.index = faiss.IndexFlatIP(dim) + else: + # this is exact match using L2 distance + self.index = faiss.IndexFlatL2(dim) + + def is_built(self): + return self.built + + def needs_building(self): + if self.algo == "flat": + return False + else: + return not self.index.is_trained + + def build_index(self, samples, **kwargss): + if self.algo == "ivfpq": + if self.normalize: + faiss.normalize_L2(samples) + self.index.train(samples) # we must train the index to cluster into cells + self.built = True + + def batch_lookup( + self, embeddings: FloatTensor, k: int = 5 + ) -> tuple[list[list[int]], list[list[float]]]: + """Find embeddings K nearest neighboors embeddings. + + Args: + embedding: Batch of query embeddings as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + + if self.normalize: + faiss.normalize_L2(embeddings) + D, I = self.index.search(embeddings, k) + return I, D + + def lookup( + self, embedding: FloatTensor, k: int = 5 + ) -> tuple[list[int], list[float]]: + """Find embedding K nearest neighboors embeddings. + + Args: + embedding: Query embedding as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + int_embedding = np.array([embedding], dtype=np.float32) + if self.normalize: + faiss.normalize_L2(int_embedding) + D, I = self.index.search(int_embedding, k) + return I[0], D[0] + + def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): + """Add a single embedding to the search index. + + Args: + embedding: The embedding to index as computed by the similarity model. + idx: Embedding id as in the index table. Returned with the embedding to + allow to lookup the data associated with a given embedding. + """ + int_embedding = np.array([embedding], dtype=np.float32) + if self.normalize: + faiss.normalize_L2(int_embedding) + if self.algo != "flat": + self.index.add_with_ids(int_embedding) + else: + self.index.add(int_embedding) + + def batch_add( + self, + embeddings: FloatTensor, + idxs: Sequence[int], + verbose: int = 1, + normalize: bool = True, + **kwargs, + ): + """Add a batch of embeddings to the search index. + + Args: + embeddings: List of embeddings to add to the index. + idxs (int): Embedding ids as in the index table. Returned with the + embeddings to allow to lookup the data associated with the returned + embeddings. + verbose: Be verbose. Defaults to 1. + """ + if self.normalize: + faiss.normalize_L2(embeddings) + if self.algo != "flat": + # flat does not accept indexes as parameters and assumes incremental + # indexes + self.index.add_with_ids(embeddings, idxs) + else: + self.index.add(embeddings) + + def save(self, path: str): + """Serializes the index data on disk + + Args: + path: where to store the data + """ + chunk = faiss.serialize_index(self.index) + np.save(self.__make_fname(path), chunk) + + def __make_fname(self, path): + return str(Path(path) / "faiss_index.npy") + + def load(self, path: str): + """load index on disk + + Args: + path: where to store the data + """ + self.index = faiss.deserialize_index( + np.load(self.__make_fname(path)) + ) # identical to index + + def get_config(self) -> dict[str, Any]: + """Contains the search configuration. + + Returns: + A Python dict containing the configuration of the search obj. + """ + config = { + "distance": self.distance.name, + "dim": self.dim, + "algo": self.algo, + "m": self.m, + "nlist": self.nlist, + "nprobe": self.nprobe, + "normalize": self.normalize, + "verbose": self.verbose, + "name": self.name, + "canonical_name": self.__class__.__name__, + } + + return config diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py new file mode 100644 index 00000000..65cf536f --- /dev/null +++ b/tensorflow_similarity/search/linear_search.py @@ -0,0 +1,183 @@ +"""The module to handle Linear search.""" + +from collections.abc import Sequence +from .search import Search +from tensorflow_similarity.distances import Distance +from tensorflow_similarity.types import FloatTensor +from pathlib import Path +from typing import Any +import numpy as np +import tensorflow as tf +import pickle +import json +from termcolor import cprint + +INITIAL_DB_SIZE = 10000 +DB_SIZE_STEPS = 10000 + + +class LinearSearch(Search): + """This class implements the Linear Search interface. + + It implements the Search interface. + """ + + def __init__( + self, + distance: Distance | str, + dim: int, + verbose: int = 0, + name: str | None = None, + ): + """Initiate Linear indexer. + + Args: + d: number of dimensions + m: number of centroid IDs in final compressed vectors. d must be divisible + by m + nbits: number of bits in each centroid + nlist: how many Voronoi cells (must be greater than or equal to 2**nbits) + nprobe: how many of the nearest cells to include in search + """ + super().__init__(distance=distance, dim=dim, verbose=verbose, name=name) + + if verbose: + t_msg = [ + "\n|-Initialize NMSLib Index", + f"| - distance: {self.distance}", + f"| - dim: {self.dim}", + f"| - verbose: {self.verbose}", + f"| - name: {self.name}", + ] + cprint("\n".join(t_msg) + "\n", "green") + self.db = np.empty((INITIAL_DB_SIZE, dim), dtype=np.float32) + self.ids = [] + + + + def is_built(self): + return True + + def needs_building(self): + return False + + def batch_lookup( + self, embeddings: FloatTensor, k: int = 5 + ) -> tuple[list[list[int]], list[list[float]]]: + """Find embeddings K nearest neighboors embeddings. + + Args: + embedding: Batch of query embeddings as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + + normalized_query = tf.math.l2_normalize(embeddings, axis=1) + items = len(self.ids) + sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) + similarity, id_idxs = tf.math.top_k(sims, k) + ids_array = np.array(self.ids) + return np.array([ids_array[x.numpy()] for x in id_idxs]), similarity + + def lookup( + self, embedding: FloatTensor, k: int = 5 + ) -> tuple[list[int], list[float]]: + """Find embedding K nearest neighboors embeddings. + + Args: + embedding: Query embedding as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + normalized_query = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) + items = len(self.ids) + sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) + similarity, id_idxs = tf.math.top_k(sims, k) + ids_array = np.array(self.ids) + return np.array(ids_array[id_idxs[0].numpy()]), similarity[0] + + def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): + """Add a single embedding to the search index. + + Args: + embedding: The embedding to index as computed by the similarity model. + idx: Embedding id as in the index table. Returned with the embedding to + allow to lookup the data associated with a given embedding. + """ + int_embedding = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) + items = len(self.ids) + if items + 1 > self.db.shape[0]: + # it's full + new_db = np.empty((len(self.ids) + DB_SIZE_STEPS, self.dim), dtype=np.float32) + new_db[:items] = self.db + self.db = new_db + self.ids.append(idx) + self.db[items] = int_embedding + + def batch_add( + self, + embeddings: FloatTensor, + idxs: Sequence[int], + verbose: int = 1, + normalize: bool = True, + **kwargs, + ): + """Add a batch of embeddings to the search index. + + Args: + embeddings: List of embeddings to add to the index. + idxs (int): Embedding ids as in the index table. Returned with the + embeddings to allow to lookup the data associated with the returned + embeddings. + verbose: Be verbose. Defaults to 1. + """ + int_embeddings = tf.math.l2_normalize(embeddings, axis=1) + items = len(self.ids) + if items + len(embeddings) > self.db.shape[0]: + # it's full + new_db = np.empty((((items + len(embeddings) + DB_SIZE_STEPS) // DB_SIZE_STEPS) * DB_SIZE_STEPS, self.dim), dtype=np.float32) + new_db[:items] = self.db + self.db = new_db + self.ids.extend(idxs) + self.db[items:items+len(embeddings)] = int_embeddings + + def __make_file_path(self, path): + return path / "index.pickle" + + def save(self, path: str): + """Serializes the index data on disk + + Args: + path: where to store the data + """ + with open(self.__make_file_path(path), "wb") as f: + pickle.dump((self.db, self.ids), f) + + def load(self, path: str): + """load index on disk + + Args: + path: where to store the data + """ + with open(self.__make_file_path(path), "rb") as f: + data = pickle.load(f) + self.db = data[0] + self.ids = data[1] + + def __make_config_path(self, path): + return path / "config.json" + + def __save_config(self): + with open(self.__make_config_file_path(path), "wt") as f: + json.dump(self.get_config(), f) + + def get_config(self) -> dict[str, Any]: + """Contains the search configuration. + + Returns: + A Python dict containing the configuration of the search obj. + """ + config = { + "distance": self.distance.name, + "dim": self.dim, + } + + return config diff --git a/tensorflow_similarity/stores/__init__.py b/tensorflow_similarity/stores/__init__.py index ea2f5772..a7c71e31 100644 --- a/tensorflow_similarity/stores/__init__.py +++ b/tensorflow_similarity/stores/__init__.py @@ -29,3 +29,5 @@ from .memory_store import MemoryStore # noqa from .store import Store # noqa +from .cached_store import CachedStore # noqa +from .redis_store import RedisStore # noqa diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py new file mode 100644 index 00000000..1cfdc55b --- /dev/null +++ b/tensorflow_similarity/stores/cached_store.py @@ -0,0 +1,228 @@ +# Copyright 2021 The TensorFlow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import io +from collections.abc import Sequence +from pathlib import Path + +import numpy as np +import pandas as pd +import tensorflow as tf +import pickle +import shutil +import dbm +import json +import math + +from tensorflow_similarity.types import FloatTensor, PandasDataFrame, Tensor + +from .store import Store + + +class CachedStore(Store): + """Efficient cached dataset store""" + + def __init__(self, shard_size=1000000) -> None: + # We are using a native python cached dictionary + # db[id] = pickle((embedding, label, data)) + self.db: list[dict[str, str]] = [] + self.shard_size = shard_size + self.num_items: int = 0 + self.path: str = "." + + def __get_shard_file_path(self, shard_no): + return f'{self.path}/cache{shard_no}' + + def __make_new_shard(self, shard_no: int): + return dbm.open(self.__get_shard_file_path(shard_no), 'c') + + def __add_new_shard(self): + shard_no = len(self.db) + self.db.append(self.__make_new_shard(shard_no)) + + def __reopen_all_shards(self): + for shard_no in range(len(self.db)): + self.db[shard_no] = self.__make_new_shard(shard_no) + + def add( + self, + embedding: FloatTensor, + label: int | None = None, + data: Tensor | None = None, + ) -> int: + """Add an Embedding record to the key value store. + + Args: + embedding: Embedding predicted by the model. + + label: Class numerical id. Defaults to None. + + data: Data associated with the embedding. Defaults to None. + + Returns: + Associated record id. + """ + idx = self.num_items + shard_no = idx // self.shard_size + if len(self.db) <= shard_no: + self.__add_new_shard() + self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, data)) + self.num_items += 1 + return idx + + def batch_add( + self, + embeddings: Sequence[FloatTensor], + labels: Sequence[int] | None = None, + data: Sequence[Tensor] | None = None, + ) -> list[int]: + """Add a set of embedding records to the key value store. + + Args: + embeddings: Embeddings predicted by the model. + + labels: Class numerical ids. Defaults to None. + + data: Data associated with the embeddings. Defaults to None. + + See: + add() for what a record contains. + + Returns: + List of associated record id. + """ + idxs: list[int] = [] + for i, embedding in enumerate(embeddings): + idx = i + self.num_items + label = None if labels is None else labels[i] + rec_data = None if data is None else data[i] + shard_no = idx // self.shard_size + if len(self.db) <= shard_no: + self.__add_new_shard() + self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, rec_data)) + idxs.append(idx) + + return idxs + + def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: + """Get an embedding record from the key value store. + + Args: + idx: Id of the record to fetch. + + Returns: + record associated with the requested id. + """ + + shard_no = idx // self.shard_size + embedding, label, data = pickle.loads(self.db[shard_no][str(idx)]) + return embedding, label, data + + def batch_get(self, idxs: Sequence[int]) -> tuple[list[FloatTensor], list[int | None], list[Tensor | None]]: + """Get embedding records from the key value store. + + Args: + idxs: ids of the records to fetch. + + Returns: + List of records associated with the requested ids. + """ + embeddings = [] + labels = [] + data = [] + for idx in idxs: + e, l, d = self.get(idx) + embeddings.append(e) + labels.append(l) + data.append(d) + return embeddings, labels, data + + def size(self) -> int: + "Number of record in the key value store." + return self.num_items + + def __close_all_shards(self): + for shard in self.db: + shard.close() + + def __copy_shards(self, path): + for shard_no in range(len(self.db)): + shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix('.db'), path) + + def __make_config_file_path(self, path): + return path / "config.json" + + def __save_config(self, path): + with open(self.__make_config_file_path(path), "wt") as f: + json.dump(self.get_config(), f) + + def __set_config(self, num_items, shard_size): + self.num_items = num_items + self.shard_size = shard_size + + def __load_config(self, path): + with open(self.__make_config_file_path(path), "rt") as f: + self.__set_config(**json.load(f)) + + def save(self, path: str, compression: bool = True) -> None: + """Serializes index on disk. + + Args: + path: where to store the data. + compression: Compress index data. Defaults to True. + """ + # Writing to a buffer to avoid read error in np.savez when using GFile. + # See: https://github.com/tensorflow/tensorflow/issues/32090 + self.__close_all_shards() + self.__copy_shards(path) + self.__save_config(path) + self.__reopen_all_shards() + + def get_config(self): + return { + "shard_size": self.shard_size, + "num_items": self.num_items + } + + def load(self, path: str) -> int: + """load index on disk + + Args: + path: which directory to use to store the index data. + + Returns: + Number of records reloaded. + """ + self.__load_config(path) + num_shards = int(math.ceil(self.num_items / self.shard_size)) + self.path = path + for i in range(self.num_items): + self.__add_new_shard() + return self.size() + + def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: + """Export data as a Pandas dataframe. + + Cached store does not fit in memory, therefore we do not implement this. + + Args: + num_records: Number of records to export to the dataframe. + Defaults to 0 (unlimited). + + Returns: + None + """ + + return None diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py new file mode 100644 index 00000000..2a51d55f --- /dev/null +++ b/tensorflow_similarity/stores/redis_store.py @@ -0,0 +1,191 @@ +# Copyright 2021 The TensorFlow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from collections.abc import Sequence + +import redis + +from .store import Store + +from tensorflow_similarity.types import FloatTensor, PandasDataFrame, Tensor + + +class RedisStore(Store): + """Efficient Redis dataset store""" + + def __init__(self, host="localhost", port=6379, db=0) -> None: + # Currently does not support authentication + self.host = host + self.port = port + self.db = db + self.__connect() + + def add( + self, + embedding: FloatTensor, + label: int | None = None, + data: Tensor | None = None, + ) -> int: + """Add an Embedding record to the key value store. + + Args: + embedding: Embedding predicted by the model. + + label: Class numerical id. Defaults to None. + + data: Data associated with the embedding. Defaults to None. + + Returns: + Associated record id. + """ + num_items = self.__conn.incr("num_items") + idx = num_items - 1 + self.__conn.set(idx, (embedding, label, data)) + + return idx + + def get_num_items(self): + return self.__conn.get("num_items") or 0 + + def batch_add( + self, + embeddings: Sequence[FloatTensor], + labels: Sequence[int] | None = None, + data: Sequence[Tensor] | None = None, + ) -> list[int]: + """Add a set of embedding records to the key value store. + + Args: + embeddings: Embeddings predicted by the model. + + labels: Class numerical ids. Defaults to None. + + data: Data associated with the embeddings. Defaults to None. + + See: + add() for what a record contains. + + Returns: + List of associated record id. + """ + idxs: list[int] = [] + for i, embedding in enumerate(embeddings): + label = None if labels is None else labels[i] + rec_data = None if data is None else data[i] + idx = self.add(embedding, label, rec_data) + idxs.append(idx) + + return idxs + + def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: + """Get an embedding record from the key value store. + + Args: + idx: Id of the record to fetch. + + Returns: + record associated with the requested id. + """ + + return self.__conn.get(str(idx)) + + def batch_get(self, idxs: Sequence[int]) -> tuple[list[FloatTensor], list[int | None], list[Tensor | None]]: + """Get embedding records from the key value store. + + Args: + idxs: ids of the records to fetch. + + Returns: + List of records associated with the requested ids. + """ + embeddings = [] + labels = [] + data = [] + for idx in idxs: + e, l, d = self.get(idx) + embeddings.append(e) + labels.append(l) + data.append(d) + return embeddings, labels, data + + def size(self) -> int: + "Number of record in the key value store." + return self.get_num_items() + + def __make_config_file_path(self, path): + return path / "config.json" + + def __save_config(self, path): + with open(self.__make_config_file_path(path), "wt") as f: + json.dump(self.get_config(), f) + + def __set_config(self, host, port, db): + self.host = host + self.port = port + self.db = db + + def __connect(self): + self.__conn = redis.Redis(host=self.host, port=self.port, db=self.db) + + def __load_config(self, path): + with open(self.__make_config_file_path(path), "rt") as f: + self.__set_config(**json.load(f)) + self.__connect() + + def save(self, path: str, compression: bool = True) -> None: + """Serializes index on disk. + + Args: + path: where to store the data. + compression: Compress index data. Defaults to True. + """ + # Writing to a buffer to avoid read error in np.savez when using GFile. + # See: https://github.com/tensorflow/tensorflow/issues/32090 + self.__save_config(path) + + def get_config(self): + return { + "host": self.host, + "port": self.port, + "db": self.db, + "num_items": self.get_num_items() + } + + def load(self, path: str) -> int: + """load index on disk + + Args: + path: which directory to use to store the index data. + + Returns: + Number of records reloaded. + """ + self.__load_config(path) + return self.size() + + def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: + """Export data as a Pandas dataframe. + + Cached store does not fit in memory, therefore we do not implement this. + + Args: + num_records: Number of records to export to the dataframe. + Defaults to 0 (unlimited). + + Returns: + None + """ + + return None From 4aae15aadef6079e935df92ed8a8c4189b50a9b1 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 27 Feb 2023 14:05:11 -0800 Subject: [PATCH 02/35] formatting --- tensorflow_similarity/base_indexer.py | 63 ++- tensorflow_similarity/indexer.py | 3 +- tensorflow_similarity/search/faiss_search.py | 414 +++++++++--------- tensorflow_similarity/search/linear_search.py | 319 +++++++------- tensorflow_similarity/stores/cached_store.py | 43 +- tensorflow_similarity/stores/redis_store.py | 27 +- tests/search/test_faiss_search.py | 108 +++++ tests/search/test_linear_search.py | 101 +++++ tests/stores/test_cached_store.py | 68 +++ tests/stores/test_redis_store.py | 53 +++ 10 files changed, 754 insertions(+), 445 deletions(-) create mode 100644 tests/search/test_faiss_search.py create mode 100644 tests/search/test_linear_search.py create mode 100644 tests/stores/test_cached_store.py create mode 100644 tests/stores/test_redis_store.py diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index ffafcdfe..0ce32e82 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -21,10 +21,8 @@ from tabulate import tabulate - class BaseIndexer(ABC): - def __init__(self, distance, embedding_output, embedding_size, evaluator, - stat_buffer_size): + def __init__(self, distance, embedding_output, embedding_size, evaluator, stat_buffer_size): distance = distance_canonicalizer(distance) self.distance = distance # needed for save()/load() self.embedding_output = embedding_output @@ -44,7 +42,7 @@ def __init__(self, distance, embedding_output, embedding_size, evaluator, self.calibration_thresholds: Mapping[str, np.ndarray] = {} return - + # evaluation related functions def evaluate_retrieval( self, @@ -348,33 +346,33 @@ def match( @abstractmethod def add( - self, + self, prediction: FloatTensor, label: int | None = None, data: Tensor = None, build: bool = True, verbose: int = 1, ): - """Add a single embedding to the indexer + """Add a single embedding to the indexer + + Args: + prediction: TF similarity model prediction, may be a multi-headed + output. - Args: - prediction: TF similarity model prediction, may be a multi-headed - output. + label: Label(s) associated with the + embedding. Defaults to None. - label: Label(s) associated with the - embedding. Defaults to None. + data: Input data associated with + the embedding. Defaults to None. - data: Input data associated with - the embedding. Defaults to None. + build: Rebuild the index after insertion. + Defaults to True. Set it to false if you would like to add + multiples batches/points and build it manually once after. - build: Rebuild the index after insertion. - Defaults to True. Set it to false if you would like to add - multiples batches/points and build it manually once after. + verbose: Display progress if set to 1. + Defaults to 1. + """ - verbose: Display progress if set to 1. - Defaults to 1. - """ - @abstractmethod def batch_add( self, @@ -384,22 +382,22 @@ def batch_add( build: bool = True, verbose: int = 1, ): - """Add a batch of embeddings to the indexer + """Add a batch of embeddings to the indexer - Args: - predictions: TF similarity model predictions, may be a multi-headed - output. + Args: + predictions: TF similarity model predictions, may be a multi-headed + output. - labels: label(s) associated with the embedding. Defaults to None. + labels: label(s) associated with the embedding. Defaults to None. - datas: input data associated with the embedding. Defaults to None. + datas: input data associated with the embedding. Defaults to None. - build: Rebuild the index after insertion. - Defaults to True. Set it to false if you would like to add - multiples batches/points and build it manually once after. + build: Rebuild the index after insertion. + Defaults to True. Set it to false if you would like to add + multiples batches/points and build it manually once after. - verbose: Display progress if set to 1. Defaults to 1. - """ + verbose: Display progress if set to 1. Defaults to 1. + """ @abstractmethod def single_lookup(self, prediction: FloatTensor, k: int = 5) -> list[Lookup]: @@ -414,8 +412,7 @@ def single_lookup(self, prediction: FloatTensor, k: int = 5) -> list[Lookup]: list of the k nearest neighbors info: list[Lookup] """ - - + @abstractmethod def batch_lookup(self, predictions: FloatTensor, k: int = 5, verbose: int = 1) -> list[list[Lookup]]: diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index f2e0518c..a16654ab 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -116,8 +116,7 @@ def __init__( Raises: ValueError: Invalid search framework or key value store. """ - super().__init__(distance, embedding_output, embedding_size, evaluator, - stat_buffer_size) + super().__init__(distance, embedding_output, embedding_size, evaluator, stat_buffer_size) # internal structure naming # FIXME support custom objects self.search_type = search diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index e4ac89b1..754e740f 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -12,216 +12,210 @@ class FaissSearch(Search): - """This class implements the Faiss ANN interface. - - It implements the Search interface. - """ - - def __init__( - self, - distance: Distance | str, - dim: int, - verbose: int = 0, - name: str | None = None, - algo="ivfpq", - m=8, - nbits=8, - nlist=1024, - nprobe=1, - normalize=True, - ): - """Initiate FAISS indexer - - Args: - d: number of dimensions - m: number of centroid IDs in final compressed vectors. d must be divisible - by m - nbits: number of bits in each centroid - nlist: how many Voronoi cells (must be greater than or equal to 2**nbits) - nprobe: how many of the nearest cells to include in search - """ - super().__init__(distance=distance, dim=dim, verbose=verbose, name=name) - self.algo = algo - self.m = m # number of bits per subquantizer - self.nbits = nbits - self.nlist = nlist - self.nprobe = nprobe - self.normalize = normalize - self.built = False - - if verbose: - t_msg = [ - "\n|-Initialize NMSLib Index", - f"| - algo: {self.algo}", - f"| - m: {self.m}", - f"| - nbits: {self.nbits}", - f"| - nlist: {self.nlist}", - f"| - nprobe: {self.nprobe}", - f"| - normalize: {self.normalize}", - f"| - query_params: {self.query_params}", - ] - cprint("\n".join(t_msg) + "\n", "green") - - if self.algo == "ivfpq": - assert dim % m == 0, f"dim={dim}, m={m}" - if self.algo == "ivfpq": - metric = faiss.METRIC_L2 - prefix = "" - if distance == "cosine": - prefix = "L2norm," - metric = faiss.METRIC_INNER_PRODUCT - # this distance requires both the input and query vectors to be normalized - ivf_string = f"IVF{nlist}," - pq_string = f"PQ{m}x{nbits}" - factory_string = prefix + ivf_string + pq_string - self.index = faiss.index_factory(dim, factory_string, metric) - # quantizer = faiss.IndexFlatIP( - # dim - # ) # we keep the same L2 distance flat index - # self.index = faiss.IndexIVFPQ( - # quantizer, dim, nlist, m, nbits, metric=faiss.METRIC_INNER_PRODUCT - # ) - # else: - # quantizer = faiss.IndexFlatL2( - # dim - # ) # we keep the same L2 distance flat index - # self.index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, nbits) - self.index.nprobe = nprobe # set how many of nearest cells to search - elif algo == "flat": - if distance == "cosine": - # this is exact match using cosine/dot-product Distance - self.index = faiss.IndexFlatIP(dim) - else: - # this is exact match using L2 distance - self.index = faiss.IndexFlatL2(dim) - - def is_built(self): - return self.built - - def needs_building(self): - if self.algo == "flat": - return False - else: - return not self.index.is_trained - - def build_index(self, samples, **kwargss): - if self.algo == "ivfpq": - if self.normalize: - faiss.normalize_L2(samples) - self.index.train(samples) # we must train the index to cluster into cells - self.built = True - - def batch_lookup( - self, embeddings: FloatTensor, k: int = 5 - ) -> tuple[list[list[int]], list[list[float]]]: - """Find embeddings K nearest neighboors embeddings. - - Args: - embedding: Batch of query embeddings as predicted by the model. - k: Number of nearest neighboors embedding to lookup. Defaults to 5. - """ - - if self.normalize: - faiss.normalize_L2(embeddings) - D, I = self.index.search(embeddings, k) - return I, D - - def lookup( - self, embedding: FloatTensor, k: int = 5 - ) -> tuple[list[int], list[float]]: - """Find embedding K nearest neighboors embeddings. + """This class implements the Faiss ANN interface. - Args: - embedding: Query embedding as predicted by the model. - k: Number of nearest neighboors embedding to lookup. Defaults to 5. - """ - int_embedding = np.array([embedding], dtype=np.float32) - if self.normalize: - faiss.normalize_L2(int_embedding) - D, I = self.index.search(int_embedding, k) - return I[0], D[0] - - def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): - """Add a single embedding to the search index. - - Args: - embedding: The embedding to index as computed by the similarity model. - idx: Embedding id as in the index table. Returned with the embedding to - allow to lookup the data associated with a given embedding. - """ - int_embedding = np.array([embedding], dtype=np.float32) - if self.normalize: - faiss.normalize_L2(int_embedding) - if self.algo != "flat": - self.index.add_with_ids(int_embedding) - else: - self.index.add(int_embedding) - - def batch_add( - self, - embeddings: FloatTensor, - idxs: Sequence[int], - verbose: int = 1, - normalize: bool = True, - **kwargs, - ): - """Add a batch of embeddings to the search index. - - Args: - embeddings: List of embeddings to add to the index. - idxs (int): Embedding ids as in the index table. Returned with the - embeddings to allow to lookup the data associated with the returned - embeddings. - verbose: Be verbose. Defaults to 1. - """ - if self.normalize: - faiss.normalize_L2(embeddings) - if self.algo != "flat": - # flat does not accept indexes as parameters and assumes incremental - # indexes - self.index.add_with_ids(embeddings, idxs) - else: - self.index.add(embeddings) - - def save(self, path: str): - """Serializes the index data on disk - - Args: - path: where to store the data + It implements the Search interface. """ - chunk = faiss.serialize_index(self.index) - np.save(self.__make_fname(path), chunk) - def __make_fname(self, path): - return str(Path(path) / "faiss_index.npy") - - def load(self, path: str): - """load index on disk - - Args: - path: where to store the data - """ - self.index = faiss.deserialize_index( - np.load(self.__make_fname(path)) - ) # identical to index - - def get_config(self) -> dict[str, Any]: - """Contains the search configuration. - - Returns: - A Python dict containing the configuration of the search obj. - """ - config = { - "distance": self.distance.name, - "dim": self.dim, - "algo": self.algo, - "m": self.m, - "nlist": self.nlist, - "nprobe": self.nprobe, - "normalize": self.normalize, - "verbose": self.verbose, - "name": self.name, - "canonical_name": self.__class__.__name__, - } - - return config + def __init__( + self, + distance: Distance | str, + dim: int, + verbose: int = 0, + name: str | None = None, + algo="ivfpq", + m=8, + nbits=8, + nlist=1024, + nprobe=1, + normalize=True, + ): + """Initiate FAISS indexer + + Args: + d: number of dimensions + m: number of centroid IDs in final compressed vectors. d must be divisible + by m + nbits: number of bits in each centroid + nlist: how many Voronoi cells (must be greater than or equal to 2**nbits) + nprobe: how many of the nearest cells to include in search + """ + super().__init__(distance=distance, dim=dim, verbose=verbose, name=name) + self.algo = algo + self.m = m # number of bits per subquantizer + self.nbits = nbits + self.nlist = nlist + self.nprobe = nprobe + self.normalize = normalize + self.built = False + + if verbose: + t_msg = [ + "\n|-Initialize NMSLib Index", + f"| - algo: {self.algo}", + f"| - m: {self.m}", + f"| - nbits: {self.nbits}", + f"| - nlist: {self.nlist}", + f"| - nprobe: {self.nprobe}", + f"| - normalize: {self.normalize}", + f"| - query_params: {self.query_params}", + ] + cprint("\n".join(t_msg) + "\n", "green") + + if self.algo == "ivfpq": + assert dim % m == 0, f"dim={dim}, m={m}" + if self.algo == "ivfpq": + metric = faiss.METRIC_L2 + prefix = "" + if distance == "cosine": + prefix = "L2norm," + metric = faiss.METRIC_INNER_PRODUCT + # this distance requires both the input and query vectors to be normalized + ivf_string = f"IVF{nlist}," + pq_string = f"PQ{m}x{nbits}" + factory_string = prefix + ivf_string + pq_string + self.index = faiss.index_factory(dim, factory_string, metric) + # quantizer = faiss.IndexFlatIP( + # dim + # ) # we keep the same L2 distance flat index + # self.index = faiss.IndexIVFPQ( + # quantizer, dim, nlist, m, nbits, metric=faiss.METRIC_INNER_PRODUCT + # ) + # else: + # quantizer = faiss.IndexFlatL2( + # dim + # ) # we keep the same L2 distance flat index + # self.index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, nbits) + self.index.nprobe = nprobe # set how many of nearest cells to search + elif algo == "flat": + if distance == "cosine": + # this is exact match using cosine/dot-product Distance + self.index = faiss.IndexFlatIP(dim) + else: + # this is exact match using L2 distance + self.index = faiss.IndexFlatL2(dim) + + def is_built(self): + return self.built + + def needs_building(self): + if self.algo == "flat": + return False + else: + return not self.index.is_trained + + def build_index(self, samples, **kwargss): + if self.algo == "ivfpq": + if self.normalize: + faiss.normalize_L2(samples) + self.index.train(samples) # we must train the index to cluster into cells + self.built = True + + def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[int]], list[list[float]]]: + """Find embeddings K nearest neighboors embeddings. + + Args: + embedding: Batch of query embeddings as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + + if self.normalize: + faiss.normalize_L2(embeddings) + D, I = self.index.search(embeddings, k) + return I, D + + def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[float]]: + """Find embedding K nearest neighboors embeddings. + + Args: + embedding: Query embedding as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + int_embedding = np.array([embedding], dtype=np.float32) + if self.normalize: + faiss.normalize_L2(int_embedding) + D, I = self.index.search(int_embedding, k) + return I[0], D[0] + + def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): + """Add a single embedding to the search index. + + Args: + embedding: The embedding to index as computed by the similarity model. + idx: Embedding id as in the index table. Returned with the embedding to + allow to lookup the data associated with a given embedding. + """ + int_embedding = np.array([embedding], dtype=np.float32) + if self.normalize: + faiss.normalize_L2(int_embedding) + if self.algo != "flat": + self.index.add_with_ids(int_embedding) + else: + self.index.add(int_embedding) + + def batch_add( + self, + embeddings: FloatTensor, + idxs: Sequence[int], + verbose: int = 1, + normalize: bool = True, + **kwargs, + ): + """Add a batch of embeddings to the search index. + + Args: + embeddings: List of embeddings to add to the index. + idxs (int): Embedding ids as in the index table. Returned with the + embeddings to allow to lookup the data associated with the returned + embeddings. + verbose: Be verbose. Defaults to 1. + """ + if self.normalize: + faiss.normalize_L2(embeddings) + if self.algo != "flat": + # flat does not accept indexes as parameters and assumes incremental + # indexes + self.index.add_with_ids(embeddings, idxs) + else: + self.index.add(embeddings) + + def save(self, path: str): + """Serializes the index data on disk + + Args: + path: where to store the data + """ + chunk = faiss.serialize_index(self.index) + np.save(self.__make_fname(path), chunk) + + def __make_fname(self, path): + return str(Path(path) / "faiss_index.npy") + + def load(self, path: str): + """load index on disk + + Args: + path: where to store the data + """ + self.index = faiss.deserialize_index(np.load(self.__make_fname(path))) # identical to index + + def get_config(self) -> dict[str, Any]: + """Contains the search configuration. + + Returns: + A Python dict containing the configuration of the search obj. + """ + config = { + "distance": self.distance.name, + "dim": self.dim, + "algo": self.algo, + "m": self.m, + "nlist": self.nlist, + "nprobe": self.nprobe, + "normalize": self.normalize, + "verbose": self.verbose, + "name": self.name, + "canonical_name": self.__class__.__name__, + } + + return config diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index 65cf536f..7cbac45e 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -17,167 +17,164 @@ class LinearSearch(Search): - """This class implements the Linear Search interface. - - It implements the Search interface. - """ - - def __init__( - self, - distance: Distance | str, - dim: int, - verbose: int = 0, - name: str | None = None, - ): - """Initiate Linear indexer. - - Args: - d: number of dimensions - m: number of centroid IDs in final compressed vectors. d must be divisible - by m - nbits: number of bits in each centroid - nlist: how many Voronoi cells (must be greater than or equal to 2**nbits) - nprobe: how many of the nearest cells to include in search - """ - super().__init__(distance=distance, dim=dim, verbose=verbose, name=name) - - if verbose: - t_msg = [ - "\n|-Initialize NMSLib Index", - f"| - distance: {self.distance}", - f"| - dim: {self.dim}", - f"| - verbose: {self.verbose}", - f"| - name: {self.name}", - ] - cprint("\n".join(t_msg) + "\n", "green") - self.db = np.empty((INITIAL_DB_SIZE, dim), dtype=np.float32) - self.ids = [] - - - - def is_built(self): - return True - - def needs_building(self): - return False - - def batch_lookup( - self, embeddings: FloatTensor, k: int = 5 - ) -> tuple[list[list[int]], list[list[float]]]: - """Find embeddings K nearest neighboors embeddings. - - Args: - embedding: Batch of query embeddings as predicted by the model. - k: Number of nearest neighboors embedding to lookup. Defaults to 5. - """ + """This class implements the Linear Search interface. - normalized_query = tf.math.l2_normalize(embeddings, axis=1) - items = len(self.ids) - sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) - similarity, id_idxs = tf.math.top_k(sims, k) - ids_array = np.array(self.ids) - return np.array([ids_array[x.numpy()] for x in id_idxs]), similarity - - def lookup( - self, embedding: FloatTensor, k: int = 5 - ) -> tuple[list[int], list[float]]: - """Find embedding K nearest neighboors embeddings. - - Args: - embedding: Query embedding as predicted by the model. - k: Number of nearest neighboors embedding to lookup. Defaults to 5. - """ - normalized_query = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) - items = len(self.ids) - sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) - similarity, id_idxs = tf.math.top_k(sims, k) - ids_array = np.array(self.ids) - return np.array(ids_array[id_idxs[0].numpy()]), similarity[0] - - def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): - """Add a single embedding to the search index. - - Args: - embedding: The embedding to index as computed by the similarity model. - idx: Embedding id as in the index table. Returned with the embedding to - allow to lookup the data associated with a given embedding. - """ - int_embedding = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) - items = len(self.ids) - if items + 1 > self.db.shape[0]: - # it's full - new_db = np.empty((len(self.ids) + DB_SIZE_STEPS, self.dim), dtype=np.float32) - new_db[:items] = self.db - self.db = new_db - self.ids.append(idx) - self.db[items] = int_embedding - - def batch_add( - self, - embeddings: FloatTensor, - idxs: Sequence[int], - verbose: int = 1, - normalize: bool = True, - **kwargs, - ): - """Add a batch of embeddings to the search index. - - Args: - embeddings: List of embeddings to add to the index. - idxs (int): Embedding ids as in the index table. Returned with the - embeddings to allow to lookup the data associated with the returned - embeddings. - verbose: Be verbose. Defaults to 1. - """ - int_embeddings = tf.math.l2_normalize(embeddings, axis=1) - items = len(self.ids) - if items + len(embeddings) > self.db.shape[0]: - # it's full - new_db = np.empty((((items + len(embeddings) + DB_SIZE_STEPS) // DB_SIZE_STEPS) * DB_SIZE_STEPS, self.dim), dtype=np.float32) - new_db[:items] = self.db - self.db = new_db - self.ids.extend(idxs) - self.db[items:items+len(embeddings)] = int_embeddings - - def __make_file_path(self, path): - return path / "index.pickle" - - def save(self, path: str): - """Serializes the index data on disk - - Args: - path: where to store the data - """ - with open(self.__make_file_path(path), "wb") as f: - pickle.dump((self.db, self.ids), f) - - def load(self, path: str): - """load index on disk - - Args: - path: where to store the data - """ - with open(self.__make_file_path(path), "rb") as f: - data = pickle.load(f) - self.db = data[0] - self.ids = data[1] - - def __make_config_path(self, path): - return path / "config.json" - - def __save_config(self): - with open(self.__make_config_file_path(path), "wt") as f: - json.dump(self.get_config(), f) - - def get_config(self) -> dict[str, Any]: - """Contains the search configuration. - - Returns: - A Python dict containing the configuration of the search obj. + It implements the Search interface. """ - config = { - "distance": self.distance.name, - "dim": self.dim, - } - return config + def __init__( + self, + distance: Distance | str, + dim: int, + verbose: int = 0, + name: str | None = None, + ): + """Initiate Linear indexer. + + Args: + d: number of dimensions + m: number of centroid IDs in final compressed vectors. d must be divisible + by m + nbits: number of bits in each centroid + nlist: how many Voronoi cells (must be greater than or equal to 2**nbits) + nprobe: how many of the nearest cells to include in search + """ + super().__init__(distance=distance, dim=dim, verbose=verbose, name=name) + + if verbose: + t_msg = [ + "\n|-Initialize NMSLib Index", + f"| - distance: {self.distance}", + f"| - dim: {self.dim}", + f"| - verbose: {self.verbose}", + f"| - name: {self.name}", + ] + cprint("\n".join(t_msg) + "\n", "green") + self.db = np.empty((INITIAL_DB_SIZE, dim), dtype=np.float32) + self.ids = [] + + def is_built(self): + return True + + def needs_building(self): + return False + + def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[int]], list[list[float]]]: + """Find embeddings K nearest neighboors embeddings. + + Args: + embedding: Batch of query embeddings as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + + normalized_query = tf.math.l2_normalize(embeddings, axis=1) + items = len(self.ids) + sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) + similarity, id_idxs = tf.math.top_k(sims, k) + ids_array = np.array(self.ids) + return np.array([ids_array[x.numpy()] for x in id_idxs]), similarity + + def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[float]]: + """Find embedding K nearest neighboors embeddings. + + Args: + embedding: Query embedding as predicted by the model. + k: Number of nearest neighboors embedding to lookup. Defaults to 5. + """ + normalized_query = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) + items = len(self.ids) + sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) + similarity, id_idxs = tf.math.top_k(sims, k) + ids_array = np.array(self.ids) + return np.array(ids_array[id_idxs[0].numpy()]), similarity[0] + + def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): + """Add a single embedding to the search index. + + Args: + embedding: The embedding to index as computed by the similarity model. + idx: Embedding id as in the index table. Returned with the embedding to + allow to lookup the data associated with a given embedding. + """ + int_embedding = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) + items = len(self.ids) + if items + 1 > self.db.shape[0]: + # it's full + new_db = np.empty((len(self.ids) + DB_SIZE_STEPS, self.dim), dtype=np.float32) + new_db[:items] = self.db + self.db = new_db + self.ids.append(idx) + self.db[items] = int_embedding + + def batch_add( + self, + embeddings: FloatTensor, + idxs: Sequence[int], + verbose: int = 1, + normalize: bool = True, + **kwargs, + ): + """Add a batch of embeddings to the search index. + + Args: + embeddings: List of embeddings to add to the index. + idxs (int): Embedding ids as in the index table. Returned with the + embeddings to allow to lookup the data associated with the returned + embeddings. + verbose: Be verbose. Defaults to 1. + """ + int_embeddings = tf.math.l2_normalize(embeddings, axis=1) + items = len(self.ids) + if items + len(embeddings) > self.db.shape[0]: + # it's full + new_db = np.empty( + (((items + len(embeddings) + DB_SIZE_STEPS) // DB_SIZE_STEPS) * DB_SIZE_STEPS, self.dim), + dtype=np.float32, + ) + new_db[:items] = self.db + self.db = new_db + self.ids.extend(idxs) + self.db[items : items + len(embeddings)] = int_embeddings + + def __make_file_path(self, path): + return path / "index.pickle" + + def save(self, path: str): + """Serializes the index data on disk + + Args: + path: where to store the data + """ + with open(self.__make_file_path(path), "wb") as f: + pickle.dump((self.db, self.ids), f) + + def load(self, path: str): + """load index on disk + + Args: + path: where to store the data + """ + with open(self.__make_file_path(path), "rb") as f: + data = pickle.load(f) + self.db = data[0] + self.ids = data[1] + + def __make_config_path(self, path): + return path / "config.json" + + def __save_config(self): + with open(self.__make_config_file_path(path), "wt") as f: + json.dump(self.get_config(), f) + + def get_config(self) -> dict[str, Any]: + """Contains the search configuration. + + Returns: + A Python dict containing the configuration of the search obj. + """ + config = { + "distance": self.distance.name, + "dim": self.dim, + } + + return config diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 1cfdc55b..b17f3564 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -41,20 +41,20 @@ def __init__(self, shard_size=1000000) -> None: self.shard_size = shard_size self.num_items: int = 0 self.path: str = "." - + def __get_shard_file_path(self, shard_no): - return f'{self.path}/cache{shard_no}' - + return f"{self.path}/cache{shard_no}" + def __make_new_shard(self, shard_no: int): - return dbm.open(self.__get_shard_file_path(shard_no), 'c') - + return dbm.open(self.__get_shard_file_path(shard_no), "c") + def __add_new_shard(self): - shard_no = len(self.db) - self.db.append(self.__make_new_shard(shard_no)) + shard_no = len(self.db) + self.db.append(self.__make_new_shard(shard_no)) def __reopen_all_shards(self): for shard_no in range(len(self.db)): - self.db[shard_no] = self.__make_new_shard(shard_no) + self.db[shard_no] = self.__make_new_shard(shard_no) def add( self, @@ -110,10 +110,10 @@ def batch_add( rec_data = None if data is None else data[i] shard_no = idx // self.shard_size if len(self.db) <= shard_no: - self.__add_new_shard() + self.__add_new_shard() self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, rec_data)) idxs.append(idx) - + return idxs def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: @@ -156,22 +156,22 @@ def size(self) -> int: def __close_all_shards(self): for shard in self.db: shard.close() - + def __copy_shards(self, path): for shard_no in range(len(self.db)): - shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix('.db'), path) - + shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix(".db"), path) + def __make_config_file_path(self, path): - return path / "config.json" - + return path / "config.json" + def __save_config(self, path): with open(self.__make_config_file_path(path), "wt") as f: json.dump(self.get_config(), f) - + def __set_config(self, num_items, shard_size): self.num_items = num_items self.shard_size = shard_size - + def __load_config(self, path): with open(self.__make_config_file_path(path), "rt") as f: self.__set_config(**json.load(f)) @@ -191,11 +191,8 @@ def save(self, path: str, compression: bool = True) -> None: self.__reopen_all_shards() def get_config(self): - return { - "shard_size": self.shard_size, - "num_items": self.num_items - } - + return {"shard_size": self.shard_size, "num_items": self.num_items} + def load(self, path: str) -> int: """load index on disk @@ -214,7 +211,7 @@ def load(self, path: str) -> int: def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: """Export data as a Pandas dataframe. - + Cached store does not fit in memory, therefore we do not implement this. Args: diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index 2a51d55f..14f57632 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -31,7 +31,7 @@ def __init__(self, host="localhost", port=6379, db=0) -> None: self.port = port self.db = db self.__connect() - + def add( self, embedding: FloatTensor, @@ -53,9 +53,9 @@ def add( num_items = self.__conn.incr("num_items") idx = num_items - 1 self.__conn.set(idx, (embedding, label, data)) - + return idx - + def get_num_items(self): return self.__conn.get("num_items") or 0 @@ -86,7 +86,7 @@ def batch_add( rec_data = None if data is None else data[i] idx = self.add(embedding, label, rec_data) idxs.append(idx) - + return idxs def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: @@ -125,17 +125,17 @@ def size(self) -> int: return self.get_num_items() def __make_config_file_path(self, path): - return path / "config.json" - + return path / "config.json" + def __save_config(self, path): with open(self.__make_config_file_path(path), "wt") as f: json.dump(self.get_config(), f) - + def __set_config(self, host, port, db): self.host = host self.port = port self.db = db - + def __connect(self): self.__conn = redis.Redis(host=self.host, port=self.port, db=self.db) @@ -156,13 +156,8 @@ def save(self, path: str, compression: bool = True) -> None: self.__save_config(path) def get_config(self): - return { - "host": self.host, - "port": self.port, - "db": self.db, - "num_items": self.get_num_items() - } - + return {"host": self.host, "port": self.port, "db": self.db, "num_items": self.get_num_items()} + def load(self, path: str) -> int: """load index on disk @@ -177,7 +172,7 @@ def load(self, path: str) -> int: def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: """Export data as a Pandas dataframe. - + Cached store does not fit in memory, therefore we do not implement this. Args: diff --git a/tests/search/test_faiss_search.py b/tests/search/test_faiss_search.py new file mode 100644 index 00000000..1963f78c --- /dev/null +++ b/tests/search/test_faiss_search.py @@ -0,0 +1,108 @@ +import numpy as np + +from tensorflow_similarity.search import FaissSearch + + +def test_index_match(): + target = np.array([1, 1, 2], dtype="float32") + embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") + + search_index = FaissSearch("cosine", 3, algo="flat") + search_index.add(embs[0], 0) + search_index.add(embs[1], 1) + + idxs, embs = search_index.lookup(target, k=2) + print(f"idxs={idxs}, embs={embs}") + + assert len(embs) == 2 + assert list(idxs) == [0, 1] + + +def test_index_save(tmp_path): + target = np.array([1, 1, 2], dtype="float32") + embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") + k = 2 + + search_index = FaissSearch("cosine", 3, algo="flat") + search_index.add(embs[0], 0) + search_index.add(embs[1], 1) + + idxs, embs = search_index.lookup(target, k=k) + print(f"idxs={idxs}, embs={embs}") + + assert len(embs) == k + assert list(idxs) == [0, 1] + + search_index.save(tmp_path) + + search_index2 = FaissSearch("cosine", 3, algo="flat") + search_index2.load(tmp_path) + + idxs2, embs2 = search_index.lookup(target, k=k) + print(f"idxs2={idxs2}, embs2={embs2}") + assert len(embs2) == k + assert list(idxs2) == [0, 1] + + # add more + # if the dtype is not passed we get an incompatible type error + search_index2.add(np.array([3.0, 3.0, 3.0], dtype="float32"), 3) + idxs3, embs3 = search_index2.lookup(target, k=3) + print(f"idxs3={idxs3}, embs3={embs3}") + assert len(embs3) == 3 + assert list(idxs3) == [0, 2, 1] + + +def test_batch_vs_single(tmp_path): + num_targets = 10 + index_size = 100 + vect_dim = 16 + + # gen + idxs = list(range(index_size)) + + targets = np.random.random((num_targets, vect_dim)).astype("float32") + embs = np.random.random((index_size, vect_dim)).astype("float32") + + # build search_index + search_index = FaissSearch("cosine", vect_dim, algo="flat") + search_index.batch_add(embs, idxs) + + # batch + batch_idxs, _ = search_index.batch_lookup(targets) + + # single + singles_idxs = [] + for t in targets: + idxs, embs = search_index.lookup(t) + singles_idxs.append(idxs) + + for i in range(num_targets): + # k neigboors are the same? + for k in range(3): + assert batch_idxs[i][k] == singles_idxs[i][k] + + +def test_ivfpq(): + # test ivfpq ANN indexing with 100M entries + num_targets = 10 + index_size = 10000 + vect_dim = 16 + + # gen + idxs = np.array(list(range(index_size))) + + targets = np.random.random((num_targets, vect_dim)).astype("float32") + embs = np.random.random((index_size, vect_dim)).astype("float32") + + search_index = FaissSearch("cosine", vect_dim, algo="ivfpq") + assert search_index.is_built() == False + search_index.build_index(embs) + assert search_index.is_built() == True + last_idx = 0 + for i in range(1000): + idxs = np.array(list(range(last_idx, last_idx + index_size))) + embs = np.random.random((index_size, vect_dim)).astype("float32") + last_idx += index_size + search_index.batch_add(embs, idxs) + found_idxs, found_dists = search_index.batch_lookup(targets, 2) + assert found_idxs.shape == (10, 2) diff --git a/tests/search/test_linear_search.py b/tests/search/test_linear_search.py new file mode 100644 index 00000000..bad1bbe3 --- /dev/null +++ b/tests/search/test_linear_search.py @@ -0,0 +1,101 @@ +import numpy as np + +from tensorflow_similarity.search import LinearSearch + + +def test_index_match(): + target = np.array([1, 1, 2], dtype="float32") + embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") + + search_index = LinearSearch("cosine", 3) + search_index.add(embs[0], 0) + search_index.add(embs[1], 1) + + idxs, embs = search_index.lookup(target, k=2) + + assert len(embs) == 2 + assert list(idxs) == [0, 1] + + +def test_index_save(tmp_path): + target = np.array([1, 1, 2], dtype="float32") + embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") + k = 2 + + search_index = LinearSearch("cosine", 3) + search_index.add(embs[0], 0) + search_index.add(embs[1], 1) + + idxs, embs = search_index.lookup(target, k=k) + + assert len(embs) == k + assert list(idxs) == [0, 1] + + search_index.save(tmp_path) + + search_index2 = LinearSearch("cosine", 3) + search_index2.load(tmp_path) + + idxs2, embs2 = search_index.lookup(target, k=k) + assert len(embs2) == k + assert list(idxs2) == [0, 1] + + # add more + # if the dtype is not passed we get an incompatible type error + search_index2.add(np.array([3.0, 3.0, 3.0], dtype="float32"), 3) + idxs3, embs3 = search_index2.lookup(target, k=3) + assert len(embs3) == 3 + assert list(idxs3) == [0, 3, 1] + + +def test_batch_vs_single(tmp_path): + num_targets = 10 + index_size = 100 + vect_dim = 16 + + # gen + idxs = list(range(index_size)) + + targets = np.random.random((num_targets, vect_dim)).astype("float32") + embs = np.random.random((index_size, vect_dim)).astype("float32") + + # build search_index + search_index = LinearSearch("cosine", vect_dim) + search_index.batch_add(embs, idxs) + + # batch + batch_idxs, _ = search_index.batch_lookup(targets) + + # single + singles_idxs = [] + for t in targets: + idxs, embs = search_index.lookup(t) + singles_idxs.append(idxs) + + for i in range(num_targets): + # k neigboors are the same? + for k in range(3): + assert batch_idxs[i][k] == singles_idxs[i][k] + + +def test_running_larger_batches(): + num_targets = 10 + index_size = 1000 + vect_dim = 16 + + # gen + idxs = np.array(list(range(index_size))) + + targets = np.random.random((num_targets, vect_dim)).astype("float32") + embs = np.random.random((index_size, vect_dim)).astype("float32") + + search_index = LinearSearch("cosine", vect_dim) + assert search_index.is_built() == True + last_idx = 0 + for i in range(1000): + idxs = np.array(list(range(last_idx, last_idx + index_size))) + embs = np.random.random((index_size, vect_dim)).astype("float32") + last_idx += index_size + search_index.batch_add(embs, idxs) + found_idxs, found_dists = search_index.batch_lookup(targets, 2) + assert found_idxs.shape == (10, 2) diff --git a/tests/stores/test_cached_store.py b/tests/stores/test_cached_store.py new file mode 100644 index 00000000..036b8c1f --- /dev/null +++ b/tests/stores/test_cached_store.py @@ -0,0 +1,68 @@ +import numpy as np + +from tensorflow_similarity.stores import CachedStore + + +def build_store(records): + kv_store = CachedStore() + idxs = [] + for r in records: + idx = kv_store.add(r[0], r[1], r[2]) + idxs.append(idx) + return kv_store, idxs + + +def test_cached_store_and_retrieve(): + records = [[[0.1, 0.2], 1, [0, 0, 0]], [[0.2, 0.3], 2, [0, 0, 0]]] + + kv_store, idxs = build_store(records) + + # check index numbering + for gt, idx in enumerate(idxs): + assert isinstance(idx, int) + assert gt == idx + + # check reference counting + assert kv_store.size() == 2 + + # get back three elements + for idx in idxs: + emb, lbl, dt = kv_store.get(idx) + assert emb == records[idx][0] + assert lbl == records[idx][1] + assert dt == records[idx][2] + + +def test_batch_add(): + embs = np.array([[0.1, 0.2], [0.2, 0.3]]) + lbls = np.array([1, 2]) + data = np.array([[0, 0, 0], [1, 1, 1]]) + + kv_store = CachedStore() + idxs = kv_store.batch_add(embs, lbls, data) + for idx in idxs: + emb, lbl, dt = kv_store.get(idx) + assert np.array_equal(emb, embs[idx]) + assert np.array_equal(lbl, lbls[idx]) + assert np.array_equal(dt, data[idx]) + + +def test_save_and_reload(tmp_path): + records = [[[0.1, 0.2], 1, [0, 0, 0]], [[0.2, 0.3], 2, [0, 0, 0]]] + + kv_store, idxs = build_store(records) + kv_store.save(tmp_path) + + # reload + reloaded_store = CachedStore() + print(f"loading from {tmp_path}") + reloaded_store.load(tmp_path) + + assert reloaded_store.size() == 2 + + # get back three elements + for idx in idxs: + emb, lbl, dt = reloaded_store.get(idx) + assert np.array_equal(emb, records[idx][0]) + assert np.array_equal(lbl, records[idx][1]) + assert np.array_equal(dt, records[idx][2]) diff --git a/tests/stores/test_redis_store.py b/tests/stores/test_redis_store.py new file mode 100644 index 00000000..d739ebde --- /dev/null +++ b/tests/stores/test_redis_store.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock +from unittest.mock import patch + +import numpy as np +from tensorflow_similarity.stores import RedisStore + + +def build_store(records): + kv_store = RedisStore() + idxs = [] + for r in records: + idx = kv_store.add(r[0], r[1], r[2]) + idxs.append(idx) + return kv_store, idxs + + +@patch("redis.Redis", return_value=MagicMock()) +def test_store_and_retrieve(mock_redis): + records = [[[0.1, 0.2], 1, [0, 0, 0]], [[0.2, 0.3], 2, [0, 0, 0]]] + mock_redis.return_value.get.side_effect = records + mock_redis.return_value.incr.side_effect = [1, 2, 3, 4, 5] + + kv_store, idxs = build_store(records) + + # check index numbering + for gt, idx in enumerate(idxs): + assert isinstance(idx, int) + assert gt == idx + + # get back three elements + for idx in idxs: + emb, lbl, dt = kv_store.get(idx) + assert emb == records[idx][0] + assert lbl == records[idx][1] + assert dt == records[idx][2] + + +@patch("redis.Redis", return_value=MagicMock()) +def test_batch_add(mock_redis): + embs = np.array([[0.1, 0.2], [0.2, 0.3]]) + lbls = np.array([1, 2]) + data = np.array([[0, 0, 0], [1, 1, 1]]) + + mock_redis.return_value.get.side_effect = [[embs[i], lbls[i], data[i]] for i in range(2)] + mock_redis.return_value.incr.side_effect = [1, 2, 3, 4, 5] + + kv_store = RedisStore() + idxs = kv_store.batch_add(embs, lbls, data) + for idx in idxs: + emb, lbl, dt = kv_store.get(idx) + assert np.array_equal(emb, embs[idx]) + assert np.array_equal(lbl, lbls[idx]) + assert np.array_equal(dt, data[idx]) From 7286fd336d5823f2a63a278b1db179cc3caffa52 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 27 Feb 2023 14:25:28 -0800 Subject: [PATCH 03/35] formatting and fixing couple of issues --- tensorflow_similarity/base_indexer.py | 10 +++--- tensorflow_similarity/indexer.py | 32 +++++++------------ tensorflow_similarity/search/faiss_search.py | 10 +++--- tensorflow_similarity/search/linear_search.py | 4 +-- tensorflow_similarity/stores/cached_store.py | 6 +--- tensorflow_similarity/stores/redis_store.py | 1 + 6 files changed, 25 insertions(+), 38 deletions(-) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index 0ce32e82..4ef65861 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -1,15 +1,14 @@ from abc import ABC, abstractmethod import numpy as np import tensorflow as tf -from .types import CalibrationResults, FloatTensor, Lookup, PandasDataFrame, Tensor +from .types import CalibrationResults, FloatTensor, Lookup, Tensor from collections.abc import Mapping, MutableMapping, Sequence from .retrieval_metrics import RetrievalMetric -from .distances import Distance, distance_canonicalizer -from .evaluators import Evaluator, MemoryEvaluator +from .distances import distance_canonicalizer from .matchers import ClassificationMatch, make_classification_matcher -from .retrieval_metrics import RetrievalMetric from .utils import unpack_lookup_distances, unpack_lookup_labels -from collections import defaultdict, deque +from collections import defaultdict +from tqdm.auto import tqdm from .classification_metrics import ( @@ -17,7 +16,6 @@ F1Score, make_classification_metric, ) -from .matchers import ClassificationMatch, make_classification_matcher from tabulate import tabulate diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index a16654ab..c6755b62 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -18,17 +18,13 @@ import json from collections import defaultdict, deque -from collections.abc import Mapping, MutableMapping, Sequence from pathlib import Path from time import time from .base_indexer import BaseIndexer from typing import ( DefaultDict, Deque, - Dict, List, - Mapping, - MutableMapping, Optional, Sequence, Union, @@ -40,20 +36,16 @@ from tqdm.auto import tqdm from .classification_metrics import ( - ClassificationMetric, F1Score, make_classification_metric, ) # internal -from .distances import Distance, distance_canonicalizer +from .distances import Distance from .evaluators import Evaluator, MemoryEvaluator -from .matchers import ClassificationMatch, make_classification_matcher -from .retrieval_metrics import RetrievalMetric -from .search import NMSLibSearch, Search, make_search +from .search import NMSLibSearch, Search, make_search, LinearSearch from .stores import MemoryStore, Store -from .types import CalibrationResults, FloatTensor, Lookup, PandasDataFrame, Tensor -from .utils import unpack_lookup_distances, unpack_lookup_labels +from .types import FloatTensor, Lookup, PandasDataFrame, Tensor class Indexer(BaseIndexer): @@ -134,7 +126,7 @@ def _init_structures(self) -> None: if self.search_type == "nmslib": self.search: Search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) elif self.search_type == "linear": - self.search = LinearSearch(distance=self.distance, dim=embedding_size) + self.search = LinearSearch(distance=self.distance, dim=self.embedding_size) elif isinstance(self.search_type, Search): self.search = self.search_type else: @@ -157,8 +149,8 @@ def _init_structures(self) -> None: raise ValueError("You need to either supply a know evaluator name " "or an Evaluator() object") # stats - self._stats: defaultdict[str, int] = defaultdict(int) - self._lookup_timings_buffer: deque[float] = deque([], maxlen=self.stat_buffer_size) + self._stats: DefaultDict[str, int] = defaultdict(int) + self._lookup_timings_buffer: Deque[float] = deque([], maxlen=self.stat_buffer_size) # calibration data self.is_calibrated = False @@ -206,7 +198,7 @@ def _get_embeddings(self, predictions: FloatTensor) -> FloatTensor: embeddings = predictions return embeddings - def _cast_label(self, label: int | None) -> int | None: + def _cast_label(self, label: Optional[int]) -> Optional[int]: if label is not None: label = int(label) return label @@ -214,7 +206,7 @@ def _cast_label(self, label: int | None) -> int | None: def add( self, prediction: FloatTensor, - label: int | None = None, + label: Optional[int] = None, data: Tensor = None, build: bool = True, verbose: int = 1, @@ -251,8 +243,8 @@ def add( def batch_add( self, predictions: FloatTensor, - labels: Sequence[int] | None = None, - data: Tensor | None = None, + labels: Optional[Sequence[int]] = None, + data: Optional[Tensor] = None, build: bool = True, verbose: int = 1, ): @@ -282,7 +274,7 @@ def batch_add( idxs = self.kv_store.batch_add(embeddings, labels, data) self.search.batch_add(embeddings, idxs, build=build, verbose=verbose) - def single_lookup(self, prediction: FloatTensor, k: int = 5) -> list[Lookup]: + def single_lookup(self, prediction: FloatTensor, k: int = 5) -> List[Lookup]: """Find the k closest matches of a given embedding Args: @@ -317,7 +309,7 @@ def single_lookup(self, prediction: FloatTensor, k: int = 5) -> list[Lookup]: self._stats["num_lookups"] += 1 return lookups - def batch_lookup(self, predictions: FloatTensor, k: int = 5, verbose: int = 1) -> list[list[Lookup]]: + def batch_lookup(self, predictions: FloatTensor, k: int = 5, verbose: int = 1) -> List[List[Lookup]]: """Find the k closest matches for a set of embeddings diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index 754e740f..85037b46 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -1,6 +1,6 @@ """The module to handle FAISS search.""" -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from termcolor import cprint from .search import Search import faiss @@ -121,8 +121,8 @@ def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[i if self.normalize: faiss.normalize_L2(embeddings) - D, I = self.index.search(embeddings, k) - return I, D + sims, indices = self.index.search(embeddings, k) + return indices, sims def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[float]]: """Find embedding K nearest neighboors embeddings. @@ -134,8 +134,8 @@ def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[fl int_embedding = np.array([embedding], dtype=np.float32) if self.normalize: faiss.normalize_L2(int_embedding) - D, I = self.index.search(int_embedding, k) - return I[0], D[0] + sims, indices = self.index.search(int_embedding, k) + return indices[0], sims[0] def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): """Add a single embedding to the search index. diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index 7cbac45e..908b2cc6 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -4,7 +4,6 @@ from .search import Search from tensorflow_similarity.distances import Distance from tensorflow_similarity.types import FloatTensor -from pathlib import Path from typing import Any import numpy as np import tensorflow as tf @@ -147,6 +146,7 @@ def save(self, path: str): """ with open(self.__make_file_path(path), "wb") as f: pickle.dump((self.db, self.ids), f) + self.__save_config(path) def load(self, path: str): """load index on disk @@ -162,7 +162,7 @@ def load(self, path: str): def __make_config_path(self, path): return path / "config.json" - def __save_config(self): + def __save_config(self, path): with open(self.__make_config_file_path(path), "wt") as f: json.dump(self.get_config(), f) diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index b17f3564..d64fe386 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -13,13 +13,9 @@ # limitations under the License. from __future__ import annotations -import io from collections.abc import Sequence from pathlib import Path -import numpy as np -import pandas as pd -import tensorflow as tf import pickle import shutil import dbm @@ -205,7 +201,7 @@ def load(self, path: str) -> int: self.__load_config(path) num_shards = int(math.ceil(self.num_items / self.shard_size)) self.path = path - for i in range(self.num_items): + for i in range(num_shards): self.__add_new_shard() return self.size() diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index 14f57632..387234d8 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -15,6 +15,7 @@ from collections.abc import Sequence +import json import redis from .store import Store From b8b883b36eb76063d99d4d319360f1ae22fa0135 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Tue, 28 Feb 2023 09:21:27 -0800 Subject: [PATCH 04/35] fix the backward compatibility issue --- tensorflow_similarity/base_indexer.py | 2 ++ tensorflow_similarity/search/faiss_search.py | 2 ++ tensorflow_similarity/search/linear_search.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index 4ef65861..b4970529 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod import numpy as np import tensorflow as tf diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index 85037b46..68dbe8ef 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -1,5 +1,7 @@ """The module to handle FAISS search.""" +from __future__ import annotations + from collections.abc import Sequence from termcolor import cprint from .search import Search diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index 908b2cc6..911e6bba 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -1,5 +1,7 @@ """The module to handle Linear search.""" +from __future__ import annotations + from collections.abc import Sequence from .search import Search from tensorflow_similarity.distances import Distance From 8d7b2015fe85e26e2e5a3d67dce36d1cf6502cff Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Tue, 28 Feb 2023 09:32:03 -0800 Subject: [PATCH 05/35] add dependencies --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index dc5f63a0..a211c40a 100644 --- a/setup.py +++ b/setup.py @@ -72,6 +72,8 @@ def get_version(rel_path): "dev": [ "flake8", "black", + "faiss", + "faiss-gpu", "pre-commit", "isort", "mkdocs", @@ -81,6 +83,7 @@ def get_version(rel_path): "mypy<=0.982", "pytest", "pytype", + "redis", "setuptools", "types-termcolor", "twine", From ae615ace2e8e9c6e7e23d6d2a07144e5da08e02b Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Tue, 28 Feb 2023 09:35:51 -0800 Subject: [PATCH 06/35] remove dependencies --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index a211c40a..e5f253f1 100644 --- a/setup.py +++ b/setup.py @@ -72,7 +72,6 @@ def get_version(rel_path): "dev": [ "flake8", "black", - "faiss", "faiss-gpu", "pre-commit", "isort", From da38cd413b1079a6c65bdb53e1aab5a780ed5971 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Tue, 28 Feb 2023 20:47:33 -0800 Subject: [PATCH 07/35] fixed typing issues --- setup.py | 1 + tensorflow_similarity/base_indexer.py | 17 +++++++++++++--- tensorflow_similarity/indexer.py | 8 -------- tensorflow_similarity/search/faiss_search.py | 1 - tensorflow_similarity/search/linear_search.py | 8 ++++---- tensorflow_similarity/stores/cached_store.py | 7 +++++-- tensorflow_similarity/stores/redis_store.py | 20 +++++++++++-------- 7 files changed, 36 insertions(+), 26 deletions(-) diff --git a/setup.py b/setup.py index e5f253f1..480d7391 100644 --- a/setup.py +++ b/setup.py @@ -84,6 +84,7 @@ def get_version(rel_path): "pytype", "redis", "setuptools", + "types-redis", "types-termcolor", "twine", "types-tabulate", diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index b4970529..b9fe6eff 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -31,6 +31,16 @@ def __init__(self, distance, embedding_output, embedding_size, evaluator, stat_b # internal structure naming # FIXME support custom objects self.evaluator_type = evaluator + + self.evaluator: Optional[Evaluator] = None + + # code used to evaluate indexer performance + if self.evaluator_type == "memory": + self.evaluator: Evaluator = MemoryEvaluator() + elif isinstance(self.evaluator_type, Evaluator): + self.evaluator: Evaluator = self.evaluator_type + else: + raise ValueError("You need to either supply a know evaluator name " "or an Evaluator() object") # stats configuration self.stat_buffer_size = stat_buffer_size @@ -92,11 +102,12 @@ def evaluate_retrieval( lookups = self.batch_lookup(predictions, k=k, verbose=verbose) # Evaluate them - return self.evaluator.evaluate_retrieval( + eval_ret : dict[str, np.ndarray] = self.evaluator.evaluate_retrieval( retrieval_metrics=retrieval_metrics, target_labels=target_labels, lookups=lookups, ) + return eval_ret def evaluate_classification( self, @@ -154,7 +165,7 @@ def evaluate_classification( dtype=lookup_distances.dtype, ) - results = self.evaluator.evaluate_classification( + results : dict[str, np.ndarray] = self.evaluator.evaluate_classification( query_labels=query_labels, lookup_labels=lookup_labels, lookup_distances=lookup_distances, @@ -229,7 +240,7 @@ def calibrate( combined_metrics: list[ClassificationMetric] = [make_classification_metric(m) for m in extra_metrics] # running calibration - calibration_results = self.evaluator.calibrate( + calibration_results : CalibrationResults = self.evaluator.calibrate( target_labels=target_labels, lookups=lookups, thresholds_targets=thresholds_targets, diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index c6755b62..239cf15f 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -140,14 +140,6 @@ def _init_structures(self) -> None: else: raise ValueError("You need to either supply a know key value " "store name or a Store() object") - # code used to evaluate indexer performance - if self.evaluator_type == "memory": - self.evaluator: Evaluator = MemoryEvaluator() - elif isinstance(self.evaluator_type, Evaluator): - self.evaluator = self.evaluator_type - else: - raise ValueError("You need to either supply a know evaluator name " "or an Evaluator() object") - # stats self._stats: DefaultDict[str, int] = defaultdict(int) self._lookup_timings_buffer: Deque[float] = deque([], maxlen=self.stat_buffer_size) diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index 68dbe8ef..739f0fdd 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -60,7 +60,6 @@ def __init__( f"| - nlist: {self.nlist}", f"| - nprobe: {self.nprobe}", f"| - normalize: {self.normalize}", - f"| - query_params: {self.query_params}", ] cprint("\n".join(t_msg) + "\n", "green") diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index 911e6bba..e0b9067f 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -6,7 +6,7 @@ from .search import Search from tensorflow_similarity.distances import Distance from tensorflow_similarity.types import FloatTensor -from typing import Any +from typing import Any, List import numpy as np import tensorflow as tf import pickle @@ -52,7 +52,7 @@ def __init__( ] cprint("\n".join(t_msg) + "\n", "green") self.db = np.empty((INITIAL_DB_SIZE, dim), dtype=np.float32) - self.ids = [] + self.ids: List[int] = [] def is_built(self): return True @@ -73,7 +73,7 @@ def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[i sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) similarity, id_idxs = tf.math.top_k(sims, k) ids_array = np.array(self.ids) - return np.array([ids_array[x.numpy()] for x in id_idxs]), similarity + return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(similarity) def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[float]]: """Find embedding K nearest neighboors embeddings. @@ -87,7 +87,7 @@ def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[fl sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) similarity, id_idxs = tf.math.top_k(sims, k) ids_array = np.array(self.ids) - return np.array(ids_array[id_idxs[0].numpy()]), similarity[0] + return list(np.array(ids_array[id_idxs[0].numpy()])), list(similarity[0]) def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): """Add a single embedding to the search index. diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index d64fe386..9e86f40b 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -21,6 +21,7 @@ import dbm import json import math +import pandas as pd from tensorflow_similarity.types import FloatTensor, PandasDataFrame, Tensor @@ -215,7 +216,9 @@ def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: Defaults to 0 (unlimited). Returns: - None + Empty DataFrame """ - return None + # forcing type from Any to PandasFrame + df: PandasDataFrame = pd.DataFrame() + return df diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index 387234d8..644736b1 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -16,6 +16,8 @@ from collections.abc import Sequence import json +import pandas as pd +import pickle import redis from .store import Store @@ -51,14 +53,14 @@ def add( Returns: Associated record id. """ - num_items = self.__conn.incr("num_items") + num_items = int(self.__conn.incr("num_items")) idx = num_items - 1 - self.__conn.set(idx, (embedding, label, data)) + self.__conn.set(idx, pickle.dumps((embedding, label, data))) return idx - def get_num_items(self): - return self.__conn.get("num_items") or 0 + def get_num_items(self) -> int: + return int(self.__conn.get("num_items")) or 0 def batch_add( self, @@ -100,7 +102,8 @@ def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: record associated with the requested id. """ - return self.__conn.get(str(idx)) + ret = pickle.loads(self.__conn.get(idx)) + return ret[0], ret[1], ret[2] def batch_get(self, idxs: Sequence[int]) -> tuple[list[FloatTensor], list[int | None], list[Tensor | None]]: """Get embedding records from the key value store. @@ -181,7 +184,8 @@ def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: Defaults to 0 (unlimited). Returns: - None + Empty DataFrame """ - - return None + # forcing type from Any to PandasFrame + df: PandasDataFrame = pd.DataFrame() + return df From b785a358e607a7cec4fc46acd06683f910f541e6 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Tue, 28 Feb 2023 21:53:18 -0800 Subject: [PATCH 08/35] remove extra typing --- .pre-commit-config.yaml | 2 +- tensorflow_similarity/base_indexer.py | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8dfcf5af..2003b47e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: stages: ['commit'] - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort name: isort (python) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index b9fe6eff..d50cc68c 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -1,24 +1,24 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Mapping, MutableMapping, Sequence + import numpy as np import tensorflow as tf -from .types import CalibrationResults, FloatTensor, Lookup, Tensor -from collections.abc import Mapping, MutableMapping, Sequence -from .retrieval_metrics import RetrievalMetric -from .distances import distance_canonicalizer -from .matchers import ClassificationMatch, make_classification_matcher -from .utils import unpack_lookup_distances, unpack_lookup_labels -from collections import defaultdict +from tabulate import tabulate from tqdm.auto import tqdm - from .classification_metrics import ( ClassificationMetric, F1Score, make_classification_metric, ) -from tabulate import tabulate +from .distances import distance_canonicalizer +from .matchers import ClassificationMatch, make_classification_matcher +from .retrieval_metrics import RetrievalMetric +from .types import CalibrationResults, FloatTensor, Lookup, Tensor +from .utils import unpack_lookup_distances, unpack_lookup_labels class BaseIndexer(ABC): @@ -31,8 +31,6 @@ def __init__(self, distance, embedding_output, embedding_size, evaluator, stat_b # internal structure naming # FIXME support custom objects self.evaluator_type = evaluator - - self.evaluator: Optional[Evaluator] = None # code used to evaluate indexer performance if self.evaluator_type == "memory": @@ -102,7 +100,7 @@ def evaluate_retrieval( lookups = self.batch_lookup(predictions, k=k, verbose=verbose) # Evaluate them - eval_ret : dict[str, np.ndarray] = self.evaluator.evaluate_retrieval( + eval_ret: dict[str, np.ndarray] = self.evaluator.evaluate_retrieval( retrieval_metrics=retrieval_metrics, target_labels=target_labels, lookups=lookups, @@ -165,7 +163,7 @@ def evaluate_classification( dtype=lookup_distances.dtype, ) - results : dict[str, np.ndarray] = self.evaluator.evaluate_classification( + results: dict[str, np.ndarray] = self.evaluator.evaluate_classification( query_labels=query_labels, lookup_labels=lookup_labels, lookup_distances=lookup_distances, @@ -240,7 +238,7 @@ def calibrate( combined_metrics: list[ClassificationMetric] = [make_classification_metric(m) for m in extra_metrics] # running calibration - calibration_results : CalibrationResults = self.evaluator.calibrate( + calibration_results: CalibrationResults = self.evaluator.calibrate( target_labels=target_labels, lookups=lookups, thresholds_targets=thresholds_targets, From 7f0be6fd38f5880ceca3edbd03a61d37f49c6360 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Tue, 28 Feb 2023 21:58:35 -0800 Subject: [PATCH 09/35] move evaluator --- tensorflow_similarity/base_indexer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index d50cc68c..bdaa0d46 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -15,6 +15,7 @@ make_classification_metric, ) from .distances import distance_canonicalizer +from .evaluators import Evaluator, MemoryEvaluator from .matchers import ClassificationMatch, make_classification_matcher from .retrieval_metrics import RetrievalMetric from .types import CalibrationResults, FloatTensor, Lookup, Tensor From bbeaae756be0196946eb0d2913ed2076a3226465 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 08:57:12 -0800 Subject: [PATCH 10/35] fix tests --- tensorflow_similarity/search/linear_search.py | 15 +++++++++------ tensorflow_similarity/stores/redis_store.py | 13 +++++++------ tests/search/test_linear_search.py | 3 ++- tests/stores/test_redis_store.py | 13 +++++++++---- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index e0b9067f..12bc862e 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -2,17 +2,20 @@ from __future__ import annotations +import json +import pickle from collections.abc import Sequence -from .search import Search -from tensorflow_similarity.distances import Distance -from tensorflow_similarity.types import FloatTensor from typing import Any, List + import numpy as np import tensorflow as tf -import pickle -import json from termcolor import cprint +from tensorflow_similarity.distances import Distance +from tensorflow_similarity.types import FloatTensor + +from .search import Search + INITIAL_DB_SIZE = 10000 DB_SIZE_STEPS = 10000 @@ -165,7 +168,7 @@ def __make_config_path(self, path): return path / "config.json" def __save_config(self, path): - with open(self.__make_config_file_path(path), "wt") as f: + with open(self.__make_config_path(path), "wt") as f: json.dump(self.get_config(), f) def get_config(self) -> dict[str, Any]: diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index 644736b1..ac81c884 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -13,17 +13,17 @@ # limitations under the License. from __future__ import annotations +import json +import pickle from collections.abc import Sequence -import json import pandas as pd -import pickle import redis -from .store import Store - from tensorflow_similarity.types import FloatTensor, PandasDataFrame, Tensor +from .store import Store + class RedisStore(Store): """Efficient Redis dataset store""" @@ -102,8 +102,9 @@ def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: record associated with the requested id. """ - ret = pickle.loads(self.__conn.get(idx)) - return ret[0], ret[1], ret[2] + ret_bytes: bytes = self.__conn.get(idx) + ret: tuple = pickle.loads(ret_bytes) + return (ret[0], ret[1], ret[2]) def batch_get(self, idxs: Sequence[int]) -> tuple[list[FloatTensor], list[int | None], list[Tensor | None]]: """Get embedding records from the key value store. diff --git a/tests/search/test_linear_search.py b/tests/search/test_linear_search.py index bad1bbe3..1f85121c 100644 --- a/tests/search/test_linear_search.py +++ b/tests/search/test_linear_search.py @@ -98,4 +98,5 @@ def test_running_larger_batches(): last_idx += index_size search_index.batch_add(embs, idxs) found_idxs, found_dists = search_index.batch_lookup(targets, 2) - assert found_idxs.shape == (10, 2) + assert len(found_idxs) == 10 + assert len(found_idxs[0]) == 2 diff --git a/tests/stores/test_redis_store.py b/tests/stores/test_redis_store.py index d739ebde..975293f6 100644 --- a/tests/stores/test_redis_store.py +++ b/tests/stores/test_redis_store.py @@ -1,7 +1,8 @@ -from unittest.mock import MagicMock -from unittest.mock import patch +import pickle +from unittest.mock import MagicMock, patch import numpy as np + from tensorflow_similarity.stores import RedisStore @@ -17,7 +18,8 @@ def build_store(records): @patch("redis.Redis", return_value=MagicMock()) def test_store_and_retrieve(mock_redis): records = [[[0.1, 0.2], 1, [0, 0, 0]], [[0.2, 0.3], 2, [0, 0, 0]]] - mock_redis.return_value.get.side_effect = records + serialized_records = [pickle.dumps(x) for x in records] + mock_redis.return_value.get.side_effect = serialized_records mock_redis.return_value.incr.side_effect = [1, 2, 3, 4, 5] kv_store, idxs = build_store(records) @@ -41,7 +43,10 @@ def test_batch_add(mock_redis): lbls = np.array([1, 2]) data = np.array([[0, 0, 0], [1, 1, 1]]) - mock_redis.return_value.get.side_effect = [[embs[i], lbls[i], data[i]] for i in range(2)] + records = [[embs[i], lbls[i], data[i]] for i in range(2)] + + serialized_records = [pickle.dumps(r) for r in records] + mock_redis.return_value.get.side_effect = serialized_records mock_redis.return_value.incr.side_effect = [1, 2, 3, 4, 5] kv_store = RedisStore() From 40a72ca701069b96e7134d5aeb72671d62503537 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 11:52:05 -0800 Subject: [PATCH 11/35] switch from dbm to shelve --- tensorflow_similarity/stores/cached_store.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 9e86f40b..26b6c063 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -13,14 +13,13 @@ # limitations under the License. from __future__ import annotations +import json +import math +import shelve +import shutil from collections.abc import Sequence from pathlib import Path -import pickle -import shutil -import dbm -import json -import math import pandas as pd from tensorflow_similarity.types import FloatTensor, PandasDataFrame, Tensor @@ -43,7 +42,7 @@ def __get_shard_file_path(self, shard_no): return f"{self.path}/cache{shard_no}" def __make_new_shard(self, shard_no: int): - return dbm.open(self.__get_shard_file_path(shard_no), "c") + return shelve.open(self.__get_shard_file_path(shard_no), "c") def __add_new_shard(self): shard_no = len(self.db) @@ -75,7 +74,7 @@ def add( shard_no = idx // self.shard_size if len(self.db) <= shard_no: self.__add_new_shard() - self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, data)) + self.db[shard_no][str(idx)] = (embedding, label, data) self.num_items += 1 return idx @@ -108,7 +107,7 @@ def batch_add( shard_no = idx // self.shard_size if len(self.db) <= shard_no: self.__add_new_shard() - self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, rec_data)) + self.db[shard_no][str(idx)] = (embedding, label, rec_data) idxs.append(idx) return idxs @@ -124,7 +123,7 @@ def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: """ shard_no = idx // self.shard_size - embedding, label, data = pickle.loads(self.db[shard_no][str(idx)]) + embedding, label, data = self.db[shard_no][str(idx)] return embedding, label, data def batch_get(self, idxs: Sequence[int]) -> tuple[list[FloatTensor], list[int | None], list[Tensor | None]]: From 6bd1581757ae0eb6304fbbc7ec8248bdefb0ed16 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 12:31:22 -0800 Subject: [PATCH 12/35] set temp dir for storing cached store --- tensorflow_similarity/stores/cached_store.py | 4 +-- tests/stores/test_cached_store.py | 27 ++++++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 26b6c063..be00f53b 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -30,13 +30,13 @@ class CachedStore(Store): """Efficient cached dataset store""" - def __init__(self, shard_size=1000000) -> None: + def __init__(self, shard_size=1000000, path=".") -> None: # We are using a native python cached dictionary # db[id] = pickle((embedding, label, data)) self.db: list[dict[str, str]] = [] self.shard_size = shard_size self.num_items: int = 0 - self.path: str = "." + self.path: str = path def __get_shard_file_path(self, shard_no): return f"{self.path}/cache{shard_no}" diff --git a/tests/stores/test_cached_store.py b/tests/stores/test_cached_store.py index 036b8c1f..a5d67d17 100644 --- a/tests/stores/test_cached_store.py +++ b/tests/stores/test_cached_store.py @@ -1,10 +1,12 @@ +import os + import numpy as np from tensorflow_similarity.stores import CachedStore -def build_store(records): - kv_store = CachedStore() +def build_store(records, path): + kv_store = CachedStore(path=path) idxs = [] for r in records: idx = kv_store.add(r[0], r[1], r[2]) @@ -12,10 +14,10 @@ def build_store(records): return kv_store, idxs -def test_cached_store_and_retrieve(): +def test_cached_store_and_retrieve(tmp_path): records = [[[0.1, 0.2], 1, [0, 0, 0]], [[0.2, 0.3], 2, [0, 0, 0]]] - kv_store, idxs = build_store(records) + kv_store, idxs = build_store(records, tmp_path) # check index numbering for gt, idx in enumerate(idxs): @@ -33,12 +35,12 @@ def test_cached_store_and_retrieve(): assert dt == records[idx][2] -def test_batch_add(): +def test_batch_add(tmp_path): embs = np.array([[0.1, 0.2], [0.2, 0.3]]) lbls = np.array([1, 2]) data = np.array([[0, 0, 0], [1, 1, 1]]) - kv_store = CachedStore() + kv_store = CachedStore(path=tmp_path) idxs = kv_store.batch_add(embs, lbls, data) for idx in idxs: emb, lbl, dt = kv_store.get(idx) @@ -50,13 +52,18 @@ def test_batch_add(): def test_save_and_reload(tmp_path): records = [[[0.1, 0.2], 1, [0, 0, 0]], [[0.2, 0.3], 2, [0, 0, 0]]] - kv_store, idxs = build_store(records) - kv_store.save(tmp_path) + save_path = tmp_path / "save" + os.mkdir(save_path) + obj_path = tmp_path / "obj" + os.mkdir(obj_path) + + kv_store, idxs = build_store(records, obj_path) + kv_store.save(save_path) # reload reloaded_store = CachedStore() - print(f"loading from {tmp_path}") - reloaded_store.load(tmp_path) + print(f"loading from {save_path}") + reloaded_store.load(save_path) assert reloaded_store.size() == 2 From a7860a6f96063c172f325f73e98af98a43969ca9 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 13:05:00 -0800 Subject: [PATCH 13/35] add debug logging --- tests/stores/test_cached_store.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/stores/test_cached_store.py b/tests/stores/test_cached_store.py index a5d67d17..80f2dd98 100644 --- a/tests/stores/test_cached_store.py +++ b/tests/stores/test_cached_store.py @@ -58,6 +58,7 @@ def test_save_and_reload(tmp_path): os.mkdir(obj_path) kv_store, idxs = build_store(records, obj_path) + logging.info(f"obj_path={os.listdir(obj_path)}\nsave_path={os.listdir(save_path)}") kv_store.save(save_path) # reload From dca72ec73ac58f0ef245f60afb8c4dccd416b964 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 13:19:54 -0800 Subject: [PATCH 14/35] add debug logging --- tests/stores/test_cached_store.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/stores/test_cached_store.py b/tests/stores/test_cached_store.py index 80f2dd98..31f26c3a 100644 --- a/tests/stores/test_cached_store.py +++ b/tests/stores/test_cached_store.py @@ -1,3 +1,4 @@ +import logging import os import numpy as np From 5b1ba06ffca3c4c8ee3d6a7c16bd4a375391e3a3 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 14:07:07 -0800 Subject: [PATCH 15/35] specify dbm implementation for cross-machine compatibility --- tensorflow_similarity/stores/cached_store.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index be00f53b..19296ad8 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -13,9 +13,10 @@ # limitations under the License. from __future__ import annotations +import dbm import json import math -import shelve +import pickle import shutil from collections.abc import Sequence from pathlib import Path @@ -42,7 +43,7 @@ def __get_shard_file_path(self, shard_no): return f"{self.path}/cache{shard_no}" def __make_new_shard(self, shard_no: int): - return shelve.open(self.__get_shard_file_path(shard_no), "c") + return dbm.ndbm.open(self.__get_shard_file_path(shard_no), "c") def __add_new_shard(self): shard_no = len(self.db) @@ -74,7 +75,7 @@ def add( shard_no = idx // self.shard_size if len(self.db) <= shard_no: self.__add_new_shard() - self.db[shard_no][str(idx)] = (embedding, label, data) + self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, data)) self.num_items += 1 return idx @@ -107,7 +108,7 @@ def batch_add( shard_no = idx // self.shard_size if len(self.db) <= shard_no: self.__add_new_shard() - self.db[shard_no][str(idx)] = (embedding, label, rec_data) + self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, rec_data)) idxs.append(idx) return idxs @@ -123,7 +124,7 @@ def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: """ shard_no = idx // self.shard_size - embedding, label, data = self.db[shard_no][str(idx)] + embedding, label, data = pickle.loads(self.db[shard_no][str(idx)]) return embedding, label, data def batch_get(self, idxs: Sequence[int]) -> tuple[list[FloatTensor], list[int | None], list[Tensor | None]]: From fca4f6baaf5047a566e54a135b1a4c13f35f2a2a Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 14:20:54 -0800 Subject: [PATCH 16/35] switch to ndb.dumb as other options not available on all machines --- tensorflow_similarity/stores/cached_store.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 19296ad8..406673d9 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -13,7 +13,7 @@ # limitations under the License. from __future__ import annotations -import dbm +import dbm.dumb import json import math import pickle @@ -43,7 +43,7 @@ def __get_shard_file_path(self, shard_no): return f"{self.path}/cache{shard_no}" def __make_new_shard(self, shard_no: int): - return dbm.ndbm.open(self.__get_shard_file_path(shard_no), "c") + return dbm.dumb.open(self.__get_shard_file_path(shard_no), "c") def __add_new_shard(self): shard_no = len(self.db) @@ -156,7 +156,9 @@ def __close_all_shards(self): def __copy_shards(self, path): for shard_no in range(len(self.db)): - shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix(".db"), path) + shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix(".bak"), path) + shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix(".dat"), path) + shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix(".dir"), path) def __make_config_file_path(self, path): return path / "config.json" From 187785b56f908e13ba82119c8330bf0fce828edd Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 14:45:53 -0800 Subject: [PATCH 17/35] fix import orders --- tensorflow_similarity/indexer.py | 18 ++++-------------- tensorflow_similarity/search/faiss_search.py | 11 +++++++---- tensorflow_similarity/stores/__init__.py | 4 ++-- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 239cf15f..f9193ae7 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -20,30 +20,20 @@ from collections import defaultdict, deque from pathlib import Path from time import time -from .base_indexer import BaseIndexer -from typing import ( - DefaultDict, - Deque, - List, - Optional, - Sequence, - Union, -) +from typing import DefaultDict, Deque, List, Optional, Sequence, Union import numpy as np import tensorflow as tf from tabulate import tabulate from tqdm.auto import tqdm -from .classification_metrics import ( - F1Score, - make_classification_metric, -) +from .base_indexer import BaseIndexer +from .classification_metrics import F1Score, make_classification_metric # internal from .distances import Distance from .evaluators import Evaluator, MemoryEvaluator -from .search import NMSLibSearch, Search, make_search, LinearSearch +from .search import LinearSearch, NMSLibSearch, Search, make_search from .stores import MemoryStore, Store from .types import FloatTensor, Lookup, PandasDataFrame, Tensor diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index 739f0fdd..24e42307 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -3,14 +3,17 @@ from __future__ import annotations from collections.abc import Sequence -from termcolor import cprint -from .search import Search +from pathlib import Path +from typing import Any + import faiss import numpy as np +from termcolor import cprint + from tensorflow_similarity.distances import Distance from tensorflow_similarity.types import FloatTensor -from pathlib import Path -from typing import Any + +from .search import Search class FaissSearch(Search): diff --git a/tensorflow_similarity/stores/__init__.py b/tensorflow_similarity/stores/__init__.py index a7c71e31..9a1950cb 100644 --- a/tensorflow_similarity/stores/__init__.py +++ b/tensorflow_similarity/stores/__init__.py @@ -27,7 +27,7 @@ via the `to_pandas()` method. """ -from .memory_store import MemoryStore # noqa -from .store import Store # noqa from .cached_store import CachedStore # noqa +from .memory_store import MemoryStore # noqa from .redis_store import RedisStore # noqa +from .store import Store # noqa From bc830c9f3ade919b8b259e2814c811588506a9f5 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 1 Mar 2023 19:53:12 -0800 Subject: [PATCH 18/35] remove extraneous logging --- tests/stores/test_cached_store.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/stores/test_cached_store.py b/tests/stores/test_cached_store.py index 31f26c3a..a5d67d17 100644 --- a/tests/stores/test_cached_store.py +++ b/tests/stores/test_cached_store.py @@ -1,4 +1,3 @@ -import logging import os import numpy as np @@ -59,7 +58,6 @@ def test_save_and_reload(tmp_path): os.mkdir(obj_path) kv_store, idxs = build_store(records, obj_path) - logging.info(f"obj_path={os.listdir(obj_path)}\nsave_path={os.listdir(save_path)}") kv_store.save(save_path) # reload From dac996465902b874d81ae1b6067ec3491d70c5d7 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 13:13:47 -0800 Subject: [PATCH 19/35] ensure only names are stored in metadata --- tensorflow_similarity/indexer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index f9193ae7..325ecb6e 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -101,8 +101,8 @@ def __init__( super().__init__(distance, embedding_output, embedding_size, evaluator, stat_buffer_size) # internal structure naming # FIXME support custom objects - self.search_type = search - self.kv_store_type = kv_store + self.search_type = search if isinstance(search, str) else type(search).__name__ + self.kv_store_type = kv_store if isinstance(kv_store, str) else type(kv_store).__name__ # initialize internal structures self._init_structures() From 799a1d8ad5fa455c8b3c3963af2cba43b2ab796b Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 13:25:37 -0800 Subject: [PATCH 20/35] separate store from store_type, and search from search_type, needed for serialization of metadata --- tensorflow_similarity/indexer.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 325ecb6e..db696d06 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -102,7 +102,11 @@ def __init__( # internal structure naming # FIXME support custom objects self.search_type = search if isinstance(search, str) else type(search).__name__ + if isinstance(search, Search): + self.search = search self.kv_store_type = kv_store if isinstance(kv_store, str) else type(kv_store).__name__ + if isinstance(kv_store, Store): + self.kv_store = kv_store # initialize internal structures self._init_structures() @@ -117,9 +121,8 @@ def _init_structures(self) -> None: self.search: Search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) elif self.search_type == "linear": self.search = LinearSearch(distance=self.distance, dim=self.embedding_size) - elif isinstance(self.search_type, Search): - self.search = self.search_type - else: + elif not isinstance(self.search, Search): + # self.search should have been already initialized raise ValueError("You need to either supply a known search " "framework name or a Search() object") # mapper from id to record data @@ -127,7 +130,8 @@ def _init_structures(self) -> None: self.kv_store: Store = MemoryStore() elif isinstance(self.kv_store_type, Store): self.kv_store = self.kv_store_type - else: + elif not isinstance(self.kv_store, Store): + # self.kv_store should have been already initialized raise ValueError("You need to either supply a know key value " "store name or a Store() object") # stats From 5e485411bbdfc3504d384ac545e069d543baab9f Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 13:37:56 -0800 Subject: [PATCH 21/35] use str path --- tensorflow_similarity/search/linear_search.py | 5 +++-- tensorflow_similarity/stores/cached_store.py | 2 +- tensorflow_similarity/stores/redis_store.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index 12bc862e..8a3d5131 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -5,6 +5,7 @@ import json import pickle from collections.abc import Sequence +from pathlib import Path from typing import Any, List import numpy as np @@ -141,7 +142,7 @@ def batch_add( self.db[items : items + len(embeddings)] = int_embeddings def __make_file_path(self, path): - return path / "index.pickle" + return Path(path) / "index.pickle" def save(self, path: str): """Serializes the index data on disk @@ -165,7 +166,7 @@ def load(self, path: str): self.ids = data[1] def __make_config_path(self, path): - return path / "config.json" + return Path(path) / "config.json" def __save_config(self, path): with open(self.__make_config_path(path), "wt") as f: diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 406673d9..a090f9a3 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -161,7 +161,7 @@ def __copy_shards(self, path): shutil.copy(Path(self.__get_shard_file_path(shard_no)).with_suffix(".dir"), path) def __make_config_file_path(self, path): - return path / "config.json" + return Path(path) / "config.json" def __save_config(self, path): with open(self.__make_config_file_path(path), "wt") as f: diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index ac81c884..dfff4e7d 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -16,6 +16,7 @@ import json import pickle from collections.abc import Sequence +from pathlib import Path import pandas as pd import redis @@ -130,7 +131,7 @@ def size(self) -> int: return self.get_num_items() def __make_config_file_path(self, path): - return path / "config.json" + return Path(path) / "config.json" def __save_config(self, path): with open(self.__make_config_file_path(path), "wt") as f: From 2a0dce3d668982b05ebd9dc61f0d0fae0d1656e6 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 14:12:19 -0800 Subject: [PATCH 22/35] put typing in one place --- tensorflow_similarity/indexer.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index db696d06..9cf2f21f 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -100,6 +100,8 @@ def __init__( """ super().__init__(distance, embedding_output, embedding_size, evaluator, stat_buffer_size) # internal structure naming + self.search: Optional[Search] = None + self.kv_store: Optional[Store] = None # FIXME support custom objects self.search_type = search if isinstance(search, str) else type(search).__name__ if isinstance(search, Search): @@ -118,7 +120,7 @@ def _init_structures(self) -> None: "(re)initialize internal storage structure" if self.search_type == "nmslib": - self.search: Search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) + self.search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) elif self.search_type == "linear": self.search = LinearSearch(distance=self.distance, dim=self.embedding_size) elif not isinstance(self.search, Search): @@ -127,13 +129,18 @@ def _init_structures(self) -> None: # mapper from id to record data if self.kv_store_type == "memory": - self.kv_store: Store = MemoryStore() + self.kv_store = MemoryStore() elif isinstance(self.kv_store_type, Store): self.kv_store = self.kv_store_type elif not isinstance(self.kv_store, Store): # self.kv_store should have been already initialized raise ValueError("You need to either supply a know key value " "store name or a Store() object") + if not self.search: + raise ValueError("search not initialized") + if not self.kv_store: + raise ValueError("kv_store not initialized") + # stats self._stats: DefaultDict[str, int] = defaultdict(int) self._lookup_timings_buffer: Deque[float] = deque([], maxlen=self.stat_buffer_size) From e54e3aa3665769184ea2b18b19e8d0c794536f9d Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 15:24:35 -0800 Subject: [PATCH 23/35] add canonical name for consistent reload --- tensorflow_similarity/indexer.py | 4 +- tensorflow_similarity/search/faiss_search.py | 4 +- tensorflow_similarity/search/linear_search.py | 3 +- tensorflow_similarity/search/utils.py | 4 ++ tensorflow_similarity/stores/__init__.py | 1 + tensorflow_similarity/stores/cached_store.py | 6 ++- tensorflow_similarity/stores/memory_store.py | 3 ++ tensorflow_similarity/stores/redis_store.py | 6 ++- tensorflow_similarity/stores/store.py | 12 +++++ tensorflow_similarity/stores/utils.py | 50 +++++++++++++++++++ 10 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 tensorflow_similarity/stores/utils.py diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 9cf2f21f..348ef8d3 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -34,7 +34,7 @@ from .distances import Distance from .evaluators import Evaluator, MemoryEvaluator from .search import LinearSearch, NMSLibSearch, Search, make_search -from .stores import MemoryStore, Store +from .stores import MemoryStore, Store, make_store from .types import FloatTensor, Lookup, PandasDataFrame, Tensor @@ -381,6 +381,7 @@ def save(self, path: str, compression: bool = True): "embedding_output": self.embedding_output, "embedding_size": self.embedding_size, "kv_store": self.kv_store_type, + "kv_store_config": self.kv_store.get_config(), "evaluator": self.evaluator_type, "search_config": self.search.get_config(), "stat_buffer_size": self.stat_buffer_size, @@ -416,6 +417,7 @@ def load(path: str | Path, verbose: int = 1): metadata = tf.keras.backend.eval(metadata) md = json.loads(metadata) search = make_search(md["search_config"]) + kv_store = make_store(md["kv_store_config"]) index = Indexer( distance=md["distance"], embedding_size=md["embedding_size"], diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index 24e42307..eaa37a32 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -221,5 +221,5 @@ def get_config(self) -> dict[str, Any]: "name": self.name, "canonical_name": self.__class__.__name__, } - - return config + base_config = super().get_config() + return {**base_config, **config} diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index 8a3d5131..de61addb 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -183,4 +183,5 @@ def get_config(self) -> dict[str, Any]: "dim": self.dim, } - return config + base_config = super().get_config() + return {**base_config, **config} diff --git a/tensorflow_similarity/search/utils.py b/tensorflow_similarity/search/utils.py index aded6a35..50d561e1 100644 --- a/tensorflow_similarity/search/utils.py +++ b/tensorflow_similarity/search/utils.py @@ -15,11 +15,15 @@ from typing import Any, Type +from .faiss_search import FaissSearch +from .linear_search import LinearSearch from .nmslib_search import NMSLibSearch from .search import Search SEARCH_ALIASES: dict[str, Type[Search]] = { "NMSLibSearch": NMSLibSearch, + "LinearSearch": LinearSearch, + "FaissSearch": FaissSearch, } diff --git a/tensorflow_similarity/stores/__init__.py b/tensorflow_similarity/stores/__init__.py index 9a1950cb..edb571ab 100644 --- a/tensorflow_similarity/stores/__init__.py +++ b/tensorflow_similarity/stores/__init__.py @@ -31,3 +31,4 @@ from .memory_store import MemoryStore # noqa from .redis_store import RedisStore # noqa from .store import Store # noqa +from .utils import make_store # noqa diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index a090f9a3..5be2004b 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -167,7 +167,7 @@ def __save_config(self, path): with open(self.__make_config_file_path(path), "wt") as f: json.dump(self.get_config(), f) - def __set_config(self, num_items, shard_size): + def __set_config(self, num_items, shard_size, **kw_args): self.num_items = num_items self.shard_size = shard_size @@ -190,7 +190,9 @@ def save(self, path: str, compression: bool = True) -> None: self.__reopen_all_shards() def get_config(self): - return {"shard_size": self.shard_size, "num_items": self.num_items} + config = {"shard_size": self.shard_size, "num_items": self.num_items} + base_config = super().get_config() + return {**base_config, **config} def load(self, path: str) -> int: """load index on disk diff --git a/tensorflow_similarity/stores/memory_store.py b/tensorflow_similarity/stores/memory_store.py index 6d2de8e8..b3372f62 100644 --- a/tensorflow_similarity/stores/memory_store.py +++ b/tensorflow_similarity/stores/memory_store.py @@ -207,3 +207,6 @@ def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: # forcing type from Any to PandasFrame df: PandasDataFrame = pd.DataFrame.from_dict(data) return df + + def get_config(self): + return super().get_config() diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index dfff4e7d..2b32988e 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -137,7 +137,7 @@ def __save_config(self, path): with open(self.__make_config_file_path(path), "wt") as f: json.dump(self.get_config(), f) - def __set_config(self, host, port, db): + def __set_config(self, host, port, db, **kw_args): self.host = host self.port = port self.db = db @@ -162,7 +162,9 @@ def save(self, path: str, compression: bool = True) -> None: self.__save_config(path) def get_config(self): - return {"host": self.host, "port": self.port, "db": self.db, "num_items": self.get_num_items()} + config = {"host": self.host, "port": self.port, "db": self.db, "num_items": self.get_num_items()} + base_config = super().get_config() + return {**base_config, **config} def load(self, path: str) -> int: """load index on disk diff --git a/tensorflow_similarity/stores/store.py b/tensorflow_similarity/stores/store.py index 7855b234..b3a29fb4 100644 --- a/tensorflow_similarity/stores/store.py +++ b/tensorflow_similarity/stores/store.py @@ -115,3 +115,15 @@ def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: Returns: pd.DataFrame: a pandas dataframe. """ + + def get_config(self) -> dict[str, Any]: + """Contains the Store configuration. + + Returns: + A Python dict containing the configuration of the Store obj. + """ + config = { + "canonical_name": self.__class__.__name__, + } + + return config diff --git a/tensorflow_similarity/stores/utils.py b/tensorflow_similarity/stores/utils.py new file mode 100644 index 00000000..ff1813b4 --- /dev/null +++ b/tensorflow_similarity/stores/utils.py @@ -0,0 +1,50 @@ +# Copyright 2021 The TensorFlow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import Any, Type + +from .cached_store import CachedStore +from .memory_store import MemoryStore +from .redis_store import RedisStore +from .store import Store + +STORE_ALIASES: dict[str, Type[Store]] = { + "RedisStore": RedisStore, + "CachedStore": CachedStore, + "MemoryStore": MemoryStore, +} + + +def make_store(config: dict[str, Any]) -> Store: + """Creates a store instance from its config. + + This method is the reverse of `get_config`, + capable of instantiating the same search from the config + + Args: + config: A Python dictionary, typically the output of get_config. + + Returns: + A Store instance. + """ + + if config["canonical_name"] in STORE_ALIASES: + config_copy = dict(config) + del config_copy["canonical_name"] + store: Store = STORE_ALIASES[config["canonical_name"]](**config_copy) + else: + raise ValueError(f"Unknown search type: {config['canonical_name']}") + + return store From 2a42c2f5bfa365405ee6307c2781925adabb8467 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 16:14:39 -0800 Subject: [PATCH 24/35] accept canonical_name --- tensorflow_similarity/search/faiss_search.py | 1 + tensorflow_similarity/search/linear_search.py | 8 +------- tensorflow_similarity/stores/cached_store.py | 2 +- tensorflow_similarity/stores/memory_store.py | 2 +- tensorflow_similarity/stores/redis_store.py | 2 +- tensorflow_similarity/stores/store.py | 1 + 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index eaa37a32..11f1584e 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -34,6 +34,7 @@ def __init__( nlist=1024, nprobe=1, normalize=True, + **kw_args, ): """Initiate FAISS indexer diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index de61addb..e0403b22 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -27,13 +27,7 @@ class LinearSearch(Search): It implements the Search interface. """ - def __init__( - self, - distance: Distance | str, - dim: int, - verbose: int = 0, - name: str | None = None, - ): + def __init__(self, distance: Distance | str, dim: int, verbose: int = 0, name: str | None = None, **kw_args): """Initiate Linear indexer. Args: diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 5be2004b..a467a086 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -31,7 +31,7 @@ class CachedStore(Store): """Efficient cached dataset store""" - def __init__(self, shard_size=1000000, path=".") -> None: + def __init__(self, shard_size=1000000, path=".", **kw_args) -> None: # We are using a native python cached dictionary # db[id] = pickle((embedding, label, data)) self.db: list[dict[str, str]] = [] diff --git a/tensorflow_similarity/stores/memory_store.py b/tensorflow_similarity/stores/memory_store.py index b3372f62..6792cf4b 100644 --- a/tensorflow_similarity/stores/memory_store.py +++ b/tensorflow_similarity/stores/memory_store.py @@ -29,7 +29,7 @@ class MemoryStore(Store): """Efficient in-memory dataset store""" - def __init__(self) -> None: + def __init__(self, **kw_args) -> None: # We are using a native python array in memory for its row speed. # Serialization / export relies on Arrow. self.labels: list[int | None] = [] diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index 2b32988e..2cad7610 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -29,7 +29,7 @@ class RedisStore(Store): """Efficient Redis dataset store""" - def __init__(self, host="localhost", port=6379, db=0) -> None: + def __init__(self, host="localhost", port=6379, db=0, **kw_args) -> None: # Currently does not support authentication self.host = host self.port = port diff --git a/tensorflow_similarity/stores/store.py b/tensorflow_similarity/stores/store.py index b3a29fb4..37d1dd48 100644 --- a/tensorflow_similarity/stores/store.py +++ b/tensorflow_similarity/stores/store.py @@ -15,6 +15,7 @@ from abc import ABC, abstractmethod from collections.abc import Sequence +from typing import Any from tensorflow_similarity.types import FloatTensor, PandasDataFrame, Tensor From 4acfa36e93e8c6df7726a6f3f7ad8393a3b46146 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 16:20:28 -0800 Subject: [PATCH 25/35] Remove optional --- tensorflow_similarity/indexer.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 348ef8d3..372155a4 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -100,15 +100,13 @@ def __init__( """ super().__init__(distance, embedding_output, embedding_size, evaluator, stat_buffer_size) # internal structure naming - self.search: Optional[Search] = None - self.kv_store: Optional[Store] = None # FIXME support custom objects self.search_type = search if isinstance(search, str) else type(search).__name__ if isinstance(search, Search): - self.search = search + self.search: Search = search self.kv_store_type = kv_store if isinstance(kv_store, str) else type(kv_store).__name__ if isinstance(kv_store, Store): - self.kv_store = kv_store + self.kv_store: Store = kv_store # initialize internal structures self._init_structures() From a5f1503d2de442bd5f687e76be3479a0681d03a6 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 22:54:52 -0800 Subject: [PATCH 26/35] adding more tests --- tensorflow_similarity/indexer.py | 16 +++++--- tensorflow_similarity/search/faiss_search.py | 2 +- tensorflow_similarity/stores/cached_store.py | 8 ++-- tests/test_indexer.py | 41 ++++++++++++++++++++ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 372155a4..740fc2b4 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import os from collections import defaultdict, deque from pathlib import Path from time import time @@ -194,6 +195,9 @@ def _cast_label(self, label: Optional[int]) -> Optional[int]: label = int(label) return label + def build_index(self, samples, **kwargss): + self.search.build_index(samples) + def add( self, prediction: FloatTensor, @@ -393,8 +397,10 @@ def save(self, path: str, compression: bool = True): metadata_fname = self.__make_metadata_fname(path) tf.io.write_file(metadata_fname, json.dumps(metadata)) - self.kv_store.save(path, compression=compression) - self.search.save(path) + os.mkdir(Path(path) / "store") + os.mkdir(Path(path) / "search") + self.kv_store.save(Path(path) / "store", compression=compression) + self.search.save(Path(path) / "search") @staticmethod def load(path: str | Path, verbose: int = 1): @@ -420,7 +426,7 @@ def load(path: str | Path, verbose: int = 1): distance=md["distance"], embedding_size=md["embedding_size"], embedding_output=md["embedding_output"], - kv_store=md["kv_store"], + kv_store=kv_store, evaluator=md["evaluator"], search=search, stat_buffer_size=md["stat_buffer_size"], @@ -429,12 +435,12 @@ def load(path: str | Path, verbose: int = 1): # reload the key value store if verbose: print("Loading index data") - index.kv_store.load(path) + index.kv_store.load(Path(path) / "store") # rebuild the index if verbose: print("Loading search index") - index.search.load(path) + index.search.load(Path(path) / "search") # reload calibration data if any index.is_calibrated = md["is_calibrated"] diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index 11f1584e..f1241dd8 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -180,7 +180,7 @@ def batch_add( if self.algo != "flat": # flat does not accept indexes as parameters and assumes incremental # indexes - self.index.add_with_ids(embeddings, idxs) + self.index.add_with_ids(embeddings, np.array(idxs)) else: self.index.add(embeddings) diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index a467a086..2afcdf80 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -31,12 +31,12 @@ class CachedStore(Store): """Efficient cached dataset store""" - def __init__(self, shard_size=1000000, path=".", **kw_args) -> None: + def __init__(self, shard_size=1000000, path=".", num_items=0, **kw_args) -> None: # We are using a native python cached dictionary # db[id] = pickle((embedding, label, data)) self.db: list[dict[str, str]] = [] self.shard_size = shard_size - self.num_items: int = 0 + self.num_items: int = num_items self.path: str = path def __get_shard_file_path(self, shard_no): @@ -110,6 +110,7 @@ def batch_add( self.__add_new_shard() self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, rec_data)) idxs.append(idx) + self.num_items += len(embeddings) return idxs @@ -173,7 +174,8 @@ def __set_config(self, num_items, shard_size, **kw_args): def __load_config(self, path): with open(self.__make_config_file_path(path), "rt") as f: - self.__set_config(**json.load(f)) + config = json.load(f) + self.__set_config(**config) def save(self, path: str, compression: bool = True) -> None: """Serializes index on disk. diff --git a/tests/test_indexer.py b/tests/test_indexer.py index 2ca33d80..a89dd12d 100644 --- a/tests/test_indexer.py +++ b/tests/test_indexer.py @@ -1,6 +1,8 @@ import numpy as np from tensorflow_similarity.indexer import Indexer +from tensorflow_similarity.search import FaissSearch, LinearSearch +from tensorflow_similarity.stores import CachedStore from . import DATA_DIR @@ -129,6 +131,45 @@ def test_uncompress_reload(tmp_path): assert indexer2.size() == 2 +def test_linear_search_reload(tmp_path): + "Ensure the save and load of custom search and store work" + embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") + search = LinearSearch("cosine", 3) + store = CachedStore() + + indexer = Indexer(3, search=search, kv_store=store) + indexer.batch_add(embs, verbose=0) + assert indexer.size() == 2 + + # save + path = tmp_path / "test_save_and_add/" + indexer.save(path, compression=False) + + # reload + indexer2 = Indexer.load(path) + assert indexer2.size() == 2 + + +def test_faiss_search_reload(tmp_path): + "Ensure the save and load of Faiss search and store work" + embs = np.random.random((1024, 8)).astype(np.float32) + search = FaissSearch("cosine", 8, m=4, nlist=2) + store = CachedStore() + + indexer = Indexer(8, search=search, kv_store=store) + indexer.build_index(embs) + indexer.batch_add(embs, verbose=0) + assert indexer.size() == 1024 + + # save + path = tmp_path / "test_save_and_add/" + indexer.save(path, compression=False) + + # reload + indexer2 = Indexer.load(path) + assert indexer2.size() == 1024 + + def test_index_reset(): prediction = np.array([[1, 1, 2]], dtype="float32") From fabf40de0cca02b60e4043365ff49cc0772d78da Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 6 Mar 2023 23:34:04 -0800 Subject: [PATCH 27/35] pass str for path --- tensorflow_similarity/indexer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 740fc2b4..569c5e26 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -399,8 +399,8 @@ def save(self, path: str, compression: bool = True): os.mkdir(Path(path) / "store") os.mkdir(Path(path) / "search") - self.kv_store.save(Path(path) / "store", compression=compression) - self.search.save(Path(path) / "search") + self.kv_store.save(str(Path(path) / "store"), compression=compression) + self.search.save(str(Path(path) / "search")) @staticmethod def load(path: str | Path, verbose: int = 1): @@ -435,12 +435,12 @@ def load(path: str | Path, verbose: int = 1): # reload the key value store if verbose: print("Loading index data") - index.kv_store.load(Path(path) / "store") + index.kv_store.load(str(Path(path) / "store")) # rebuild the index if verbose: print("Loading search index") - index.search.load(Path(path) / "search") + index.search.load(str(Path(path) / "search")) # reload calibration data if any index.is_calibrated = md["is_calibrated"] From d4dce5af5e2ccb9625e2ca7c00b06b045bbbdde8 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Wed, 8 Mar 2023 22:45:31 -0800 Subject: [PATCH 28/35] support more distances for LinearSearch --- tensorflow_similarity/search/linear_search.py | 45 ++++++++++++++----- tests/search/test_linear_search.py | 28 ++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index e0403b22..75e9323d 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -66,12 +66,38 @@ def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[i k: Number of nearest neighboors embedding to lookup. Defaults to 5. """ - normalized_query = tf.math.l2_normalize(embeddings, axis=1) items = len(self.ids) - sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) - similarity, id_idxs = tf.math.top_k(sims, k) - ids_array = np.array(self.ids) - return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(similarity) + if self.distance.name == "cosine": + normalized_query = tf.math.l2_normalize(embeddings, axis=1) + sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) + similarity, id_idxs = tf.math.top_k(sims, k) + ids_array = np.array(self.ids) + return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(similarity) + elif self.distance.name in ("euclidean", "squared_euclidean"): + normalized_query = tf.math.l2_normalize(embeddings, axis=1) + items = len(self.ids) + assert ( + normalized_query.shape.as_list()[-1] == self.db.shape[-1] + ), "the last dimension should have the same size" + query_norms = tf.reduce_sum(tf.square(normalized_query), axis=1) + query_norms = tf.reshape(query_norms, [-1, 1]) # Only one column per row + + db_norms = tf.reduce_sum(tf.square(self.db[:items]), axis=1) + db_norms = tf.reshape(db_norms, [-1, 1]) # Only one column per row + + dists = query_norms - 2 * tf.matmul(normalized_query, tf.transpose(self.db[:items])) + db_norms + dists, id_idxs = tf.math.top_k(-dists, k) + dists = -dists + ids_array = np.array(self.ids) + return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(dists) + elif self.distance.name == "manhattan": + dists = tf.reduce_sum(tf.abs(tf.subtract(self.db[:items], tf.expand_dims(embeddings, 1))), axis=2) + dists, id_idxs = tf.math.top_k(-dists, k) + dists = -dists + ids_array = np.array(self.ids) + return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(dists) + else: + raise ValueError("Unsupported metric space") def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[float]]: """Find embedding K nearest neighboors embeddings. @@ -80,12 +106,9 @@ def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[fl embedding: Query embedding as predicted by the model. k: Number of nearest neighboors embedding to lookup. Defaults to 5. """ - normalized_query = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) - items = len(self.ids) - sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) - similarity, id_idxs = tf.math.top_k(sims, k) - ids_array = np.array(self.ids) - return list(np.array(ids_array[id_idxs[0].numpy()])), list(similarity[0]) + embeddings: FloatTensor = tf.convert_to_tensor([embedding], dtype=np.float32) + idxs, dists = self.batch_lookup(embeddings, k=k) + return idxs[0], dists[0] def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): """Add a single embedding to the search index. diff --git a/tests/search/test_linear_search.py b/tests/search/test_linear_search.py index 1f85121c..0a86a0b1 100644 --- a/tests/search/test_linear_search.py +++ b/tests/search/test_linear_search.py @@ -17,6 +17,34 @@ def test_index_match(): assert list(idxs) == [0, 1] +def test_index_match_l1(): + target = np.array([1, 1, 2], dtype="float32") + embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") + + search_index = LinearSearch("l1", 3) + search_index.add(embs[0], 0) + search_index.add(embs[1], 1) + + idxs, embs = search_index.lookup(target, k=2) + + assert len(embs) == 2 + assert list(idxs) == [1, 0] + + +def test_index_match_l2(): + target = np.array([1, 1, 2], dtype="float32") + embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") + + search_index = LinearSearch("l2", 3) + search_index.add(embs[0], 0) + search_index.add(embs[1], 1) + + idxs, embs = search_index.lookup(target, k=2) + + assert len(embs) == 2 + assert list(idxs) == [0, 1] + + def test_index_save(tmp_path): target = np.array([1, 1, 2], dtype="float32") embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") From 6699eb7f29fb0279c53397a8c69818b2c59c7d2e Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Thu, 9 Mar 2023 11:05:37 -0800 Subject: [PATCH 29/35] add indexing colab --- examples/indexing_colab.ipynb | 2746 +++++++++++++++++++++++++++++++++ 1 file changed, 2746 insertions(+) create mode 100644 examples/indexing_colab.ipynb diff --git a/examples/indexing_colab.ipynb b/examples/indexing_colab.ipynb new file mode 100644 index 00000000..6f6d3e06 --- /dev/null +++ b/examples/indexing_colab.ipynb @@ -0,0 +1,2746 @@ +{ + "nbformat": 4, + "nbformat_minor": 0, + "metadata": { + "colab": { + "provenance": [], + "collapsed_sections": [ + "ePmNIj8hSVAn" + ] + }, + "kernelspec": { + "name": "python3", + "display_name": "Python 3" + }, + "language_info": { + "name": "python" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "9dffbdfbc552434ebcc3f480daee4bd9": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HBoxModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_15445b1000d94eea943c0f2db61f3de1", + "IPY_MODEL_b81c53fd06c24652affa33c7e5b95af3", + "IPY_MODEL_36894b6f420e41b196c94b5bbedc2552" + ], + "layout": "IPY_MODEL_a22fcd57348e4b9b9b537c461b7240d2" + } + }, + "15445b1000d94eea943c0f2db61f3de1": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_e501f796a3a649ef9f2fccb9017279b3", + "placeholder": "​", + "style": "IPY_MODEL_b900825d8731446a8dae9299ecf5c1a3", + "value": "filtering examples: 100%" + } + }, + "b81c53fd06c24652affa33c7e5b95af3": { + "model_module": "@jupyter-widgets/controls", + "model_name": "FloatProgressModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_003a9d6fd5a34026969972b568460f4b", + "max": 60000, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_d87efba0bf1f419d8f916a04d50b2057", + "value": 60000 + } + }, + "36894b6f420e41b196c94b5bbedc2552": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_3d4ba235da194b728ba6350e86d3b2d1", + "placeholder": "​", + "style": "IPY_MODEL_ee45e68a2a7f43c7b6eadddb5634eed5", + "value": " 60000/60000 [00:00<00:00, 823941.96it/s]" + } + }, + "a22fcd57348e4b9b9b537c461b7240d2": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "e501f796a3a649ef9f2fccb9017279b3": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "b900825d8731446a8dae9299ecf5c1a3": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "003a9d6fd5a34026969972b568460f4b": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "d87efba0bf1f419d8f916a04d50b2057": { + "model_module": "@jupyter-widgets/controls", + "model_name": "ProgressStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "3d4ba235da194b728ba6350e86d3b2d1": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "ee45e68a2a7f43c7b6eadddb5634eed5": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "7437216a87894cb1b15f3a1e190c8684": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HBoxModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_fc4b40f05f1b44f3b8fb924f7e56390d", + "IPY_MODEL_3752a74a573947e797533665e85750f0", + "IPY_MODEL_3a6c2ea5aea84cc29808825a0cde0f1b" + ], + "layout": "IPY_MODEL_6968ab9dba0d492f8a53db348595af10" + } + }, + "fc4b40f05f1b44f3b8fb924f7e56390d": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_b3de8ed0b9ba4787ab8a65ad34a8b396", + "placeholder": "​", + "style": "IPY_MODEL_d7560023f385471989ebb30475f76e02", + "value": "selecting classes: 100%" + } + }, + "3752a74a573947e797533665e85750f0": { + "model_module": "@jupyter-widgets/controls", + "model_name": "FloatProgressModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_afafac0b0078453fb3e72264bf54ad40", + "max": 6, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_02ec015a1aa24dffab063edfeb453998", + "value": 6 + } + }, + "3a6c2ea5aea84cc29808825a0cde0f1b": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_69ee26e15b064e90be44c0fcfc89e778", + "placeholder": "​", + "style": "IPY_MODEL_89c3ea106b5442cb96d77c658cfb35be", + "value": " 6/6 [00:00<00:00, 298.71it/s]" + } + }, + "6968ab9dba0d492f8a53db348595af10": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "b3de8ed0b9ba4787ab8a65ad34a8b396": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "d7560023f385471989ebb30475f76e02": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "afafac0b0078453fb3e72264bf54ad40": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "02ec015a1aa24dffab063edfeb453998": { + "model_module": "@jupyter-widgets/controls", + "model_name": "ProgressStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "69ee26e15b064e90be44c0fcfc89e778": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "89c3ea106b5442cb96d77c658cfb35be": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "5838c303535a4d119bb20c72c2a8d4b0": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HBoxModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_ae0a4b489c30469da7554a4703c2ba2c", + "IPY_MODEL_6372c74bb16e4bc18a4fb35dcfb58e69", + "IPY_MODEL_8b3fd08c655a44d9a7f37fd73f756370" + ], + "layout": "IPY_MODEL_a43064e7c0234afdbf6ed7cb7b67b426" + } + }, + "ae0a4b489c30469da7554a4703c2ba2c": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_38802fe54df5428ba519218fc8e43d33", + "placeholder": "​", + "style": "IPY_MODEL_81c40b1e7bc04ff5b45848d534a7eb66", + "value": "gather examples: 100%" + } + }, + "6372c74bb16e4bc18a4fb35dcfb58e69": { + "model_module": "@jupyter-widgets/controls", + "model_name": "FloatProgressModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_266059b4dae84e918ff61474da0b05c8", + "max": 36963, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_4a27079fb25744e89461b92cb6f89de3", + "value": 36963 + } + }, + "8b3fd08c655a44d9a7f37fd73f756370": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_44dee7908f0d49669921f173a90ec536", + "placeholder": "​", + "style": "IPY_MODEL_64ba102445024f1fb9205990c457aa50", + "value": " 36963/36963 [00:00<00:00, 549257.81it/s]" + } + }, + "a43064e7c0234afdbf6ed7cb7b67b426": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "38802fe54df5428ba519218fc8e43d33": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "81c40b1e7bc04ff5b45848d534a7eb66": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "266059b4dae84e918ff61474da0b05c8": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "4a27079fb25744e89461b92cb6f89de3": { + "model_module": "@jupyter-widgets/controls", + "model_name": "ProgressStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "44dee7908f0d49669921f173a90ec536": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "64ba102445024f1fb9205990c457aa50": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "2ba94ac719dc4d7ba5ab2e98661ef0ed": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HBoxModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_b50870fb01d842158e43283d006f9949", + "IPY_MODEL_c805692f6fee406ebec95a28b31573d6", + "IPY_MODEL_68ee51abad1344408cf94aa6cd510ff8" + ], + "layout": "IPY_MODEL_9ba3187dc1354099b37e847479769fee" + } + }, + "b50870fb01d842158e43283d006f9949": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_f0629dd648ad4d6e8bf64e4ff908c183", + "placeholder": "​", + "style": "IPY_MODEL_2a3207a4dbf449a3b528a2118ac492cc", + "value": "indexing classes: 100%" + } + }, + "c805692f6fee406ebec95a28b31573d6": { + "model_module": "@jupyter-widgets/controls", + "model_name": "FloatProgressModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_c859c8774c5c4a2087bba61a15795226", + "max": 36963, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_cf28890e93e1424bbb6db38b0659b1a7", + "value": 36963 + } + }, + "68ee51abad1344408cf94aa6cd510ff8": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_7458b09153b64d91afd947bc4e613e57", + "placeholder": "​", + "style": "IPY_MODEL_fecbab879514406db7cd3452a3d4ad07", + "value": " 36963/36963 [00:00<00:00, 683225.26it/s]" + } + }, + "9ba3187dc1354099b37e847479769fee": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "f0629dd648ad4d6e8bf64e4ff908c183": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "2a3207a4dbf449a3b528a2118ac492cc": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "c859c8774c5c4a2087bba61a15795226": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "cf28890e93e1424bbb6db38b0659b1a7": { + "model_module": "@jupyter-widgets/controls", + "model_name": "ProgressStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "7458b09153b64d91afd947bc4e613e57": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "fecbab879514406db7cd3452a3d4ad07": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + } + } + } + }, + "cells": [ + { + "cell_type": "markdown", + "source": [ + "**Introduction**\n", + "\n", + "This codelab walks you through how to use different Search and Store types for indexing embeddings for nearest neighbor lookups, both exact lookup and approximate lookups.\n", + "The Indexer uses two components to handle the indexing:\n", + "\n", + "\n", + "1. Search: The component that given an embedding looks up k-nearest-neighbors of it\n", + "2. Store: stores and retrievs the metadata associated with a given embedding\n", + "\n", + "\n", + "\n", + "The package currently supports the following NN algorithms (Search component):\n", + "\n", + "* LinearSearch\n", + "* nmslib\n", + "* Faiss\n", + "\n", + "It supports the following Stores:\n", + "\n", + "* MemoryStore: For small datasets that fit in the memory\n", + "* CachedStore: For medium size datasets that would fit in the memory and disk of the machine\n", + "* RedisStore: For larger datasets that would require a server to store and retrieve the metadata\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "metadata": { + "id": "ePmNIj8hSVAn" + } + }, + { + "cell_type": "code", + "source": [ + "#@title install git repo's indexing branch\n", + "!git clone https://github.com/tensorflow/similarity.git && cd similarity && git checkout indexing && pip install .[dev] && cd ..\n" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "aeptpGNhGoj0", + "outputId": "5dfdbfce-3074-48cc-8aca-2348aa0f3875" + }, + "execution_count": null, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Cloning into 'similarity'...\n", + "remote: Enumerating objects: 7082, done.\u001b[K\n", + "remote: Counting objects: 100% (1243/1243), done.\u001b[K\n", + "remote: Compressing objects: 100% (371/371), done.\u001b[K\n", + "remote: Total 7082 (delta 954), reused 1071 (delta 862), pack-reused 5839\u001b[K\n", + "Receiving objects: 100% (7082/7082), 166.74 MiB | 17.24 MiB/s, done.\n", + "Resolving deltas: 100% (4420/4420), done.\n", + "Branch 'indexing' set up to track remote branch 'indexing' from 'origin'.\n", + "Switched to a new branch 'indexing'\n", + "Looking in indexes: https://pypi.org/simple, https://us-python.pkg.dev/colab-wheels/public/simple/\n", + "Processing /content/similarity\n", + " Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n", + " Getting requirements to build wheel ... \u001b[?25l\u001b[?25hdone\n", + " Preparing metadata (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n", + "Collecting umap-learn\n", + " Downloading umap-learn-0.5.3.tar.gz (88 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m88.2/88.2 KB\u001b[0m \u001b[31m3.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + "Collecting nmslib\n", + " Downloading nmslib-2.1.1-cp38-cp38-manylinux2010_x86_64.whl (13.4 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m13.4/13.4 MB\u001b[0m \u001b[31m86.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: matplotlib in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (3.5.3)\n", + "Requirement already satisfied: numpy in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (1.22.4)\n", + "Requirement already satisfied: tqdm in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (4.64.1)\n", + "Requirement already satisfied: Pillow in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (8.4.0)\n", + "Requirement already satisfied: tensorflow-datasets>=4.2 in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (4.8.3)\n", + "Requirement already satisfied: bokeh in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (2.4.3)\n", + "Requirement already satisfied: tabulate in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (0.8.10)\n", + "Requirement already satisfied: pandas in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (1.3.5)\n", + "Collecting distinctipy\n", + " Downloading distinctipy-1.2.2-py3-none-any.whl (25 kB)\n", + "Collecting mypy<=0.982\n", + " Downloading mypy-0.982-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (17.4 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m17.4/17.4 MB\u001b[0m \u001b[31m92.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting faiss-gpu\n", + " Downloading faiss_gpu-1.7.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (85.5 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m85.5/85.5 MB\u001b[0m \u001b[31m11.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting types-tabulate\n", + " Downloading types_tabulate-0.9.0.1-py3-none-any.whl (3.1 kB)\n", + "Collecting black\n", + " Downloading black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.6 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.6/1.6 MB\u001b[0m \u001b[31m86.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting twine\n", + " Downloading twine-4.0.2-py3-none-any.whl (36 kB)\n", + "Collecting pytype\n", + " Downloading pytype-2023.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.8 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.8/3.8 MB\u001b[0m \u001b[31m97.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting mkdocs-autorefs\n", + " Downloading mkdocs_autorefs-0.4.1-py3-none-any.whl (9.8 kB)\n", + "Collecting mkdocs-material\n", + " Downloading mkdocs_material-9.1.1-py3-none-any.whl (7.7 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m7.7/7.7 MB\u001b[0m \u001b[31m114.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting pre-commit\n", + " Downloading pre_commit-3.1.1-py2.py3-none-any.whl (202 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m202.3/202.3 KB\u001b[0m \u001b[31m23.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting redis\n", + " Downloading redis-4.5.1-py3-none-any.whl (238 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m238.5/238.5 KB\u001b[0m \u001b[31m30.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: setuptools in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (57.4.0)\n", + "Collecting mkdocstrings\n", + " Downloading mkdocstrings-0.20.0-py3-none-any.whl (26 kB)\n", + "Collecting types-termcolor\n", + " Downloading types_termcolor-1.1.6.1-py3-none-any.whl (2.4 kB)\n", + "Collecting types-redis\n", + " Downloading types_redis-4.5.1.4-py3-none-any.whl (55 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m55.4/55.4 KB\u001b[0m \u001b[31m7.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: wheel in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (0.38.4)\n", + "Collecting isort\n", + " Downloading isort-5.12.0-py3-none-any.whl (91 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m91.2/91.2 KB\u001b[0m \u001b[31m12.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting mkdocs\n", + " Downloading mkdocs-1.4.2-py3-none-any.whl (3.7 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.7/3.7 MB\u001b[0m \u001b[31m118.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting flake8\n", + " Downloading flake8-6.0.0-py2.py3-none-any.whl (57 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m57.8/57.8 KB\u001b[0m \u001b[31m7.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: pytest in /usr/local/lib/python3.8/dist-packages (from tensorflow-similarity==0.17.0.dev18) (3.6.4)\n", + "Requirement already satisfied: tomli>=1.1.0 in /usr/local/lib/python3.8/dist-packages (from mypy<=0.982->tensorflow-similarity==0.17.0.dev18) (2.0.1)\n", + "Collecting mypy-extensions>=0.4.3\n", + " Downloading mypy_extensions-1.0.0-py3-none-any.whl (4.7 kB)\n", + "Requirement already satisfied: typing-extensions>=3.10 in /usr/local/lib/python3.8/dist-packages (from mypy<=0.982->tensorflow-similarity==0.17.0.dev18) (4.5.0)\n", + "Requirement already satisfied: tensorflow-metadata in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (1.12.0)\n", + "Requirement already satisfied: promise in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (2.3)\n", + "Requirement already satisfied: toml in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (0.10.2)\n", + "Requirement already satisfied: click in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (8.1.3)\n", + "Requirement already satisfied: wrapt in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (1.15.0)\n", + "Requirement already satisfied: absl-py in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (1.4.0)\n", + "Requirement already satisfied: protobuf>=3.12.2 in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (3.19.6)\n", + "Requirement already satisfied: dm-tree in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (0.1.8)\n", + "Requirement already satisfied: psutil in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (5.4.8)\n", + "Requirement already satisfied: importlib-resources in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (5.12.0)\n", + "Requirement already satisfied: requests>=2.19.0 in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (2.25.1)\n", + "Requirement already satisfied: etils[enp,epath]>=0.9.0 in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (1.0.0)\n", + "Requirement already satisfied: termcolor in /usr/local/lib/python3.8/dist-packages (from tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (2.2.0)\n", + "Requirement already satisfied: packaging>=22.0 in /usr/local/lib/python3.8/dist-packages (from black->tensorflow-similarity==0.17.0.dev18) (23.0)\n", + "Collecting pathspec>=0.9.0\n", + " Downloading pathspec-0.11.0-py3-none-any.whl (29 kB)\n", + "Requirement already satisfied: platformdirs>=2 in /usr/local/lib/python3.8/dist-packages (from black->tensorflow-similarity==0.17.0.dev18) (3.0.0)\n", + "Requirement already satisfied: Jinja2>=2.9 in /usr/local/lib/python3.8/dist-packages (from bokeh->tensorflow-similarity==0.17.0.dev18) (3.1.2)\n", + "Requirement already satisfied: tornado>=5.1 in /usr/local/lib/python3.8/dist-packages (from bokeh->tensorflow-similarity==0.17.0.dev18) (6.2)\n", + "Requirement already satisfied: PyYAML>=3.10 in /usr/local/lib/python3.8/dist-packages (from bokeh->tensorflow-similarity==0.17.0.dev18) (6.0)\n", + "Collecting pyflakes<3.1.0,>=3.0.0\n", + " Downloading pyflakes-3.0.1-py2.py3-none-any.whl (62 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m62.8/62.8 KB\u001b[0m \u001b[31m6.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting mccabe<0.8.0,>=0.7.0\n", + " Downloading mccabe-0.7.0-py2.py3-none-any.whl (7.3 kB)\n", + "Collecting pycodestyle<2.11.0,>=2.10.0\n", + " Downloading pycodestyle-2.10.0-py2.py3-none-any.whl (41 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m41.3/41.3 KB\u001b[0m \u001b[31m5.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.8/dist-packages (from matplotlib->tensorflow-similarity==0.17.0.dev18) (4.38.0)\n", + "Requirement already satisfied: kiwisolver>=1.0.1 in /usr/local/lib/python3.8/dist-packages (from matplotlib->tensorflow-similarity==0.17.0.dev18) (1.4.4)\n", + "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.8/dist-packages (from matplotlib->tensorflow-similarity==0.17.0.dev18) (2.8.2)\n", + "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.8/dist-packages (from matplotlib->tensorflow-similarity==0.17.0.dev18) (0.11.0)\n", + "Requirement already satisfied: pyparsing>=2.2.1 in /usr/local/lib/python3.8/dist-packages (from matplotlib->tensorflow-similarity==0.17.0.dev18) (3.0.9)\n", + "Collecting pyyaml-env-tag>=0.1\n", + " Downloading pyyaml_env_tag-0.1-py3-none-any.whl (3.9 kB)\n", + "Collecting watchdog>=2.0\n", + " Downloading watchdog-2.3.1-py3-none-manylinux2014_x86_64.whl (80 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m80.6/80.6 KB\u001b[0m \u001b[31m11.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting markdown<3.4,>=3.2.1\n", + " Downloading Markdown-3.3.7-py3-none-any.whl (97 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m97.8/97.8 KB\u001b[0m \u001b[31m14.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting mergedeep>=1.3.4\n", + " Downloading mergedeep-1.3.4-py3-none-any.whl (6.4 kB)\n", + "Collecting ghp-import>=1.0\n", + " Downloading ghp_import-2.1.0-py3-none-any.whl (11 kB)\n", + "Requirement already satisfied: importlib-metadata>=4.3 in /usr/local/lib/python3.8/dist-packages (from mkdocs->tensorflow-similarity==0.17.0.dev18) (6.0.0)\n", + "Collecting colorama>=0.4\n", + " Downloading colorama-0.4.6-py2.py3-none-any.whl (25 kB)\n", + "Collecting mkdocs-material-extensions>=1.1\n", + " Downloading mkdocs_material_extensions-1.1.1-py3-none-any.whl (7.9 kB)\n", + "Collecting pymdown-extensions>=9.9.1\n", + " Downloading pymdown_extensions-9.10-py3-none-any.whl (235 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m235.5/235.5 KB\u001b[0m \u001b[31m27.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting pygments>=2.14\n", + " Downloading Pygments-2.14.0-py3-none-any.whl (1.1 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.1/1.1 MB\u001b[0m \u001b[31m74.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: regex>=2022.4.24 in /usr/local/lib/python3.8/dist-packages (from mkdocs-material->tensorflow-similarity==0.17.0.dev18) (2022.6.2)\n", + "Collecting requests>=2.19.0\n", + " Downloading requests-2.28.2-py3-none-any.whl (62 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m62.8/62.8 KB\u001b[0m \u001b[31m7.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: MarkupSafe>=1.1 in /usr/local/lib/python3.8/dist-packages (from mkdocstrings->tensorflow-similarity==0.17.0.dev18) (2.1.2)\n", + "Collecting pybind11<2.6.2\n", + " Downloading pybind11-2.6.1-py2.py3-none-any.whl (188 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m188.5/188.5 KB\u001b[0m \u001b[31m23.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: pytz>=2017.3 in /usr/local/lib/python3.8/dist-packages (from pandas->tensorflow-similarity==0.17.0.dev18) (2022.7.1)\n", + "Collecting identify>=1.0.0\n", + " Downloading identify-2.5.18-py2.py3-none-any.whl (98 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m98.8/98.8 KB\u001b[0m \u001b[31m12.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting nodeenv>=0.11.1\n", + " Downloading nodeenv-1.7.0-py2.py3-none-any.whl (21 kB)\n", + "Collecting virtualenv>=20.10.0\n", + " Downloading virtualenv-20.20.0-py3-none-any.whl (8.7 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m8.7/8.7 MB\u001b[0m \u001b[31m128.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting cfgv>=2.0.0\n", + " Downloading cfgv-3.3.1-py2.py3-none-any.whl (7.3 kB)\n", + "Requirement already satisfied: py>=1.5.0 in /usr/local/lib/python3.8/dist-packages (from pytest->tensorflow-similarity==0.17.0.dev18) (1.11.0)\n", + "Requirement already satisfied: attrs>=17.4.0 in /usr/local/lib/python3.8/dist-packages (from pytest->tensorflow-similarity==0.17.0.dev18) (22.2.0)\n", + "Requirement already satisfied: more-itertools>=4.0.0 in /usr/local/lib/python3.8/dist-packages (from pytest->tensorflow-similarity==0.17.0.dev18) (9.1.0)\n", + "Requirement already satisfied: pluggy<0.8,>=0.5 in /usr/local/lib/python3.8/dist-packages (from pytest->tensorflow-similarity==0.17.0.dev18) (0.7.1)\n", + "Requirement already satisfied: atomicwrites>=1.0 in /usr/local/lib/python3.8/dist-packages (from pytest->tensorflow-similarity==0.17.0.dev18) (1.4.1)\n", + "Requirement already satisfied: six>=1.10.0 in /usr/local/lib/python3.8/dist-packages (from pytest->tensorflow-similarity==0.17.0.dev18) (1.15.0)\n", + "Collecting pydot>=1.4.2\n", + " Downloading pydot-1.4.2-py2.py3-none-any.whl (21 kB)\n", + "Collecting ninja>=1.10.0.post2\n", + " Downloading ninja-1.11.1-py2.py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl (145 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m146.0/146.0 KB\u001b[0m \u001b[31m18.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting libcst>=0.4.9\n", + " Downloading libcst-0.4.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.8 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.8/2.8 MB\u001b[0m \u001b[31m76.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting importlab>=0.8\n", + " Downloading importlab-0.8-py2.py3-none-any.whl (21 kB)\n", + "Collecting networkx<2.8.4\n", + " Downloading networkx-2.8.3-py3-none-any.whl (2.0 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.0/2.0 MB\u001b[0m \u001b[31m71.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: async-timeout>=4.0.2 in /usr/local/lib/python3.8/dist-packages (from redis->tensorflow-similarity==0.17.0.dev18) (4.0.2)\n", + "Collecting rfc3986>=1.4.0\n", + " Downloading rfc3986-2.0.0-py2.py3-none-any.whl (31 kB)\n", + "Collecting readme-renderer>=35.0\n", + " Downloading readme_renderer-37.3-py3-none-any.whl (14 kB)\n", + "Collecting requests-toolbelt!=0.9.0,>=0.8.0\n", + " Downloading requests_toolbelt-0.10.1-py2.py3-none-any.whl (54 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m54.5/54.5 KB\u001b[0m \u001b[31m6.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting keyring>=15.1\n", + " Downloading keyring-23.13.1-py3-none-any.whl (37 kB)\n", + "Requirement already satisfied: urllib3>=1.26.0 in /usr/local/lib/python3.8/dist-packages (from twine->tensorflow-similarity==0.17.0.dev18) (1.26.14)\n", + "Collecting pkginfo>=1.8.1\n", + " Downloading pkginfo-1.9.6-py3-none-any.whl (30 kB)\n", + "Collecting rich>=12.0.0\n", + " Downloading rich-13.3.2-py3-none-any.whl (238 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m238.7/238.7 KB\u001b[0m \u001b[31m28.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting types-pyOpenSSL\n", + " Downloading types_pyOpenSSL-23.0.0.4-py3-none-any.whl (6.9 kB)\n", + "Collecting cryptography>=35.0.0\n", + " Downloading cryptography-39.0.2-cp36-abi3-manylinux_2_28_x86_64.whl (4.2 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m4.2/4.2 MB\u001b[0m \u001b[31m118.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: scikit-learn>=0.22 in /usr/local/lib/python3.8/dist-packages (from umap-learn->tensorflow-similarity==0.17.0.dev18) (1.2.1)\n", + "Requirement already satisfied: scipy>=1.0 in /usr/local/lib/python3.8/dist-packages (from umap-learn->tensorflow-similarity==0.17.0.dev18) (1.10.1)\n", + "Requirement already satisfied: numba>=0.49 in /usr/local/lib/python3.8/dist-packages (from umap-learn->tensorflow-similarity==0.17.0.dev18) (0.56.4)\n", + "Collecting pynndescent>=0.5\n", + " Downloading pynndescent-0.5.8.tar.gz (1.1 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.1/1.1 MB\u001b[0m \u001b[31m77.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + "Requirement already satisfied: cffi>=1.12 in /usr/local/lib/python3.8/dist-packages (from cryptography>=35.0.0->types-redis->tensorflow-similarity==0.17.0.dev18) (1.15.1)\n", + "Requirement already satisfied: zipp in /usr/local/lib/python3.8/dist-packages (from etils[enp,epath]>=0.9.0->tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (3.15.0)\n", + "Collecting jeepney>=0.4.2\n", + " Downloading jeepney-0.8.0-py3-none-any.whl (48 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m48.4/48.4 KB\u001b[0m \u001b[31m5.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting SecretStorage>=3.2\n", + " Downloading SecretStorage-3.3.3-py3-none-any.whl (15 kB)\n", + "Collecting jaraco.classes\n", + " Downloading jaraco.classes-3.2.3-py3-none-any.whl (6.0 kB)\n", + "Collecting typing-inspect>=0.4.0\n", + " Downloading typing_inspect-0.8.0-py3-none-any.whl (8.7 kB)\n", + "Requirement already satisfied: llvmlite<0.40,>=0.39.0dev0 in /usr/local/lib/python3.8/dist-packages (from numba>=0.49->umap-learn->tensorflow-similarity==0.17.0.dev18) (0.39.1)\n", + "Requirement already satisfied: joblib>=0.11 in /usr/local/lib/python3.8/dist-packages (from pynndescent>=0.5->umap-learn->tensorflow-similarity==0.17.0.dev18) (1.2.0)\n", + "Requirement already satisfied: docutils>=0.13.1 in /usr/local/lib/python3.8/dist-packages (from readme-renderer>=35.0->twine->tensorflow-similarity==0.17.0.dev18) (0.16)\n", + "Requirement already satisfied: bleach>=2.1.0 in /usr/local/lib/python3.8/dist-packages (from readme-renderer>=35.0->twine->tensorflow-similarity==0.17.0.dev18) (6.0.0)\n", + "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.8/dist-packages (from requests>=2.19.0->tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (3.0.1)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.8/dist-packages (from requests>=2.19.0->tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (2.10)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.8/dist-packages (from requests>=2.19.0->tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (2022.12.7)\n", + "Collecting markdown-it-py<3.0.0,>=2.2.0\n", + " Downloading markdown_it_py-2.2.0-py3-none-any.whl (84 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m84.5/84.5 KB\u001b[0m \u001b[31m11.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: threadpoolctl>=2.0.0 in /usr/local/lib/python3.8/dist-packages (from scikit-learn>=0.22->umap-learn->tensorflow-similarity==0.17.0.dev18) (3.1.0)\n", + "Collecting distlib<1,>=0.3.6\n", + " Downloading distlib-0.3.6-py2.py3-none-any.whl (468 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m468.5/468.5 KB\u001b[0m \u001b[31m44.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: filelock<4,>=3.4.1 in /usr/local/lib/python3.8/dist-packages (from virtualenv>=20.10.0->pre-commit->tensorflow-similarity==0.17.0.dev18) (3.9.0)\n", + "Requirement already satisfied: googleapis-common-protos<2,>=1.52.0 in /usr/local/lib/python3.8/dist-packages (from tensorflow-metadata->tensorflow-datasets>=4.2->tensorflow-similarity==0.17.0.dev18) (1.58.0)\n", + "Requirement already satisfied: webencodings in /usr/local/lib/python3.8/dist-packages (from bleach>=2.1.0->readme-renderer>=35.0->twine->tensorflow-similarity==0.17.0.dev18) (0.5.1)\n", + "Requirement already satisfied: pycparser in /usr/local/lib/python3.8/dist-packages (from cffi>=1.12->cryptography>=35.0.0->types-redis->tensorflow-similarity==0.17.0.dev18) (2.21)\n", + "Collecting mdurl~=0.1\n", + " Downloading mdurl-0.1.2-py3-none-any.whl (10.0 kB)\n", + "Building wheels for collected packages: tensorflow-similarity, umap-learn, pynndescent\n", + " Building wheel for tensorflow-similarity (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n", + " Created wheel for tensorflow-similarity: filename=tensorflow_similarity-0.17.0.dev18-py3-none-any.whl size=241562 sha256=446cc6a98f5235d8a0a757a6fdf62ae120f422aafac3483ac5d0e3a572c71efa\n", + " Stored in directory: /tmp/pip-ephem-wheel-cache-wujt_gjg/wheels/73/62/33/8ca1c2e61b184580b4b0caac916dda8778f0ca566e43e04ddf\n", + " Building wheel for umap-learn (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + " Created wheel for umap-learn: filename=umap_learn-0.5.3-py3-none-any.whl size=82829 sha256=4641ebf51eaec50dbb6752575e99cef1e5a8a68ce450422fdad4a40f66c1e75e\n", + " Stored in directory: /root/.cache/pip/wheels/a9/3a/67/06a8950e053725912e6a8c42c4a3a241410f6487b8402542ea\n", + " Building wheel for pynndescent (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + " Created wheel for pynndescent: filename=pynndescent-0.5.8-py3-none-any.whl size=55513 sha256=86a88c58d2e95ceae3ccba06dba8b2157f188314e41cb8e2655ac7c5f0575971\n", + " Stored in directory: /root/.cache/pip/wheels/1c/63/3a/29954bca1a27ba100ed8c27973a78cb71b43dc67aed62e80c3\n", + "Successfully built tensorflow-similarity umap-learn pynndescent\n", + "Installing collected packages: types-termcolor, types-tabulate, ninja, faiss-gpu, distlib, watchdog, virtualenv, rfc3986, requests, redis, pyyaml-env-tag, pygments, pyflakes, pydot, pycodestyle, pybind11, pkginfo, pathspec, nodeenv, networkx, mypy-extensions, mkdocs-material-extensions, mergedeep, mdurl, mccabe, jeepney, jaraco.classes, isort, identify, distinctipy, colorama, cfgv, typing-inspect, requests-toolbelt, readme-renderer, pre-commit, nmslib, mypy, markdown-it-py, markdown, importlab, ghp-import, flake8, cryptography, black, types-pyOpenSSL, SecretStorage, rich, pynndescent, pymdown-extensions, mkdocs, libcst, umap-learn, types-redis, pytype, mkdocs-material, mkdocs-autorefs, keyring, twine, tensorflow-similarity, mkdocstrings\n", + " Attempting uninstall: requests\n", + " Found existing installation: requests 2.25.1\n", + " Uninstalling requests-2.25.1:\n", + " Successfully uninstalled requests-2.25.1\n", + " Attempting uninstall: pygments\n", + " Found existing installation: Pygments 2.6.1\n", + " Uninstalling Pygments-2.6.1:\n", + " Successfully uninstalled Pygments-2.6.1\n", + " Attempting uninstall: pydot\n", + " Found existing installation: pydot 1.3.0\n", + " Uninstalling pydot-1.3.0:\n", + " Successfully uninstalled pydot-1.3.0\n", + " Attempting uninstall: networkx\n", + " Found existing installation: networkx 3.0\n", + " Uninstalling networkx-3.0:\n", + " Successfully uninstalled networkx-3.0\n", + " Attempting uninstall: markdown\n", + " Found existing installation: Markdown 3.4.1\n", + " Uninstalling Markdown-3.4.1:\n", + " Successfully uninstalled Markdown-3.4.1\n", + "\u001b[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.\n", + "ipython 7.9.0 requires jedi>=0.10, which is not installed.\u001b[0m\u001b[31m\n", + "\u001b[0mSuccessfully installed SecretStorage-3.3.3 black-23.1.0 cfgv-3.3.1 colorama-0.4.6 cryptography-39.0.2 distinctipy-1.2.2 distlib-0.3.6 faiss-gpu-1.7.2 flake8-6.0.0 ghp-import-2.1.0 identify-2.5.18 importlab-0.8 isort-5.12.0 jaraco.classes-3.2.3 jeepney-0.8.0 keyring-23.13.1 libcst-0.4.9 markdown-3.3.7 markdown-it-py-2.2.0 mccabe-0.7.0 mdurl-0.1.2 mergedeep-1.3.4 mkdocs-1.4.2 mkdocs-autorefs-0.4.1 mkdocs-material-9.1.1 mkdocs-material-extensions-1.1.1 mkdocstrings-0.20.0 mypy-0.982 mypy-extensions-1.0.0 networkx-2.8.3 ninja-1.11.1 nmslib-2.1.1 nodeenv-1.7.0 pathspec-0.11.0 pkginfo-1.9.6 pre-commit-3.1.1 pybind11-2.6.1 pycodestyle-2.10.0 pydot-1.4.2 pyflakes-3.0.1 pygments-2.14.0 pymdown-extensions-9.10 pynndescent-0.5.8 pytype-2023.3.2 pyyaml-env-tag-0.1 readme-renderer-37.3 redis-4.5.1 requests-2.28.2 requests-toolbelt-0.10.1 rfc3986-2.0.0 rich-13.3.2 tensorflow-similarity-0.17.0.dev18 twine-4.0.2 types-pyOpenSSL-23.0.0.4 types-redis-4.5.1.4 types-tabulate-0.9.0.1 types-termcolor-1.1.6.1 typing-inspect-0.8.0 umap-learn-0.5.3 virtualenv-20.20.0 watchdog-2.3.1\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title check if the package is installed successfully\n", + "!pip list | grep tensorflow" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "RKo2xxOa_xQ7", + "outputId": "3998c4fa-5c2e-43cd-d847-89936f550625" + }, + "execution_count": null, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "tensorflow 2.11.0\n", + "tensorflow-datasets 4.8.3\n", + "tensorflow-estimator 2.11.0\n", + "tensorflow-gcs-config 2.11.0\n", + "tensorflow-hub 0.12.0\n", + "tensorflow-io-gcs-filesystem 0.31.0\n", + "tensorflow-metadata 1.12.0\n", + "tensorflow-probability 0.19.0\n", + "tensorflow-similarity 0.17.0.dev18\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "import gc\n", + "import os\n", + "\n", + "import numpy as np\n", + "from matplotlib import pyplot as plt\n", + "from tabulate import tabulate\n", + "import tensorflow as tf\n", + "import tensorflow_similarity as tfsim # main package\n", + "\n", + "# INFO messages are not printed.\n", + "# This must be run before loading other modules.\n", + "os.environ[\"TF_CPP_MIN_LOG_LEVEL\"] = \"1\"" + ], + "metadata": { + "id": "83Q84nCUF0es" + }, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "source": [ + "#@title allow gpu memory to grow\n", + "tfsim.utils.tf_cap_memory()\n" + ], + "metadata": { + "id": "ylwoAusEmNSs" + }, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "source": [ + "#@title Clear out any old model state.\n", + "gc.collect()\n", + "tf.keras.backend.clear_session()\n", + "print(\"TensorFlow:\", tf.__version__)\n", + "print(\"TensorFlow Similarity\", tfsim.__version__)" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "9rAWsA4qmQKp", + "outputId": "29b4da3b-e796-4235-d84d-1a9177d925d4" + }, + "execution_count": null, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "TensorFlow: 2.11.0\n", + "TensorFlow Similarity 0.17.0.dev18\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title load data\n", + "(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "gwpkWfVimcz8", + "outputId": "0ca61c36-b872-4390-e313-f1828ffc8250" + }, + "execution_count": null, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Downloading data from https://storage.googleapis.com/tensorflow/tf-keras-datasets/mnist.npz\n", + "11490434/11490434 [==============================] - 0s 0us/step\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title the sampler\n", + "CLASSES = [2, 3, 1, 7, 9, 6, 8, 5, 0, 4]\n", + "NUM_CLASSES = 6 # @param {type: \"slider\", min: 1, max: 10}\n", + "CLASSES_PER_BATCH = NUM_CLASSES\n", + "EXAMPLES_PER_CLASS = 10 # @param {type:\"integer\"}\n", + "STEPS_PER_EPOCH = 1000 # @param {type:\"integer\"}\n", + "\n", + "sampler = tfsim.samplers.MultiShotMemorySampler(\n", + " x_train,\n", + " y_train,\n", + " classes_per_batch=CLASSES_PER_BATCH,\n", + " examples_per_class_per_batch=EXAMPLES_PER_CLASS,\n", + " class_list=CLASSES[:NUM_CLASSES], # Only use the first 6 classes for training.\n", + " steps_per_epoch=STEPS_PER_EPOCH,\n", + ")" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 180, + "referenced_widgets": [ + "9dffbdfbc552434ebcc3f480daee4bd9", + "15445b1000d94eea943c0f2db61f3de1", + "b81c53fd06c24652affa33c7e5b95af3", + "36894b6f420e41b196c94b5bbedc2552", + "a22fcd57348e4b9b9b537c461b7240d2", + "e501f796a3a649ef9f2fccb9017279b3", + "b900825d8731446a8dae9299ecf5c1a3", + "003a9d6fd5a34026969972b568460f4b", + "d87efba0bf1f419d8f916a04d50b2057", + "3d4ba235da194b728ba6350e86d3b2d1", + "ee45e68a2a7f43c7b6eadddb5634eed5", + "7437216a87894cb1b15f3a1e190c8684", + "fc4b40f05f1b44f3b8fb924f7e56390d", + "3752a74a573947e797533665e85750f0", + "3a6c2ea5aea84cc29808825a0cde0f1b", + "6968ab9dba0d492f8a53db348595af10", + "b3de8ed0b9ba4787ab8a65ad34a8b396", + "d7560023f385471989ebb30475f76e02", + "afafac0b0078453fb3e72264bf54ad40", + "02ec015a1aa24dffab063edfeb453998", + "69ee26e15b064e90be44c0fcfc89e778", + "89c3ea106b5442cb96d77c658cfb35be", + "5838c303535a4d119bb20c72c2a8d4b0", + "ae0a4b489c30469da7554a4703c2ba2c", + "6372c74bb16e4bc18a4fb35dcfb58e69", + "8b3fd08c655a44d9a7f37fd73f756370", + "a43064e7c0234afdbf6ed7cb7b67b426", + "38802fe54df5428ba519218fc8e43d33", + "81c40b1e7bc04ff5b45848d534a7eb66", + "266059b4dae84e918ff61474da0b05c8", + "4a27079fb25744e89461b92cb6f89de3", + "44dee7908f0d49669921f173a90ec536", + "64ba102445024f1fb9205990c457aa50", + "2ba94ac719dc4d7ba5ab2e98661ef0ed", + "b50870fb01d842158e43283d006f9949", + "c805692f6fee406ebec95a28b31573d6", + "68ee51abad1344408cf94aa6cd510ff8", + "9ba3187dc1354099b37e847479769fee", + "f0629dd648ad4d6e8bf64e4ff908c183", + "2a3207a4dbf449a3b528a2118ac492cc", + "c859c8774c5c4a2087bba61a15795226", + "cf28890e93e1424bbb6db38b0659b1a7", + "7458b09153b64d91afd947bc4e613e57", + "fecbab879514406db7cd3452a3d4ad07" + ] + }, + "id": "AMtypckSmigX", + "outputId": "14e1f114-c68e-474f-f8fa-b74cfe560070" + }, + "execution_count": null, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "\n", + "The initial batch size is 60 (6 classes * 10 examples per class) with 0 augmentations\n" + ] + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "filtering examples: 0%| | 0/60000 [00:00" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title make index\n", + "x_index, y_index = tfsim.samplers.select_examples(x_train, y_train, CLASSES, 20)\n", + "model.reset_index()\n", + "model.index(x_index, y_index, data=x_index)" + ], + "metadata": { + "id": "LypwRy-LnBgD" + }, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "source": [ + "#@title NN lookup results\n", + "# re-run to test on other examples\n", + "num_neighbors = 5\n", + "\n", + "# select\n", + "x_display, y_display = tfsim.samplers.select_examples(x_test, y_test, CLASSES, 1)\n", + "\n", + "# lookup nearest neighbors in the index\n", + "nns = model.lookup(x_display, k=num_neighbors)\n", + "\n", + "# display\n", + "for idx in np.argsort(y_display):\n", + " tfsim.visualization.viz_neigbors_imgs(x_display[idx], y_display[idx], nns[idx], fig_size=(16, 2), cmap=\"Greys\")" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000, + "referenced_widgets": [ + "1f70394ab6a64358be4b03a75aaf58d1", + "89cbe354b3024e3d838df344521415aa", + "fa1b6f54f0544ed5becef2b513f4b9ec", + "514cdca68e1b4eb4b717b7e7d24c209f" + ] + }, + "id": "AQyO36ZdnD6J", + "outputId": "ca32378a-2146-4c05-b2d0-a851d865fe92" + }, + "execution_count": null, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "1f70394ab6a64358be4b03a75aaf58d1", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "filtering examples: 0%| | 0/10000 [00:00" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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ww97cfK+ZUnJfRfw8ssve3Fyfns2rrvuOi8eO3ZsztpG6UreV68y5513nhdzbz1kKjnP88Ybb/TiAQMGeHGXLl3ynVKNxpk9AAAAAAgQxR4AAAAABIhiDwAAAAACVGPm7O3YscOLr776ai/Odo5e3759vfjXv/71nufM0UNFlixZktH6CxYs8OJevXrlMBvUBi+++GLa5aeccooXn3TSSflMByUoObd4+/btWbVXr97erwnXXHONt6xBgwZZtY0wVDa3OGnevHl5ygS1xf333+/F++23nxffc889hUynxuPMHgAAAAAEiGIPAAAAAAJEsQcAAAAAASranL2dO3d68YgRI7x49uzZWbV/wgknePGf//xnL07eEw1YvXq1F2/atCnt+r179/bi733veznPCWH78ssvvfiuu+5Ku3737t29OHU+FZAPU6dO3fN8//33L2ImqKnmz5+fdnlqHwKq44EHHvDiiRMnenH//v29uGnTpvlOqaRwZg8AAAAAAkSxBwAAAAABotgDAAAAgAAVbcJH8h5m2c7RGzp0qBdPnz7di+vUoa5Feu3bt/fi5JjvzZs3e3Hnzp29OHmfF6AyyXsBbdy4Me36t956ax6zAaSzzjrLiwcNGlSkTBCKyy67LO3ykSNHFigTlKpFixZ5sXPOi2+44YZCplNyqIAAAAAAIEAUewAAAAAQIIo9AAAAAAhQ0ebsde3a1YuvuuoqL07OTTn44IPTLk/e44w5esjW+eef78W33357kTJBqMrKytIuP+qoo7x49+7d+UwH0KRJk7yYe9IiW8n77DFHD9kaPXq0F3fp0qVImZQGKiIAAAAACBDFHgAAAAAEqGjDOA855BAvTg4dScZAoZ1++ule/NJLL3nxhRdeWMh0EKBhw4Z58YoVK7x42bJlXrxq1Sov7tSpU17yQunq06ePF1c2VBjIVvIy+ECuzZgxo9gplDTO7AEAAABAgCj2AAAAACBAFHsAAAAAEKCizdkDarq+ffumjYFsNW/e3Ivvu+++ImUCAABCxJk9AAAAAAgQxR4AAAAABIhiDwAAAAACRLEHAAAAAAGi2AMAAACAAFHsAQAAAECAKPYAAAAAIEAUewAAAAAQIIo9AAAAAAgQxR4AAAAABIhiDwAAAAACZM65qq9stk7S6vylgxxr75xrWewkMkEfKzn0MRRCSfUz+lhJoo8h3+hjyLcK+1hGxR4AAAAAoDQwjBMAAAAAAkSxBwAAAAABotgDAAAAgABR7AEAAABAgCj2AAAAACBAFHsAAAAAECCKPQAAAAAIEMUeAAAAAASIYg8AAAAAAvT/qtKDU83BVZMAAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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ZP86/VyOfi7ISsAvewAAAACQQgz2AAAAACCFGOwBAAAAQApVTM3exRdfbHKy3qkq7dq1M/lHP/qRyaeddprJfl4yf553LlOmTKnxtpKdlwj114MPPmiyr8nz53HPmDHDZD8vDOD5+UUPPfTQnNsn526UpKOOOiq73LZtW7PuggsuMPnMM880udAavY8++shk39+Rfr6P+bxkyRKTff9G/TRy5EiTd999d5MPP/xwk4tZk1coX0fq5/xbuXKlyb7+at999y1NwxqAm2++2eQbbrghu7zNNtvUdXM222effWbyBx98YLKvWfU1fpWIX/YAAAAAIIUY7AEAAABACjHYAwAAAIAUqpiaPT8Piz8H3M+F17ixbXop59yYOnVqQdtvt912JWoJ6lKzZs1M9uejX3TRRSa3adOm5G1CuvgavXw1e4W47bbbirYvSXr88cdN3rhxo8nHHHOMyS1atCjq86Py+Jo8P2+krxtF/ZSvHriS3HvvvTnX+2sqdO3atYStaVjSMkehn5PW939fv/7mm2+azDx7AAAAAIA6wWAPAAAAAFKIwR4AAAAApFDF1Oz5c30POeSQ8jREX58r6KGHHjLZz9vi5/zr1KlTaRqGitKhQ4cab7to0SKTZ82alXN7P9ePn9cIqGs33nhjzvXnnXeeyY0aNSplc1ABevfubfKHH35YppagoVq/fr3JX3zxRc7tmzZtarKvz0LD4+fNe/TRR03239/8Z9vRRx9dmoYVEb/sAQAAAEAKMdgDAAAAgBRisAcAAAAAKVQxNXuV5Msvvyxo+x122MHkHXfcsZjNQQq89tprJuerK+jVq5fJfs4/oNh83cLIkSNNfu+990zu2LGjyb5+C+nXv39/k4cOHWryiBEj6rI5aIBWrlxp8vPPP59ze18PD3z11VcmX3755SZv2LDB5LPOOsvkbt26laZhRcQvewAAAACQQgz2AAAAACCFOI1TX7+sqv8Jd+PGjSZvsYUdI/tL+QKStGbNmuzy1VdfnXPbVq1amXzxxReXokkosxUrVpj8zDPPmDx16lST3377bZO32mork0877bTs8pFHHmnWNWnSJGf2x7U777zT5EsvvVS5jBo1ymSmWmh4ZsyYYfKPf/zjMrUEleTdd981ee3atTm3b9OmTXa5bdu2ObdNfq5K0vXXX2+yPx3d5/vuuy/n/tHw+P7q+5h3yimnlLA1pcEvewAAAACQQgz2AAAAACCFGOwBAAAAQApRsyfpueeeM3nSpEkm+xo9X5ty6623lqJZqOfeeOON7PKrr76ac9s+ffqY3KVLl1I0CSW2dOlSk6+55hqT77//fpN93VyhfM1Ukq8ruP32200eO3asycOHD8/5XC1btjS5X79+NWgh0mzatGkmn3766WVqCUrJH6cWL15s8rXXXmvyhAkTTPaXtveSNXu+D/Xs2dPk5cuXm+xrjUMIJvtrMOSrCUT6+enVHn74YZM/+eQTkwcNGmRyfZxejV/2AAAAACCFGOwBAAAAQAox2AMAAACAFGqwNXvJc8ivvPLKgh677bbbmtytWzeT/Vxa22yzjclbbrllQc+H+mncuHHlbgJKzM/Pc8ABB5j8/vvv53z8gQceaPLQoUMLev5kzdQf/vAHs+53v/udyf/5z39Mnj17dkHP5WubO3ToUNDjUT+tX78+u/zZZ5+ZdUuWLDHZr3/66adN9rUuu+yyi8nNmjXb7HaidPx1CXx9r5/LztfNderUyWR/nHzssceyy7fcckvOfRXquuuuq9XjkT6+/44ePdpk3+f8/KE77LBDaRpWQvyyBwAAAAApxGAPAAAAAFKIwR4AAAAApFCDqdn78MMPTT7//POzy37elnyWLVtm8nbbbZdz+2HDhpl80UUX5dw+WePXqlWrgtqG0vFzs8ybN8/khx56yORHH320xvv++9//bvL8+fNN9rUtrVu3rvG+UTp+7jpfo7fvvvuaPGrUKJO/853vmNy4cWGH5BNOOCG77I8VY8aMMfmvf/1rQfv2cz362mSkw8KFC01O1k9J0vjx47PL//znP806X6vl5zzr3r27yWvWrDHZzz/q/3848sgjTe7fv7/JnTt3zi43bdrUrPPHa+oBq+fn0fM1epdddlnOxw8cONBkP+/ebrvtZnKTJk1MPvPMM7PL3/3ud3M3No9CPndRf61du9Zk/x1/ypQpJr/yyivVPtbz368OPfTQwhtYYfhlDwAAAABSiMEeAAAAAKQQgz0AAAAASKF6W7Pnz7n18/288847Jvt5Mj799NPSNKwKvo7BZ2/nnXfOLl9wwQVm3dlnn23yFlswXi+VK664wuS5c+eaPGPGjKI91wcffGCyr/Vq27atySeeeKLJJ510kslvvfWWyS+++KLJyfoWX19BbUvN+Xn2ttpqK5MnTZpksp9nrLb+9re/ZZfvueeeou578eLFJp9zzjkmN2/e3OSddtrJ5COOOCK77OcmrY/zFNVXvq5u1qxZJh999NEmX3PNNSaPHTs2u/yzn/3MrNtrr71Mvvvuu032c8r62rDkfLdS/mOsr5FNHjf9HH89evQwecCAASb7ud4aMl/f6Och80aMGGHyJZdcUqvnL/S6Cbm0adOmaPtC5fI1eccdd1zO7ZPHQT+P3je/+U2TJ0+ebHL79u03p4kVhZECAAAAAKQQgz0AAAAASCEGewAAAACQQvWmZs+fj+/ndSl0DqlKlqw/TM4HKH29vqJjx4510qaG4I033jDZzzXk6xoK4evgfB2Nr13xPv74Y5PvuOOOnLkQ3/72t00++eSTN3tfDY2f08nPk+fn/slXs+f7wezZs032c3Qm5z1bt26dWdeuXTuT/VxBvXr1MvnBBx/M2bYXXnjBZP98yflBJWnChAnZZV8flawDQ2l98cUXJvu563w9r58bb8OGDdnl1atXm3U33nijyb5Gz/M15r7u86CDDsqZURq+Htd/PnXq1MnkoUOHFrR/X5Pua85nzpxZ7XP7+T59/frLL79s8mGHHWby559/bnKLFi3yNxgVx38HOuOMM4q270MOOcRkPy9kofxn45NPPmmyP8b6msFS4Jc9AAAAAEghBnsAAAAAkEIM9gAAAAAghepNzd64ceNMLmWN3j777GOynyurtpYuXWpyrlqZBx54wOSJEyeafO655xavYQ3M66+/bvKll15qcm1q9CTpwAMPzC6PHj3arPPndC9btszkMWPGmDx//nyT/TyR+Wr+UBqDBw82+fe//73Jl112mcn+/9dVq1aZfMMNN5i8YMGCGrelUaNGJvtj5lFHHZXz8X5OT8/3WT9nWnLuRlQOPwdtnz59TP7Wt76V8/F/+ctfssvr168369Iw/xS+fpzx85DtvffeJvvvIc8880zO/b399tsm+2swJOcn/fnPf27W+WOor8Hz9Yb++9r3vvc9k6dNm2ayrzVGZXrkkUdM9n0on+Tnla8d9vOD+n3nq6nz39P9PN/eoEGDTPb/P5UCv+wBAAAAQAox2AMAAACAFGKwBwAAAAApVG9q9o455hiTL7744qLtu2fPniY//fTTJm+99dZFey7p63Nt7b///tVue/3115tMXUzxzJkzx2Q/F0qh+vfvb/KIESOyy77mIZ8BAwbkXP/KK6+Y/NZbbxW0/6Rc/Q+5+VrMp556yuRkvVNVubZuuumm7PKpp55q1nXo0KGoz9WkSZOi7g91Y5dddjHZz0vma8b9XJG/+MUvsstTpkwx6/LNq4d0mDp1as7s58bzNX/efvvtZ/K9996bXd5jjz1yPjZZ3ydJ999/v8l+blL/Oe8/p/0xu9jf97B51qxZY/Kzzz5rcr4+5iXn0vv3v/9t1vlrJvj5c718/d3n3r17m+xrBOsCv+wBAAAAQAox2AMAAACAFGKwBwAAAAApVG9q9op9HnVyLperrrqqpM9VG61bty53E1LLz+eTj5+bpV+/fiY/8cQTJjdv3nzzGlYDvgaw0JpAFEeLFi1M9vPkDRs2zOTVq1fn3F/Lli1NPuWUU0z2c0G2a9cuu0xNHari++hLL71ksq/19DXl06dPzy5369atyK1DJXj++edNPvPMM032dXC+HtjPuTlkyBCT/dzFvXr1Mrk2xy5/zFy8eLHJvsbP9/877rjD5OHDh5vsP/dRHjvttFNB2/u57JLz9Pl5ul999VWTR40alXPfvmbP92//PaBr164mN2vWLOf+S4FeDAAAAAApxGAPAAAAAFKIwR4AAAAApFC9qdlr27atyf6c8Pvuu8/k7t27m+zPy+7bt292udD5OpAOY8eONdnXR3kXXnihyTfffHOxm4R6zvchP6fTqlWrcj6+VatWJm+77bZFaRewSefOnU2eOXNmmVqCSuGvUzB+/HiT/XHL14FWEl8PNXnyZJN9LdcVV1xhsp931tfmo274ax4kv7NLUqdOnUz2c9n5OrpkXah/T30u9HoO9QG/7AEAAABACjHYAwAAAIAUqjencfpTLe++++6cGcjnJz/5Sc4M1FbHjh3L3QQAqJVKPm0znwEDBpi8YcOGMrUEtXHMMceUuwn1Gr/sAQAAAEAKMdgDAAAAgBRisAcAAAAAKcRgDwAAAABSiMEeAAAAAKQQgz0AAAAASCEGewAAAACQQgz2AAAAACCFGOwBAAAAQAox2AMAAACAFGKwBwAAAAApxGAPAAAAAFKIwR4AAAAApBCDPQAAAABIIQZ7AAAAAJBCIYqimm8cwoeSlpSuOSiynaMo6lDuRhSCPlbv0MdQF+pVP6OP1Uv0MZQafQylVmUfK2iwBwAAAACoHziNEwAAAABSiMEeAAAAAKQQgz0AAAAASCEGewAAAACQQgz2AAAAACCFGOwBAAAAQAox2AMAAACAFGKwBwAAAAApxGAPAAAAAFLo/wOAXrURSAaaRAAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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1iyTt27davIee+xhcseOHbPXORSlRx991OSwRi9c6+755583mRq99PhlDwAAAAASiMkeAAAAACQQkz0AAAAASKCCrdn76quvMnr8Z599ZvLgwYPTPj6shUnVrFkzk8P6waFDh5rcunVrkyur9UJ+LFmyxOSBAweaHNYhhML2l156yeT33nuvyn056KCDTD7qqKNMDuurwrpSJNOHH35Y7ef+4Ac/MLlVq1Ym33HHHWmf37dvX5PbtWtX7b6geIRrooW1nqm2bNli8saNG01u2LChyY0aNaph71CINm3aZPL++++f0fOffPJJkzt16lR2v1u3bqatbt26GfYOxWjt2rVp28877zyTw3WOkR6/7AEAAABAAjHZAwAAAIAEYrIHAAAAAAlUsDV7Xbp0MXnu3LkmH3nkkWmf36BBA5M7dOhg8ieffFLhc2fOnGnyb37zm7T566+/Npk6heJQWW1lZe3jxo0zOazpS31+urby2sN1iO68806TL7/88rR9Q3E68MADTZ4+fXq1t7VmzRqTr7322rSPHzFihMnhcTA8hnKcK05hbfGrr75a5edOnTo1bT7zzDNNnjJlismMmWQI6+jat29v8uLFi9M+/7nnnqswn3rqqabttttuM7lHjx5V7ieS44knnjA5vAZDuM7sYYcdZnLqdRFatmxp2naGulB+2QMAAACABGKyBwAAAAAJxGQPAAAAABLIVba2WKqSkhJfWlqaw+78x7Zt20wO6+LCdciyKaxpCGtdXn/9dZPDNf1uvfVWk3fbbbfsdS4DJSUlKi0tLapF/3I5xsJzvDt27GhypnV1mbRne9uHHnqoyWHdzS677KLawBjLru+++87kOXPmVPm5vXv3NrlOnZp9l7d9+3aTBw0aZPIDDzxQo+1nwjk313tfUms7rKFCGmNvvvmmySeddJLJ4XtrNp111lkmh3U34Zq2+cQYq75wDP3pT38y+ZprrjE5XKcvnXC90HBN2kmTJpncuHHjKm+7tjHGKrZgwQKTjz32WJPDtbczmbuEwmPgqFGjTA7Xjazpe2ltqmiMFc8rAAAAAABUGZM9AAAAAEiggl16IbwUai5P2wx1797d5PDy0nvuuafJDz74oMmHH364yeedd172OodqC5czmDVrlskvvfRSbXbHWLFihcnhsg6hefPmmXzGGWeY/Je//MXk2jqtEzUTLhlz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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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zZw502T/75FfF5qKX3/++9//PuW+n3jiCZMPP/xwk/16Qb8vmfxsWFrMMgAAAAAggZjsAQAAAEACMdkDAAAAgATK25o9f42nvXv3mnzuueea7Nfo+fyapREjRhTc99fryLa//vWvJk+YMMFkaviyo2XLlib751X754T7ateubXImz8Petm2byf54HTx4cFr7888599eZfOeddwrut27dOq19I3P8WsyhQ4ea7NeR+rUvN9xwQ5H73rlzp8nPPvtsyn37a4927tzZ5FmzZpns1z0gP+2d+IjJa7+xdXcNLuhucvDfT03e/tPrC+4PmfJP0+aUus65w3Q7Rm743rX28QcdnPLxyI0//9nWXhZXz+7z6946dOhQ4sd++qkdf37d8m233ZZWX3y7d+82uWrVqib7v+uTTz5ZcL9nT1uD2qxZszL1BUXbs8e+T/k15/6YatSokcn+378pU6YU3L/mmmtMW506dUz+wQ9+YLJ/HY/4mJCkSy65xGR/XMyePTtlX8sDswoAAAAASCAmewAAAACQQHl7GmfTpk1TtvunCXz22Wcm+5fFnzp1aomf2z/dr1OnTib7p1IdfLA9FSV+ilxhz+2fouefVhBv55TOzKlVq5bJ6Z6C8a9//StjffFP2zz77LNNfvvtt01O9zQa39q1a03etGlTmfaH0vFPLbnoootM3rBhg8n+8gi/+c1vTN61a1fBff/U98suu8zkVatWmXzdddeZfNddd5n83HPPmbx06VKT33zzTSH/BFs3mjzzIXtKnv8XZfM0eyn6e26fbHKT6vtPcxt3fQ/T5rqfavIL19lLkM/ZtMPkhZ3s9sd/9A+7vyr2lDpUTO3btzf5tNNOS7l9fFkkf5mt1atXZ6xfhfE/f/muuOKKgvs//elPTdvo0aOz0id8u3Trn/+0p5D7p3n6S6j55VnxJc/80pWFCxea3LBhw5R9u/VWu4TMgAEDTD766KNN9ssznnrqKZPL43M+MwkAAAAASCAmewAAAACQQEz2AAAAACCB8qZmz78E+ZAhQ0z2z6G94IILTPZrW7788kuT69evb/KoUaMK7vft29e0+bVd/iX3i+Ofn+7X9C1atMjkmjVrmlzW+iwU7oEHHjB50qRJKbfv1q2byePHj0/r+b744ouC+3PnzjVt9957r8n+mPDPV0/3nG7/8f4548XVxCI75s2bZ/J7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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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yW3bti3ztgBJCu07mfzlzytNXjlikMn1Lr+zqptU6fhlDwAAAAAyiMEeAAAAAGQQgz0AAAAAyKCCrdlbudKeU/vaa6+Z3K1bt5zrhxCK7g8fPtwsGzNmjMmdO3c2+f777ze5devWuRuLTFi8eLHJQ4cONfnaa68tcd1GjRqZ/OGHH5q83nrrmTxkyBCTzzrrLJObNWuWs62oHVatWlV0f+nSpWbZs88+a/JHH32Uc1u+f1922WUmb7rppib7/aTf59apw3eJ1WHGjBkmH3300Sa/8847Odf379O9995b4nMvv/xyk9u1a2fyvvvua7LvI40bN87ZFhSm77//3mR/bPR1dV7681hpJkyYYLLv3++9957J7IewplZ8+4PJhThw4l8BAAAAAGQQgz0AAAAAyCAGewAAAACQQQVz6qmvR/FzrTz33HM51//ggw9M3mCDDYru+3o/X5N34403muxr+P7zn/+Y3KZNm5xtQWHw81X94Q9/MNm/7x06dDD5/PPPL7p/4IEHmmW33nqryQcffLDJF154ocm+Zs+vX7duXSF7li9fbvKUKVNMfvTRR4vu+z5RXr7+ao899jDZ1+Xsv//+Jv/wg61raNKkSYmv9dNPP+Xctt/nomSnnXaayaXV6PXu3dvkww47LOfz03OA+vlq/Xy2d911l8n+WPr888+bvMMOO5hcv379nG1BzfDtt9+avPPOO5vs+0VV+uSTT0z29fCdOtk51FAzRPO/NHl+Xzu/dcvHnzI5rNW8ytpSp+OOJm/Q0A2N3JzKhYhf9gAAAAAggxjsAQAAAEAGMdgDAAAAgAwqmJq9999/32Rfo7fddtuZnK6XkqR69eyfmp7vZ6+99jLLdtttN5O33nprk32NRI8ePUz28x5RT1WYRo0aZbKv0fNzRt1zzz0m+36U9re//S3na//jH/8w+eyzzzb5gQceMLlfv345t4ea6csvbd3C9ddfb7KvcfL1xVGqlqB5c1vT0LNnT5O32WYbk30dabqOWZJatGhhsq9hnThxosm59nN+7qtTTz3V5BdffNHkjh07lrgtWFOnTjXZzyvWvn17k8ePH29yaXPfHXHEEUX3r7zySrPM10v985//NPmSSy4xeffddzf5qquuMvncc8/N2RbUDIcccojJvkbP1+vuuuuuJh911FEmb7/99kX3/TyPI0aMMNnPseznnKVGr2ZaOWmcycMPt5+BVri6uMEfzTS57jZdqqZhxfC/gv38la1Hb6TCwy97AAAAAJBBDPYAAAAAIIMY7AEAAABABhVMzV5p/Dnhffv2rfC2/Fw/J5xwgsm+bmH27Nkmv/322yb7ekLUTIsWLTL5iiuuMHm//fYz2ddI5arRK6+1117b5K5du5r8zDPPmHzooYea7GtUUTPMmTPH5D333NNkP9+c5+f4TNcLDxgwwCxbZ511KtLEEvnaru7du5vs594aM2ZM0f077rjDLPN/59VXX23y//7v/5rctGnT8jW2Fhk6dKjJl156qcmNGlVehYnf1hZbbGHyBRdcYPKOO9r5q84880yTlyxZUmlti1zNz+LFi032x/XK/P+Sdb62+K233sr5/JEjR5rs/z3nsu2225p89NFHm+yvoeCP2y+//LLJfh+L/Fg08haTl65aZfIF/e2xrTpr9Lzm9ezvYOP/85nJJ1dnYyoJv+wBAAAAQAYx2AMAAACADGKwBwAAAAAZVDCFPf78ej+XkD/335+/H0Ko8Gs3aNDA5F69epl88803V3jbqDn8/D2+T40ePdrkxx57rMrbtNrOO+9ssq9jGDZsmMmtW7eu8jah/AYNGmSyr13z88u98MILJrds2dJkv2+qSgsXLjTZz086b948k9O1NG3atDHLPvroI5M33HBDk/3+HSXzfWrChAkm+/oqX2f36quvmrz++utXuC1+rsUDDjggZ15T6T7n6z7TNaOS1Lt3b5MfeeSRSm1Llv3wg51nrLRaS19XVx7+OOznr/VWrFhh8kknnWSyn7PW15Gievxntq3pPnundiY3HPNQdTYnp0N6bWXy3x+2+9BowVcmh99VfJ9ZXTiiAgAAAEAGMdgDAAAAgAxisAcAAAAAGVQwNXu+zqBt27Ym+/Oy/RxpG2ywQdU0DLWGryMaOHBgtb32tGnTqu21UHUefvhhk32f8vuxNamfKq8FCxaYfPnll5t87bXX5lzft/Wuu+4qun/QQQetYetQEl/P7t83Pwenr61Mz9UoSW+++abJvg6vKv3yyy8mf/PNNyb7Ounzzjuv6P7SpUvNss0228zkwYMHV0YTa6UWLVqY7OeB9XMajhs3zuRtttkm5/ppfl/x5JNPlrWZkqT33nvPZD/P3qRJk3IuR+WJlv1at73A1Va6y2rUKP6aH58ut21fcflfTK4/4t4qb9Oa4pc9AAAAAMggBnsAAAAAkEEM9gAAAAAggwqmZs978MEHTd51111N3mWXXUy+8cYbTU7P91PaHHx+jpmxY8eWuZ3IjhEjRpjs+1Rl8nMH3XbbbSafcsopJrdq1arK2oLK42uNv/76a5P79u1rsp8jrVmzZhV+7eXLl5s8f/58k/1cd36/2Lx5c5P9XI/XXHONyfXqFezhpaD17NnT5KlTp5q8ww47mDx79myTn3rqKZPLMzfet9/aubR8Hajna1j9cd3XD+bi5xs899xzTfZ1Zyg7X4/brp2dI83Xyflj4/Tp0032cxym57AtrUbP76d8fVV6fk/pt5/funXrZvJzzz1ncpcuXXK+Psqh/q/1xGu72t/58+3xyM4gm18NTh9gcp17jzF58Vufmfw7t340/0v7gDuW5mNePn7ZAwAAAIAMYrAHAAAAABnEYA8AAAAAMqhgiyp83cEZZ5xh8nXXXWdy7969Tb766quL7vfp08csa926tclDhgwx2ddT+ef7+X1QGHw9VL9+/Uz2dQa+z2255ZYmL1mypOj+nDlzzLIvv7TndLdp08ZkX/Pg662GDRtmsp+vDTXTmDFjTC5tDrQjjzzS5JtvvtnkXPOH+m2dc845Jvt6KS+9jyyurb52BjVTx44dTfa1ln5ux6OOOsrkKVOmFN339VH333+/ybfeeqvJpdXslcbP8de9e3eT0/Pp+rncqnN+wNrmoYceMvnwww83edasWSa/9tprJvuav1yOPfZYk/1nO98nly1bZvKLL75osp8fd037KEoW6tUvut/jEPvv8y9jXzX570NPNLnexfY6BdWpTns7r/cWa9U3eelSOwZo8ctPJn998CEmt77tepOp2QMAAAAAVAoGewAAAACQQQV7Gqe/rPeoUaNM9lMvnHzyySb/9a9/Lbp/3nnnmWX+lLiVK1ea7C9B/sILL5jcuHHjElqNmsy/7/5Uyn322cfkbbfd1uTjjz/e5Pvuu6/o/uLFi9eobePHjzd57bXXXqPtIT8OPPBAk4cPH25yer8kSU8//bTJgwcPNjk9Jce///1vs+z888832Z9K5fnX8pcgb9SokVB41lprLZPvvPNOk5955hmTv/nmG5N9ycSa2HjjjXNm3+fOOussk9dk6hFUns0339zkGTNmmDxhwgSTjzvuOJP9dAi5vPPOOyb76Tj8tFv+2OjLdA45xJ5iV9rUW6gcdfe2n5/kTuMcPeZ5kwdsbU/XrdvXls1UpdDSltV0bNzQ5CtmfGHyIZtuZ/L8FXbM0HfD/Jd28cseAAAAAGQQgz0AAAAAyCAGewAAAACQQQVbs+f58679Jct93d0xxxxTdN9futc/t2FDe77uyy+/bPIWW9jLtCIbfG3mY489ZvJGG21k8u23325yut/4Os5OnTqZfNNNN5k8ceJEk33NaY8ePUxu2bKlUPP5y8EPGDDA5FWrVpk8aNAgk++55x6TJ02aVHTf11p5nTt3NtnX6LVo0SLn+sgGP+XG/PnzK7wtX+fsp+vw9VJ+P9WkSZMKvzbyx3/e8nnfffet8Pb8tqZNm2byXnvtZfLuu+9u8ujRo032NadMU5Qfdf90qsl/f8V+jj7jhpdMPv1YW8/e62w7bVHPi+xn/LBXL5vXt/XA0Vef/Hr/sw9ytjWa8i+TZy6xU1/Zo7T00yo7hjj05QdtWxo3zfl61YFeDwAAAAAZxGAPAAAAADKIwR4AAAAAZFBmava++uork5977jmT/Vx65eHraJjjrHb64gs7t4qf6/HJJ580uXv37hV+LT9PpJ9f6o477jD5nHPOqfBrIX98Dd/AgQNNfuSRR0x+9VU7N9GXX35ZdN/Xovh59vwcfX7+NWTD8uW2vmTo0KEmX3PNNSavt956Jrdt29bkdB9L35d+uw/08+KhdrrrrrtM9vPq+X1P+vNa69atzbJLL73UZD9P5NSpU0328+j5mj/f35EfdU619ejD5tjP8OdPmmPyU/OX2HzmrSa3qDfW5N/Vs8fDBSt+/Ry/cIX9TO9r8Er7Fcwv36ipnYO2zsbbqqbhlz0AAAAAyCAGewAAAACQQQz2AAAAACCDCrZmb+HChSZ37NjR5CVL7Pm9vs7uqKOOKrrvaxr8/FNnnHGGyZdddpnJt9xyi8l+nhgUJl+r6WsHDj74YJPXpEavNP369TN57733Nvm0004zuVEjew45aqZffvnF5JkzZ5r84Ycfmuz3Lek6Pb/Mz8VIjV7t8Morr5g8YsSInM+fPn26yW3atDE5PX+jX+bnpPVz9jH/Z+3g+4H/TOSdeOKJJvsa9bSxY20tlt/P+fr1efPmmbzffvuZ/Prrr5vs66ZRPXxdW9N77TzGfx9la85vGmmvifDuUnvsnO/q8HxdXi7tG9qhUB33EX6Fm4v7i59sfy8E/LIHAAAAABnEYA8AAAAAMojBHgAAAABkUMHW7I0bN85kX6M3cuRIk0866SSTc9Wv9OnTx+RBg+x8IP4ccj+f1SabbFLitlE43njjDZP9PHpz586ttrZsuOGGJrdr187kZ5991uTevXtXeZuw5nyNXq7aFem3tcnputKPPvrILLv44otN9v23YcOGZW4nCsfkyZNzLt95551NbtWqVc7nr7vuukX3/Rxnxx57rMlXXHGFyVdddZXJfl4+ZMPbb79t8qxZs0z2czcOHz68zNv2NXrdunUz2dfW+zn+/D7Wt3W77bYrc1tQdULjpibXO/96k08b+JPJq15+1OQfLh9tt+fq7pod2fPXdWfPtq815NqcbVv1wsMmn3n0lTmfXxPxyx4AAAAAZBCDPQAAAADIIAZ7AAAAAJBBBXsCfd++fU0+88wzTV5//fVNLs8cU37dzz//3OR9993XZD/n2ZtvvmnyOuusU+bXRs3xySefmOzrpdZbb73qbI5xxBFHmOzrEHr16mUycwnVDHPmzDH5j3/8Y7nWv/56W8cQpeb/2X///c2yKVOmmOznQPNzpqEwLV++3OQJEybkfL6fL7R+/fo5n5+umSqtpvTaa23ty4ABA0z2tcfIhsjNQ+b5GvIGDRpU+LX69+9vsr9eg6/Z82179913TaZmrzCE+rbGvO4+h5vc0uVcyvtpqE6PI03+fWM7d+n1c78zeczDN9jX+9Op5XzFyscvewAAAACQQQz2AAAAACCDGOwBAAAAQAYVbM1ey5YtTe7SpYvJfl69ffbZx+Ty1Fs1b97c5J122snkm2++2eR58+aZTM1eYdpqq61M9vVW77zzjsnVee6/f61+/fqZfM4555hMzV7N8Oijj5r8/fff53z+bDcfUKdOnUz29Sq53HjjjSZfcsklZV4XNdfChQtN9vuljTfe2OTdd9+9ytri59Fjv1M7+LnwqtKMGTNMHjhwYM7nN27c2OQ999yzspuEjAtrtzD5iC1am3zJG/81+bu//8Pk9ajZAwAAAABUBQZ7AAAAAJBBDPYAAAAAIIMKtmbPzw20+eabm/zSSy+ZfN1115l82WWXlfm1Vq1aZfKiRYtM9jV97du3L/O2UXP5Whc/L9mgQYNMfuKJJ0yuzHqVZcuWmXzccceZvOuuu5rcqFGjSnttVB4/55PPhx9u5wrabLPNKrxtb/HixWXeFgqHnxfWz3V3xhlnmOxrkSdOnJhzeXnstddeJrdt27bC20Lh8Mcbf+zzn5n8vipXzd8PP/xg8sknn2xyafu13XbbzWTmesSaan3CgfaBN24y8a1PFprco4rbUxb8sgcAAAAAGcRgDwAAAAAyiMEeAAAAAGRQwdbseSNHjjR51qxZJt99990mH3rooUX3S5sf7b//tXNojB8/3uQDD7Tn7/o5AFGY/Pw8p5xyiskXXnihyaNGjTL5rLPOKrpfWv3eypUrTX777bdNvuKKK0z2fey2227LuX3UDL42xedXXnnF5B9//NFk348efvjhErfl9enTp8ztROE64IADTB4yZIjJn332mcnbb7+9yRdccIHJm266adH9Dz74oDKaiIzx10zYf//9Tb7nnntM3mabbUxO15z/61//MstGjx5tst8neqeeauc088dlYE3VOegEk/cefKfJndqsXY2tKRt+2QMAAACADGKwBwAAAAAZxGAPAAAAADIoMzV7TZo0MdnPq9e1a1eTd9xxx6L7fm6gbt26mXzDDTfkfO0jjzyyzO1E4Ro8eLDJH3/8scnnnnuuyek6ux497Ewrzz77bM7XWrBggcl+/cmTJ5vs551EzbT11lub7Od8mjdvnsl+3+Lrh2fOnFl039fs3X777SZ37ty5fI1FQfLzvM6dO9dk36cmTZpk8sUXX1zm1/I1pFdffXWZ10V29e/f32Q/B62fozY9715ptcf+s56fd2/YsGEm16nDbxqoXOF3dm7Tw7+Yk6eWlB3/CgAAAAAggxjsAQAAAEAGZeY0Tm+HHXYw+fnnnzc5fRn9KVOmmGU+e3/5y19M5pLmtYM/HWTs2LEm/+1vfzM5fVqnP/XE95n05c2l35527E/BK20qB9RMPXv2NHno0KEmn3jiiSY//fTTZd728ccfb/Lhhx9ucsOGDcu8LWRHs2bNTJ44caLJF110kcmXXnqpyel+4y9j76cdatOmTQVbiSxJT20lSdOmTTPZ96Ncp276/Zo/bZPT04HS8cseAAAAAGQQgz0AAAAAyCAGewAAAACQQZmt2fN22mknk6dPn56nliArfJ3BxhtvbPKDDz5Ync1BATr66KNN3meffUx+4IEHcq6frj32NXn16tWa3TvWgK/Z8xkoL7/vGTlyZM4MoGrxyx4AAAAAZBCDPQAAAADIIAZ7AAAAAJBBFHUAQJ74+RI32mgjkwcOHFidzQEAABnDL3sAAAAAkEEM9gAAAAAggxjsAQAAAEAGMdgDAAAAgAxisAcAAAAAGcRgDwAAAAAyiMEeAAAAAGRQiKKo7E8O4VtJc6uuOahkG0VRtG6+G1Ee9LGCQx9DdSiofkYfK0j0MVQ1+hiqWrF9rFyDPQAAAABAYeA0TgAAAADIIAZ7AAAAAJBBDPYAAAAAIIMY7AEAAABABjHYAwAAAIAMYrAHAAAAABnEYA8AAAAAMojBHgAAAABkEIM9AAAAAMig/wflFw2VVn6OVQAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ] + }, + { + "cell_type": "code", + "source": [ + "model.index_summary()" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "CQxUTBfPnUOa", + "outputId": "de0f815b-5e2d-436a-f07a-7177b9eed41d" + }, + "execution_count": null, + "outputs": [ + { + "metadata": { + "tags": null + }, + "name": "stdout", + "output_type": "stream", + "text": [ + "[Info]\n", + "------------------ ------------\n", + "distance cosine\n", + "key value store CachedStore\n", + "search algorithm LinearSearch\n", + "evaluator memory\n", + "index size 200\n", + "calibrated False\n", + "calibration_metric f1\n", + "embedding_output\n", + "------------------ ------------\n", + "\n", + "\n", + "\n", + "[Performance]\n", + "----------- -----------\n", + "num lookups 10\n", + "min 0.00716727\n", + "max 0.00716727\n", + "avg 0.00716727\n", + "median 0.00716727\n", + "stddev 0\n", + "----------- -----------\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title save the model and the index\n", + "save_path = \"models/hello_world\" # @param {type:\"string\"}\n", + "model.save(save_path, save_index=True)" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "GHbK9xObnWPh", + "outputId": "8b72c936-894d-4b80-892b-d386ed6ec2a3" + }, + "execution_count": null, + "outputs": [ + { + "metadata": { + "tags": null + }, + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:absl:Found untraced functions such as _jit_compiled_convolution_op, _jit_compiled_convolution_op, _jit_compiled_convolution_op, _jit_compiled_convolution_op, _update_step_xla while saving (showing 5 of 5). These functions will not be directly callable after loading.\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title reload the model\n", + "reloaded_model = tf.keras.models.load_model(\n", + " save_path,\n", + " custom_objects={\"SimilarityModel\": tfsim.models.SimilarityModel},\n", + ")\n", + "# reload the index\n", + "reloaded_model.load_index(save_path)" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "8n51hOGynYXv", + "outputId": "56c238c4-c80e-4c0d-d1e1-e05c15e3f6e3" + }, + "execution_count": null, + "outputs": [ + { + "metadata": { + "tags": null + }, + "name": "stdout", + "output_type": "stream", + "text": [ + "Distance metric automatically set to cosine use the distance arg to override.\n", + "Loading index data\n", + "Loading search index\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title check the index is back\n", + "reloaded_model.index_summary()" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "BHTwTTY5nbJJ", + "outputId": "ffadf50c-f527-47eb-8e9b-65bf3ac3d351" + }, + "execution_count": null, + "outputs": [ + { + "metadata": { + "tags": null + }, + "name": "stdout", + "output_type": "stream", + "text": [ + "[Info]\n", + "------------------ ------------\n", + "distance cosine\n", + "key value store CachedStore\n", + "search algorithm LinearSearch\n", + "evaluator memory\n", + "index size 200\n", + "calibrated False\n", + "calibration_metric f1\n", + "embedding_output\n", + "------------------ ------------\n", + "\n", + "\n", + "\n", + "[Performance]\n", + "----------- -\n", + "num lookups 0\n", + "min 0\n", + "max 0\n", + "avg 0\n", + "median 0\n", + "stddev 0\n", + "----------- -\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "#@title re-run to test on other examples\n", + "num_neighbors = 5\n", + "\n", + "# select\n", + "x_display, y_display = tfsim.samplers.select_examples(x_test, y_test, CLASSES, 1)\n", + "\n", + "# lookup the nearest neighbors\n", + "nns = model.lookup(x_display, k=num_neighbors)\n", + "\n", + "# display\n", + "for idx in np.argsort(y_display):\n", + " tfsim.visualization.viz_neigbors_imgs(x_display[idx], y_display[idx], nns[idx], fig_size=(16, 2), cmap=\"Greys\")" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000, + "referenced_widgets": [ + "f530d120e10445ecb401b690461cc09c", + "862ae5e690c84720aa259ded368d3fef", + "bab9c3c5b3164d23a54f203574bc2bbe", + "0ad5111179e947ebbd0e6086be82596b" + ] + }, + "id": "JpR6WrCinfW4", + "outputId": "c8788f94-f01c-4ffc-a31e-8173243fd105" + }, + "execution_count": null, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "f530d120e10445ecb401b690461cc09c", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "filtering examples: 0%| | 0/10000 [00:00" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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k/v161d2v3379mbb+++/b/LEiRNNPv30001etWqVyeecc07GtqB2Cq9348ePN3nw4MEmf/vttxUea8GCBVk9d9jn+vbtm9X+yJ2hQ4eW3b/11lszPjbXNeLh8T766KOy+0cffbTZ1q1bN5PD63TDhg1z2jbkR7JGVJIWLVpkcljPu2TJkho9X/g9COeff37Z/fA9/PaAT/YAAAAAIIUY7AEAAABACjHYAwAAAIAUKmjN3sknn1x2P6xBKqZwzZd33nnH5D//+c8mh2sLvfjiiyY3adIkh61DdYVrkv3kJz8xOaynqkz9+vUzbr/yyitNzlRLsGbNGpNvuummjMc+44wzTH777bdNbtGiRcb9URibNm0yOax/+vDDD00O14z63e9+Z/Jpp51mcseOHU1u0KBBldv21FNPmdy/f3+Tw1qYs88+22TWdqydwvVDr7nmGpPvueeeKh+rV69eJodroI0ePdrkefPmmcy1EdkKr3UnnniiyVOnTi1kc5CF5HdzDB8+3GwbOXJkQduSPM+F44/wuppGfLIHAAAAACnEYA8AAAAAUojBHgAAAACkUEFr9l555ZWy+7W5/iOs6bv44otNHjVqVAFbg4rUqVPH5AEDBpgc1k+dddZZJof1VMccc0zO2hauXTV9+nSTwznlYe1LWG9IzV5pqFvXnlJvu+02k19//XWTw/UU99577/w0TNJee+2VcXu43lpYq7zTTjvlvE3IvfXr15t8yimnmPzSSy9l3H/IkCEmDxo0qOx+2D/DesAnn3wy47H33XffjNtROOeee27Z/crWS7zwwgtNbt68eY2e+9lnnzU5rCPN5L333qvRcyN/whre5PuUL7/8Mqtjhetd9+jRw+S1a9ea/Oijj2Y83tKlS8vud+/e3WwLr8sdOnSocjtrCz7ZAwAAAIAUYrAHAAAAACnEYA8AAAAAUqigNXvJOqTK5tdma+DAgSaHa6pl8v7775vcu3dvk1euXFndZqGImjZtavINN9xQpJZ81+bNm02ePXt2kVqCfDr00EMz5kLabbfdTA7XFw3zpEmTTO7Xr19+Goacuuqqq0wOa/QaNWqU8fFXX321yTvssO1vwmGNXmXrSJ555pkmt2rVqqJmo8CSNbxjx47N63OFdXYjRozI6/OhOP75z3+anDx3hGvIVlanGa5rHK4pu2XLFpNvv/12k5M1qZL0xBNPlN1ftmyZ2RbWGofnxDTgkz0AAAAASCEGewAAAACQQgz2AAAAACCFClqzl1zbIlznopjCub/ZYo0zlCfZr6ZOnWq2jRs3zuTFixdnPNbdd99t8kEHHVTD1mF7E9ZTVLbW6XHHHZfP5qBA2rRpY3JYn9K1a9eM+yf7zQknnGC2hfXs4VqOd9xxh8nh2qWoHcJ1YMOap+QaypI0ceJEk8M11tasWVPl5w7XbrvllluqvC8KK6zNTK5fF6732aRJkxo9V3j9CvOcOXNqdPy04ZM9AAAAAEghBnsAAAAAkEIM9gAAAAAghQpas1dMq1evNvmhhx4qu3/55ZebbZXVsoSGDh1a/YYhNcLaz9tuu63sfrh2VWV22WUXk8M1Y+rW3W7+10U1hWs5hnU3oT59+pjcsGHDnLcJ+ReuEXXzzTebHK5XFbr33ntNfvDBB8vuhzV6O+64o8ljxowxOXx8Tet0UBxhn3rjjTcK9txhnwlrTMPzXJ06dfLeJpTvwAMPzJhz6bXXXjP5qKOOqvaxpkyZYvJFF11kcuPGjat97FLBJ3sAAAAAkEIM9gAAAAAghRjsAQAAAEAK1ZrCn1WrVpkcrucRGjhwoMnhui7h8WrixRdfNDlc1+iQQw7J2XOhcJYuXWryp59+mvHx06ZNM3nIkCHVfu7BgwebTI0eypNc72r48OFm21tvvWVyZXU2//jHP0y+8cYbTb700ktNpv6qNLVu3Tqrxz/99NMmX3zxxSZnWod2w4YNJh977LEmh/VT4TnxyCOPNLlLly4msy4fJk+ebHJYc9q0aVOTw/VEO3ToYDLX0nRIruFXU6+++qrJ4drZYQ1fmMP1RUsRn+wBAAAAQAox2AMAAACAFGKwBwAAAAApVLKTl8P6pyuuuMLkGTNmFLA1mfXv39/kcE55cp0i6bvrgbRs2dLk+vXr57B1qKr//e9/Jnfr1s3kRYsWFawt99xzj8nJ2izpuzWpPXv2NHmHHfg7ThqF9cHnnHNO2f0FCxbU6NgrVqww+YYbbjD50UcfNTlc5yisVUZpeO6550wOa/LC2uRMNXqVWbduXcbtv//97zNu79ixo8lhLU24/igK46677jL5D3/4g8nLly/PuH947WzUqJHJ//nPf6rdtnAtx06dOpkcvj8L10X+wQ9+YDLr9NUOp512mslvvvmmyYcddpjJyRq/qVOnmm1r1641edOmTSbfeeedJofjj9tvv93kgw8+uIJWFw/vCAEAAAAghRjsAQAAAEAKlew0znnz5plcStM2K7N+/XqTBw0alPHx4ddRh9MMUBjhV9cXctpmKJwONWrUqIw57GOPPPKIyUzrrJ02btxocnLapiTNnz+/7L5zzmxr3LixyeE0zIMOOijjc99///0mh1NVwmnOixcvzng8FMbHH39scp8+fUz23md1vDPPPLPs/pVXXmm2VfaV499++63JL7/8ssm/+tWvTJ4zZ47J4bJFn3zyicmUPBRGOC3t+eefz2r/cIr4TjvtZPLs2bMr3DdcMmbixIkmT5kyJeNzjx8/PmMeNmyYyeFUY66dpalt27YmT5gwocr7Lly40OSwj4V94oMPPjA5nF4eLjnzwgsvmFwK0zrpxQAAAACQQgz2AAAAACCFGOwBAAAAQAqVbM1e+PXP2dYZhPOu27dvb3JY+5KNmTNnmvzUU0+ZPG7cOJM///xzk8Pf5frrrzc5WUtz0kknVbudyM6tt95qcvi6LlmyJOP+5513nsnJr3yurLblyy+/NDms46ysLmHMmDEm//rXvza5e/fuGfdHaQrPg2vWrDG5RYsWZffDr9S/5JJLTG7SpElWz3355ZebfN9995kcft366NGjTR48eHBWz4fcmD59usnh9aZuXXvZHzlypMnhV9U3a9as2m0Jn+vEE080ee7cuSaHy3f897//Nbkmy0KgeJo3b55xe6b64XDb+eefb/KsWbNMPvnkk00O+1DommuuMTlciqFfv34Z90fts8cee5j8y1/+0uQTTjjB5EmTJpl87bXXmhzWEvfq1cvkcKmHAw88sMptzRU+2QMAAACAFGKwBwAAAAApxGAPAAAAAFKoZGv2BgwYYPLxxx+f1f677rqryblcK6Vz584Zc1gP2KNHD5PDNQRDyTVnqNkrnHDtn7CepLK60R133NHkcN2zTMI1Y/7+97+b/Pbbb5t89NFHm7xhwwaTL7roIpPDdSrDWhqUpgYNGpgc1v8mfe9738vpc7du3drksI706quvNvmhhx4ymZq94vj5z39ucli/HtZyhq9zIYX1gLvttpvJn376aQFbg9ogfC8Xvv8Kv1NhxIgRJodrqIWee+45k6nZ2/40bNjQ5FNOOcXkcN28sM5z5cqVJh9xxBEmv/XWWyb/8Ic/rFY7s8En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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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Vibp9bjsREU/fB4R72OSonl57YP9r5N0TVH+7Yv94pfKTdGQuieKXQ9upf86K/pyu1bR0I8lkrokyqZLmp7I5yi6utP2+EvLI5JaVrPfckmDqym7WtGQuXB7F9ySdfmfkjYp6vTNl9S52P/+h8OtvttY/LcXFP1qXdW+TlB0EYzuVZQ1k/SYovlUH0u6Pij/qaS/KxoW876kfw7KL4nLd8T15EvSYdjGgu1csq0p+sFpSfw+VKHoLN1xifITFA313BW3sWuD/V0ft80diq5K3KzY//6Hw62htDFFc6ScouU6diVuI+Py2+LyZNmuxL4uk/Shoh+uVkk6r9j/9ofLrdTamA79PuUSZV0VfY/6VNEomUWSTonLeisapbA7Ll8sqV+w7wFx+VHF+LeuPAsAAAAAAEiRw/YCLQAAAACQZnT2AAAAACCF6OwBAAAAQArR2QMAAACAFKKzBwAAAAAplNOi6sccc4zr0qVLgaqCfCsvL1dFRUW4qGNJo401LLQx1Ic333yzwjn3tWLXo6ZoYw0PbQyFRhtDoVXXxnLq7HXp0kUrV67MX61QUP369St2FXJGG2tYaGOoD2a2vth1yAVtrOGhjaHQaGMotOraGMM4AQAAACCF6OwBAAAAQArR2QMAAACAFKKzBwAAAAApRGcPAAAAAFKIzh4AAAAApBCdPQAAAABIITp7AAAAAJBCdPYAAAAAIIXo7AEAAABACtHZAwAAAIAUorMHAAAAAClEZw8AAAAAUqhJsSsA4FDz58/38kUXXeTlN99808t9+/YtcI0AAACQi71793p59OjRXn711VcP3l+zZo1X1rp167zUgTN7AAAAAJBCdPYAAAAAIIVSM4xzz549Xh45cqSXf/KTn3h56NChBavLvn37vDxt2jQvDx482Munn356weqChmnWrFleNjMvX3DBBV7+4IMPCl4npFtZWZmXx48f72XnXH1WBwUyZ84cL992221eXrFihZfbtGmTt2Nv3rzZy+ecc46X33nnnbwdC+n06aefevmYY47x8meffeblfLZfoDbee+89Lz/55JPVbrtr1y4vM4wTAAAAAFAtOnsAAAAAkEJ09gAAAAAghVIzZ++ee+7x8u9//3svH3300V4u5Jy95cuXe/mGG27w8qmnnurlt956y8stWrQoTMXQYIwdO9bL8+bNK1JNcLhYvHixly+5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wuHnGzZsyNu+w8/t1atXe/mFF17w8qZNmzLur1u3bl4Oh+CFlyi/8sorvZwcxoniCZdeCF/Hv/zlL14+7rjjvJzp+144bDPcN8M20+m2227z8q233urlunyHCodt5ns5tHBphrD99+nTJ6/HqwnO7AEAAABACtHZAwAAAIAUorMHAAAAAClUtDl7a9eu9XI4pjWbESNG5K0u119/vZfDpRbuvPNOL2cbQ37iiSd6OZdLwqJutm7devB+OOY717H9N998c06Pz1R+0kkneTlsI6HevXt7+ZFHHvFyOF79lltu8fKkSZO8HLZpFEY4xy7bXOJswsfXZX/Tpk2rU11QGrLNVbnvvvsy5lKyf/9+L4dziX/96197+fvf/37B64Tc9e/f38tPP/10xu3D1z352dmkif+1NFwSCQ1TeF2B448/3ssffvihl8Mlzl555RUvv/baa15esWKFl8Pv3clrZxRyiSPp0OVuwqUeioEzewAAAACQQnT2AAAAACCF6OwBAAAAQAoVbc7e9OnTvVzXtVIuuOACLx9xxBFevuKKK7zcuXPng/fPPPNMryxcnyrbui5nnHGGl9944w0vN2/evLpqI8+WLl168H647lhdtW7d2stDhw718tixY72cHLd9wgkneGVt27bNeKwlS5Z4OZyzFwrXiWGOXnFkm1OXbd5c+N6TbX/JuQeDBg3yysaNG+flsrKyOtUNpeHCCy/0cocOHby8efPm+qxOTvr27evlqVOnennw4MH1WBvkSzgvNFxH74MPPvByOLf59NNPP3g//D7VtGlTL4fXTAgzSlO4fnU4Ry80Z86cjDlXs2fPrtPjk8L5huF8wq5du3o5nIdaDJzZAwAAAIAUorMHAAAAAClEZw8AAAAAUqhoA0n79OmTsbxFixZeDueyhOuWHXvssV7OthZR0t69e70crs+WzZAhQ7zMHL3iSb4WAwcO9MrC+SK57Es6dG5n+/btc6tcAYVzYMPnjvoRzh8J54326NEj4+PDeXZAKJz/MWXKFC9PnDixTvs/77zzvJzpfTNcD/T888/PuO9w3uiAAQNyqxxKUqdOnbz8q1/9qtb72rBhQ8bybNdQQGn64Q9/6OVwXmf4vlVRUeHlr776ysvhun1hnyBcnzHbHMGkcJ7oRRdd5OXf/va3Xg77K6WIM3sAAAAAkEJ09gAAAAAghejsAQAAAEAKFW3O3nXXXeflU0891cunnHJKxpxPe/bs8XK4Tl4onA+YXOsKxZWcL7lw4UKvLFx7MU02bdpU7CqgCtnm6AF1deWVV2bMhbR///56OxYOD3/+85+9nG1OXjhfEKUp/N4crqm5evVqL2/fvt3L4Zy9jRs3erlnz55evvHGG72cyzzSyy67zMszZ86s8WNLFWf2AAAAACCF6OwBAAAAQArR2QMAAACAFCranL1w/O6wYcOKVJND1+vIJlwP5Jvf/GY+q4M8SdMcvXD9tgMHDmTMQK5Y4w+5yrYmWrjmbNeuXQtZHaTArl27vBx+9oU6duxYyOqgSNq0aZOxPFzneN68eV7OZY5eeA2RyZMn1/ixDQVn9gAAAAAghejsAQAAAEAK0dkDAAAAgBQq2py9UjJ79uyctu/Vq1eBagJULVxrKJzzGmYgV+vWrfMyawQim7lz52Ysb9GihZc7dOhQyOogBR555BEvZ1tnb8WKFYWsDkpUOF946tSpNX5s+D40duxYLzdr1qz2FStRfEMEAAAAgBSiswcAAAAAKURnDwAAAABSiDl7kgYMGJDT9g888ICXr7nmmjzWBgDq3+LFi73MnD1kM3/+/Izl2dbKAkLhmrHZ1tnL9fsb0mHp0qVezjZ3M3ldg2effdYr6969e/4qVqI4swcAAAAAKURnDwAAAABSiM4eAAAAAKQQc/YkvfHGGzltf++99xaoJgBQHOPHj/fyuHHjilQTlKqKigovr1q1KuP24fpVQL7169ev2FVAEfTu3Tun7Tt16nTwfrdu3byyJk3S3xXizB4AAAAApBCdPQAAAABIITp7AAAAAJBC6R+omgc/+tGPvDxkyJAi1QSHi+9+97teHjlypJefeOIJL990000FrxMatkGDBuW0fVlZmZeZw4cWLVp4uX379l7euXOnl8M5fkBdNW7c2MutWrUqUk1QTKtXr85Yftlll3k5uT52u3btClGlksaZPQAAAABIITp7AAAAAJBCDOOUNGDAAC+PGjXKy2PGjPFyo0b0kVFYTZs29fKMGTMyZiCbHj16eHnt2rVe7tmzp5dZigGhI4880suTJk3ycthmwjYFZNOnTx8vL1u2zMvhcPTTTjut4HVC6QmntoQZPnotAAAAAJBCdPYAAAAAIIXo7AEAAABACjFnT4eOAc/1EuUA0NCEc/icc0WqCRqqcB4n8zpRVw8++GDGDCB3nNkDAAAAgBSiswcAAAAAKURnDwAAAABSiM4eAAAAAKQQnT0AAAAASCE6ewAAAACQQnT2AAAAACCF6OwBAAAAQArR2QMAAACAFKKzBwAAAAApRGcPAAAAAFLInHM139hsi6T1hasO8uwk59zXil2JXNDGGhzaGOpDg2pntLEGiTaGQqONodCqbGM5dfYAAAAAAA0DwzgBAAAAIIXo7AEAAABACtHZAwAAAIAUorMHAAAAAClEZw8AAAAAUojOHgAAAACkEJ09AAAAAEghOnsAAAAAkEJ09gAAAAAghf4/kL3I8e+lHdsAAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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7bebnF7uw79vrhq9XPylFObMmWOyX381YMAAk0899dTden7kh1/neeCBB5rs18L49Se9e/fObE+cONG0+bXy/ufq66+/brK/PE3btm1N/vjjj02uU6eOUPxyLb1w+eWXm/zss8+W+Xj/sX79X6720aNHm0zNXhj++Mc/mjxjxgyTx44da/Kf//xnk9N1c/vss49p82v0fP7n9gknnGCy/92wa9euJg8cONDkefPmZX2+ysCRPQAAAAAIEJM9AAAAAAgQkz0AAAAACFAwNXtLly7N2u6vlbdgwYIy75trXRd/X/46Rf75u/75vigN/ppPw4cPN/nBBx/M+vgNGzZkth966CHT5ue9997b5AsuuMBkv1bLrxtFmK6++mqT/bUf0zV/zZs3r9S++HXRRx11lMl+zSA1e8XBX/vu008/NdmvFz700ENNfuyxxzLbfo1eLn5tzOTJk00+5ZRTTH755ZdN7tOnT4WeD4WRa509/7PS/0518MEHZ7ZPPvnkrPv210Tzr6ngP7e/v/R4lqS99tpLKD2XXXaZyePHjzf5mmuuMfnNN9/MbPtrIu+uNm3amDx//nyTTz/9dJPfeustk/01bCsDR/YAAAAAIEBM9gAAAAAgQEz2AAAAACBAwdTsnXvuuXnbl78mWsuWLU321w7y86JFi0zOtqYfipdfVzBu3DiTZ82aZfLq1atNfuGFFzLbX331lWnz1yRbv369yf6aMv76VPk+5xyF4a/H468h5dfoFTPWRCtOfl3co48+arJfL/ziiy9mbd8d/vpUfn3g1KlTTaZmrzTkWmcv11p56bWKW7dunfW5/Bo9f400f9/+mn7Lli0z2V8vFKXBr7Vct26dyVdccYXJ27Zty2wPGTKk8jqmn17XY82aNWX2papwZA8AAAAAAsRkDwAAAAACxGQPAAAAAAIUTM3eypUrTfbPIfc99dRTmW1/PagaNWpkfaxfR9O2bVuT/fOBP/roo6z7Q2nw17bbd999TfZr9tJ1CNdee61py1XX+eWXX5rsrws5ePBgk3ONWRSniy66yOTXXnvN5FxrOc6cOTOz3a9fP9Pm13k2aNBgV7pYZt9effVVk3P1FYWRXu9zZ8477zyT81mj5/Pfp+rWrVtpz4Wqk2udvVztuer0st33vffeM9mvC/Vr/NK19BI1e6Xq8ssvN3nSpEkmp79/+Q477LBK6dMOI0aMMNn/LG7Xrl2lPv/OcGQPAAAAAALEZA8AAAAAAsRkDwAAAAACFEzN3rvvvmvy3XffbfJDDz1kcrq+5MgjjzRtBx10UNbn2rx5s8n+mhl+7RbC5K+F17dvX5OnTJmS2b744otNm3/O9qpVq0weNWqUyePHjzfZr1vw74/SdN9995nsr8c4cOBAk5cuXZrZ7tWrl2n7/e9/b/LVV19tsl+D6lu7dq3J/fv3N9lfI60idTeoOmPGjDHZr5/yc2Xya7W2bNlicu3atausL8if3V1nb3e0adPG5JNOOslkv5Y41/UcUBr8dV3/9Kc/meyvl502fPhwk//yl7+Y7NcSb9261WT/OhznnHOOyf4agP78Y3fr53cFR/YAAAAAIEBM9gAAAAAgQEz2AAAAACBAwdTsHXLIISbfcsstJvvnzD777LOZ7S5dupi29u3bm+yfX+7XV/m1Lb/73e9ydxglr2fPniZ36tTJ5DfeeCOzPWzYMNP217/+1WS/juC6664zeeLEiSa///77FessSoK/Dtlxxx1n8hNPPGHyoEGDMttffPGFaRs9erTJ999/v8lNmjTJ2pePP/7Y5EsuucRk/z02Vw0gCuP77783uUWLFibfe++9Jp9++ukm+zXtFbFx40aTb7zxRpOXLVtm8qxZs3b5uVA427dvN9lfA82vm/Nr+E4++eTM9s0332zaevToUaG++DWoBx98sMlnnXVWhfaH0nD88ceb/Mwzz5icXk/7ySefNG3+GrJ+TZ1fs7dixQqTjz32WJNnz56ddX+FwJE9AAAAAAgQkz0AAAAACBCTPQAAAAAIUDA1e77mzZub7J8Hftttt2W2169fb9oWLlxocq51WU488USTL7vssnL3E6WrVq1aJvvrvKTX1vPPEa9Xr16Fnstf9+Wwww6r0OMRht69e5ucrt306wT8utCnn37a5M8++8xkv+b01ltvNdmvw/HrC1Gc/DWh/PqqkSNHmty1a1eTjz766Mz2nXfeadoaNWpk8nPPPWdy+nNW+mmt1mOPPWZy27ZthdLnj6kJEyaY7F8HIX0NBX8M+bXC/r79Gr30viRbDyixHmio/DHlr7f40ksvZbanTp1q2vzx6Y8pf12+yZMnm+x/dhZDjZ6PI3sAAAAAECAmewAAAAAQICZ7AAAAABCgYGv2/PN3/bXvrrrqqsy2X8syZcoUk5csWWLywIEDTb7nnntMbtiwYcU6iyD4a60sXrw4sz1ixAjT5q/76LvggguyZr92C9VTupZzwIABps3PqJ723HPPrO0PPPCAyX369DE5Xc9y5plnmrY6deqY3K9fP5Nnzpxpcrdu3Uxu3Lhx1r6hNPl1cX6d6PTp000+77zzMtt+Xae/Rl+u+ir/Ggu9evUqR48Ruu7du+90W/rpGrQh4sgeAAAAAASIyR4AAAAABIjJHgAAAAAEKNiaPZ9fw9ekSZPMdq76KGBXpNda8esM/AwAheCvl5iun9pZBnbXoEGDTE7X+Pk1dz179jTZ/y7n1/iNHTs263MB1RFH9gAAAAAgQEz2AAAAACBA1eY0TgAAABQX/1L4af6yDQAqjiN7AAAAABAgJnsAAAAAECAmewAAAAAQICZ7AAAAABAgJnsAAAAAECAmewAAAAAQICZ7AAAAABAgJnsAAAAAECAmewAAAAAQICZ7AAAAABAgJnsAAAAAECAmewAAAAAQICZ7AAAAABAgJnsAAAAAECAmewAAAAAQIBdFUfnv7NwaSR9VXneQZwdHUdSs0J2oCMZYyWGMoSqU1DhjjJUkxhgqG2MMlW2nY6xCkz0AAAAAQGngNE4AAAAACBCTPQAAAAAIEJM9AAAAAAgQkz0AAAAACBCTPQAAAAAIEJM9AAAAAAgQkz0AAAAACBCTPQAAAAAIEJM9AAAAAAjQ/weAHKj98UDFWwAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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5D59+jTp869cuTK9/NFHHznrrLVOfvvtt538wx/+MOOx/fqrgQMHOvlHP/qRk88+++zMjUXF27hxo5PnzZvn5HfffdfJc+bMyXi8JUuWODnenyVp8ODB+TYRTeCNN95w8nvvvZfX/v656brrrksv79mzp/CGSTriiCOcvGrVKid369atUcdH7uI1u/Pnz3fWXXjhhU6+4IILnNy+fXsnt27dOuHWAcnat29fevnpp5/OuG38nCflf96rqalx8u9//3snd+rUKa/j4a+GDx/u5BdffDG9PGLECGfd1KlTnex//iBZXNkDAAAAgAAx2AMAAACAADHYAwAAAIAANWnN3ksvvZRevuKKK5x1SdeD+LUtxhgn/+lPf0ov19XV5bWvn32HHOKOof3aF7/GYuHChU4eOnRoxuMjGXv37nXy2rVrnXzcccdl3L9ZM/d/n02bNjk5Xnswa9YsZ51fw9dYr732mpOp2UuO/16ddtpp6eXt27fndazPPvvMyfFalVz49cDxc02281I2f/7zn5386KOPOvmuu+5q1PGRu/j7umDBAmedn30nnHCCk/16eL+Gz5/Ds5R69uzpZOp4KsOOHTucvHjxYic/9thjTvbr5DIdq7G/j/n872jw68z8enjk7mtf+5qTX3nllfTyihUrnHVHH320ky+55BIn9+7d28nHHHOMk/v375/xeOvXr8+hxYXp2rWrk3v16lW050oKV/YAAAAAIEAM9gAAAAAgQAz2AAAAACBARa3Z8+f+GjNmTHrZr5Pbtm1bos+d7T7vUvJrxT755JMStaS67Ny508lDhgxxcocOHZzs18H5/NrKbNsX08UXX1yy5w6dPzfeunXrStSSL4rXLfj98eSTT3ayP5fjgw8+mPHYc+fOdTI1e03nzDPPTC+PHDnSWefP0enz3+dsczk+++yzGdfHP0sb+zma7XO5S5cuTvZru7p3796o50dh/HqrUaNGOXn37t1O3rx5c9Ha4s+z17x584zbP/PMM0722xb//gbpi7+PdezYMd8mVq0PPvjAyV/+8pfTy9n6RHxOPin/c41fi+zXxzfm3OWftwYMGODkV199NWNbygFX9gAAAAAgQAz2AAAAACBADPYAAAAAIEBFrdnz5zPx6/SqlT930KBBg0rUkrAtW7bMyZMmTXLyW2+91ajj51Oj588B48+v5s/Rl69KmOcl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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA3sAAACOCAYAAACIehHUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/NK7nSAAAACXBIWXMAAAsTAAALEwEAmpwYAAAj4ElEQVR4nO3deZgU1dn+8fuwyy6Ksijighpxl58xooKACkYxvmgwID+JwRUiLkRxe0FwQXAhKBDcF1AQI0oIalBZFDEIalBwJSq44IKArApS7x9VtPUcp3ump2emp4vv57r6su6upWvsQ3Wd7nrquCAIBAAAAABIlir53gEAAAAAQNmjswcAAAAACURnDwAAAAASiM4eAAAAACQQnT0AAAAASCA6ewAAAACQQHT2AAAAACCBtuvOnnOupXNuunNulXNuhXPubudctXzvF8pWWb/P0fZmOuc2OOfec851yrBsI+fcJOfcSufct865Cc65+kUs1845Fzjnbow9d45zbqFz7nvn3GfOueFF7bdzrpVzbpNzbnzsuWucc+tij43Oua3OuZ1L+3cjvTy3scXee73FOfeP2PxDo3a0IfrvoUVso4Zz7l3n3Gfe82nXdc5d5pz7b9Q+v3DO3cnxs/zks41Fy3dyzr3hnFsfHY9+H5tX1Tl3Y9QO1jrn3nTONSxiGy9Gx7lqUd7FOfd4tN4a59xc59yvY8sf75x72zm3OjqGTnHONS/t34zMos+rKdF7/KlzrkeO2+sYta0NUVvbo5jl+zvnPo5e/13n3L5FLPNA1Ib2iT03K/oM3HYMfN9bp0f096x3zj3tnGsUm7fOe/zknLsrl78b6VXy49g9zrn3XXiu1NtbL+P5WKY26JxrH20z3s7OKe3fXBrbdWdP0hhJX0tqKulQSe0kXZzPHUK5KOv3+XFJb0raSdK1kp50zjVOs+yNknaUtKekvSXtKmlwfAHnXHVJf5X0b2/d2pIulbSzpF9L6ihpQBGvMVrS6/EngiC4OQiCutsekm6VNCsIgm9L9iciS3lrY0EQtI69z/UkLZc0WQo7cZKekTReYTt8WNIz0fNxf5H0TfyJEqw7VdLhQRDUl3SgpEMkXZLD34zM8tbGnHMHSHosWq6Bwvd6YWyRGyQdLek3kupL6iVpk7eNnpKqe5uuq/DYdYSkRgrb2D+dc3Wj+UsknRQEQUNJzSR9KGls9n8qSmi0pB8Vfk71lDTWOde6NBty4ReLT0m6XuF7u0DSpAzL95H0J0m/VdguTpH0rbfMMQo/R4vSL/aZt19sndaSxilsk7tK2qDw35IkyfucbCJpo6LjJ8pFZT6O/SfalzeKWL0k52NFtsHIF/G2FgTBw1n8jbkLgmC7fUh6V9LJsTxC0rh87xePyvs+S9pX0g+S6sWee1nShWmWf1bSxbHcV9Lz3jIDJQ2X9JCkGzO89uWS/uE9d5akJxR2IMenWc9J+q+kc/L9XiT1kc825q3bTtJaSXWifKKkzyW52DLLJHWO5T2j/e8i6bPY88WuG3t+J0kvSBqT7/ciqY88H8cekzQ0zbwdJa2TtHeG12sg6QNJR0kKJFXLsOz3ko4o4vmakm6RtCTf70USH5LqKOzo7Rt77lFJw0q5vfMlveptf6Ok/YtYtorCL6k6ZtheNYUn9QdHbWif2LxZkvqkWe9mSY/F8t7R31mviGXPiT4rXaa/jUdO7axSHse85V6R1LuYZcz5WDFtsH38szUfj+39l72Rks5yztWOLg3pIum5/O4SysFIld373FrSf4MgWBt77j/R80UZLekU59yOzrkdJXVT2AGUJEWXtZwraUgJXvs4SYtj69aP1ru8mPWOlbSLpL+X4DVQOiOVvzYWd46kvwdBsD62rUVB9IkTWeRt6y5J1yg8EfP3I+O60eVR3yv8Bv4Qhd+go3yMVP7a2FGSFF1S+aVzbnzsUriDJG2RdEZ0WdYHzrm+3vo3K/xFbkWmnXLhZcI1JH0Ue66Fc261wvY5QOEXYyh7+0raEgTBB7HnSnrcKUrraH1JUnRMWppme7tFjwOdc8ujSzlvcM7Fz1EvkzQnCIJFaV7vFheWSsx1zrXPsB9LFXVqi9jGOZIe8Y55KFsjVTmPY9ky52ORdG1QknZxzn0Vte07nXN1Svm6pbK9d/bmKGwU30v6TOFlBk/nc4dQLsryfa4raY333BqFl88V5Q2FJy8ro8dPil1CImmUpOuDIFiX6UWdc+dKaiPpttjTQyXdHwTBZ0WvlXKOpCeLew3kJJ9tTJLknKst6QyFvxCXaFvOudMlVQ2CYEpp9iMIgseC8DLOfSX9TdJXmfYROclnG9tN4WVw3SS1krSDwi8Jts1roLAN7KmwDQ52zp0gSc65NpLaxpYvUvTl1aOSbgiCILVvQRAsC8LLOHeWdJ2k90r0FyJbdRW2rbhijzvFbK+kbWy36L8nKvzy4HhJf1B4Waecc7tLukDS/6Z5rask7SWpuaR7JP3DObftcs8S7Uf0xWs7hZcSo/xU1uNYiaU5H8vUBt9TeMlqU0kdFF62fke2r5uL7bazF31j9JzCa8rrKPwg2VFhbRMSItv32Tn3bKyAtmcRi6xTWJMSV1/hpXNFeULh5Uv1ouWWKqyBknPuVIWXH6StY4iW+53Cy5e6BFHNXfQNeCdJdxazbm1JZ4oPsHJTCdrYNv8j6TtJs0uyreibxeFKX2dX4v0IguBDhd9yjvHnIXeVoI1tlPRgEAQfRF8a3Szp5Ng8SRoSBMHG6JeXiZJOjvZ7jKT+QRBsyfD37SDpH5JeC4LglqKWCYLgO/1cN8qNgMpeVm3Cu9lEixy3t60NDQ+CYHUQBJ8ovEpgWxsbqbB9+Sf2kqQgCP4dBMHaIAh+CMJaqLmxdUu6H70kvRIEwcdFvQZyV8mPYyX9G34n73xMytwGgyBYEQTBkiAItkbt60qFHc4Ks9129hQWDLeQdHf05qyU9KCyfONR6WX1PgdB0CX4uYB2QhGLLJa0l3Mu/s3RIfrlz/nbHKrwevT10cHlb7HX7iipTXTp0wpJ3SVd6px7ZtvKzrnOku6VdGoQBG/HttteUktJy6J1B0jq5pzzC4tPV9gBmJVm/5C7fLexbYq6BGmxpIOdcy723MHR860UtqGXozb0lKSmUXtsWcy6Ramm9DdPQG7y3cYWKayTSr2EN89/btt0fYXfgE+K2ti2G0l95pw7VpKcczUVfrP/mcJfbzKppvCS9F/c0Rg5+0BSNedcq9hzadtEYG82sayIRRZH60uSoi+X9k6zvfcVXlqZro11lDQi9lkpSfNc+ruFBgpr1Yvaj70U1n9+4K3z/8WXouWtMh/HipXhfKwo8TZY1LyK7X/ls2Aw3w+FhbgDFX6ANJQ0RbFCXh7JeJT1+yzpNYU/39dS2JlaLalxmmVnKrxMYIfoMUZR0brCX/uaxB6TFP5S1yia30HhpZ/HFbHd2t66t0l60t8PSf9S+I1o3t+HJD/y2cai5XdTWDe1t/d8DUmfSuqv8ASnX5RrRPsab0P/I+mLaLpqpnWjbfeRtEs0fYDCD9g78v1eJPWR5+PYuZI+VniZUm2FVyw8Gps/R+EvMTUl/Urh3fY6KjzZibex/6fwRKd51L6qK/xF72kVcdOWqE3up/DEqHH0um/k+71I6kPhL7KPK/zVpa3CS+Jal3JbjaP1u0Vt7FaFv9ymW/4RSdMUfi7upvDStz9F83bx2lGgsP5qh+jfwknRa1RTeBfR9YpuNKOfLxk8Nvq7xkua6L320dE6v7hpC48yb2OV+ThWI9rOXEnnRdNVonmZzseKa4PHS9ojOh7urvC88MEK/f+e7zc+z43uUIW/eKxSeIOBJyTtmu/94lG532eFv4bMUnhJwPuSOsXm9ZS0OJb3VHgys1LhL2zPSWqVZrsPKXY3zuiAsEXhpQrbHs+mWXewvLtxKjyh2qLYXct4JK+NRc9dLenlNNs6TOHtpTcqrCE9LM1y7eXdMSzTugq/kf0q+lD7ROFd1Wrl+71I6qMStLEbFA7P8Y3C2rodY/OaR8e2dQpP5i7I8JqBoo6dwhqpQOHt8OPHuWOj+X9WeHK2XuHNXSZK2iPf70VSHwp/eXk6+v+9TFKPHLfXSWGnbWPU1lrG5v1N0t9iuX70/q5VeGfO/1Wau2IqdjdOhZ3K16P1Vis8+T/BW75H9PesVzicTCNv/jjFTvp5lGsbq8zHsVlR24o/2kfz0p6PFdcGFd5E7/PoOLdc4b0aKvSLBRftCAAAAAAgQbbnmj0AAAAASCw6ewAAAACQQHT2AAAAACCB6OwBAAAAQALR2QMAAACABKqWzcI777xz0LJly3LaFZS1Tz75RN9++226QR0rJdpYYaGNoSIsXLjw2yAIGud7P0qKNlZ4Cq+N7RS0bNEi37uBLCx8860Ca2McxwpNuuNYVp29li1basGCBWW3VyhXbdq0yfcuZI02VlhoY6gIzrlP870P2aCNFZ6Ca2MtWmjBK7PyvRvIgqvTsLDaGMexgpPuOMZlnAAAAACQQHT2AAAAACCB6OwBAAAAQALR2QMAAACABKKzBwAAAAAJRGcPAAAAABKIzh4AAAAAJBCdPQAAAABIIDp7AAAAAJBAdPYAAAAAIIHo7AEAAABAAtHZAwAAAIAEorMHAAAAAAlULd87AAAAAPy0aLZ94uP3cttg/YYmVj2+e27bAwoQv+wBAAAAQALR2QMAAACABOIyTgCoIBs3bjR51apVJvfu3dvkGTNmmNyzZ0+T99prr9T0b3/7WzPvgAMOMLlevXpZ7SuSacuWLSbPmzfP5PHjx5t8zz33lHjbp512msljxowxuVmzZiXeFpIr2PKjyWtO6Ziafn7RCjNvzppNOb1Ws5pVTR445nOTq551eU7bR2H45JNPUtPnnnuumTdr1iyTgyAwuXt3e+nvww8/bHLNmjVz38Fyxi97AAAAAJBAdPYAAAAAIIHo7AEAAABAAiWmZs+/hnbmzJkmP/LIIxW2L7fddpvJl1+e3TXhP/zwQ2ra/7umTJli8gsvvGDykiVLitwOKt7WrVtT0xs2bDDz/vWvf5m8dOnSjNtat26dyTfeeKPJ++yzj8mjR482uUOHDiZXqcL3PBXhrbfeMrlXr14mx/+9FsV/nx5//PG0y950000m77bbbiZ37NjRZL+N7LDDDhn3BYVp5cqVJg8aNMhkv67O55wr8WtNnTrVZL99v/eevY0+x6FkCjbbc48fL+1h8qiJb5j8ySZbR1qWvvjhJ5Ovu+CvJg9dbeumq104tNz2BeVn+vTpJt91110mz507NzW9efNmM88/f/roo49Mnjx5ssnz5883eeTIkSZ37dq1+B2uYBxpAQAAACCB6OwBAAAAQALR2QMAAACABCrYmr0//elPJj/00EMZl8+m7iBXV155pcnHH3+8yYcddljG9RcsWJCavuiiizIu64/vsffee6edh7K1aZMd/2f27NkmP/3006npe++9N6fX8uuv2rZta7Jfl9O5c2eT16xZY3KdOnXSvpZf6+lve+zYsanpL774Iu12IPXt29fk4mr0/Gv9f//732dcPl478Mwzz5h5n376qcl+/e+kSZNMfumll0w+/PDDTa5evXrGfUHl8M0335h85JFHmuy3i/L08ccfm+zXwuy7774Vti+oQGvtZ8blD81Ps2DF+37LVpMHXWXv5zCk4Y4mMw5f5XT77beb7Nesr1692uRRo0alps8880wzr0GDBiZ/+OGHJp944okm+8fQ008/3eSffrJ1opUBv+wBAAAAQALR2QMAAACABKKzBwAAAAAJVDA1e369SXE1er6BAweavHDhwtR0u3btstrWuHHjTF6+fLnJQRCYHB9vTfplbUy3bt1M9sdkizvrrLNMfvDBB01m3KKy8+WXX5p89913m+y/j6+//rrJ8XbgXxPepUsXkw866CCTTz31VJObN29ucsOGDU3euHGjydOmTTO5atWqSscf++riiy82ec6cOSa3atUqNb1qlR2jCNarr75qsv/vs0WLFiY/9thjJhc39t0f/vCH1PQtt9xi5vn1Uk888YTJQ4YMMfnoo482ediwYSb/5S9/ybgvqBz8+hG/vsSv1z3qqKNMPvvss02O15j74zz6Y8r6tSr169c3mRq9ZAh+tPXqWwb2NnnyxIUqKyc3su3Vv/vCSc/eZ3LwyFiTB417xeTVXs2en4ddPMrkgU1+rpev2j5zDTXKj3+u4dfo+fclGDFihMnx+19kOh+Sij8fu//++zPvbCVEzwAAAAAAEojOHgAAAAAkEJ09AAAAAEiggqnZ88erKs7gwYNN9se+i9fOZDt+lF9vdcQRR2Rc/oQTTjDZH5/NH9esXr16qelrr73WzLvssstMrlatYN7CSs8fW+WYY44x2R9vztemTRuT42Oz9O/f38zbaaedSrOLafm1XZ06dTLZH3tr9OjRqWm/7tP/O/1r388777zU9HHHHZf9zm5HBg0aZPLQoUNNrlWrVpm9lr+tX/3qVyZfd911JvvHrUsuucTk9evXl9m++XXM69atM9k/Bpfl/5ek82uL//Of/2Rc/o477jA5/u+5OAcffLDJvXr1MvnAAw80ee3atSa/8oqtn/KPsSgMfo3epePm5rS9eF3eiWccYuZVu22Cya5qMec8w22b+s3j+5v87HeZj2tf/OCNkbZ2TdELokL552f+OHo+//OtuDq9OP8eCFOmTDHZ/zwrBPyyBwAAAAAJRGcPAAAAABKIzh4AAAAAJFDBFHz5Y88554+2YvljRvk1IbmMR9esWTOTd999d5P9cff88T98/lhEV199dWqasa0qjj8Wo1+7Fh9fTpJmzpxpcqNGjUyuUaNGGe5dZv71634tnd8m47U0TZs2NfOWLl1qst++4/92GNcxM79NTZ061WS/vsqvs3vttddMbtKkSan3xa9ZOOWUUzLmXMXbnF/3Ga8ZlaSuXbua7NdIID3/86W4Wku/ri4b/jh6EyZMSLNkaMuWLSaff/75Jj/66KMmF1f/jooRbLb3Efjp+j4m/+/983La/hH1aprc5fXpqekqTfbKadu+XavndpobfPVFGe0JcuEft/wxav1zHP885thjj01NF1e/56/73Xffmez3P9q3b59xe5UBZ2oAAAAAkEB09gAAAAAggejsAQAAAEACFUzNXrb8mr2yrC3yx4j69ttvs1rfr0/xx+I69NBDS7VfyM1TTz1lst9m/PqSXOqnsrVq1SqTb7rpJpPvvPPOjOv7+/rwww+npk877bQc9w7p+OPF+e/bGWecYbJfdxAfq1GS3nzzTZOzGTsoV5s3bzb566+/NvmZZ54x+aqrrkpN+zXX++23n8nXXHNNWezidqlhw4Ym161b12T/82r8+PEmH3TQQRnXj/OPFdOnT0+zZNHee+89k/1x9mbMmJFxPirG1oX/Mrn/XbOyWn+XGva4dMVJ9t977WG3mVzWdXpxR7xi638fatUhq/X7978vNT26z6AMS6I81a5d22R/DOoLL7zQZH/80LZt26am99/fjr2YLf/c8NZbb81pexWBX/YAAAAAIIHo7AEAAABAAtHZAwAAAIAESmzN3gMPPGBy3759S72tJUuWmByvRZGkjRs3ZlzfH1to5MiRJtesacecQX744yd+9dVXJp955pkm+2Ok+eMlZmPTpk0m++O6+GPd+eO8NGjQwORevXqZfPvtt5tcrVpi/+lXal26dDH51VdfNfnwww83efHixSY/++yzJmczNt4333xjsl8H6vNrWJ988kmT/frBTPzxBv3xQ/26M5ScX4+72267mezXyY0dO9bkBQsWmOyPcThq1KjUdHE1ev5xKggCk+Pje0q/HCOwQwdbT/Xiiy+aHB8rC+VoU+ZzGl/tqvbz6NrhvU2udt7gHHcIsPxzHL8eftmyZSZPnDgxNT148OCM2/bHgfX5YxO3adMm4/KVAb/sAQAAAEAC0dkDAAAAgASiswcAAAAACVQwhTv+WHRDhgzJuPzQoUNN9seratWqVWraryPwa1X69etnsj9mlI8avcLkX6dd3BhoPXr0MHncuHEmN2/ePO1r+du64oorTPbboG/EiBEZ99WvnUHlFD8OSb+sQ/DHdjz77LNNnj17dmrar4+aNGmSyffee6/JxdXsFccf469Tp04m33zzzalpfyy3ihwfcHvz97//3eTu3bub/M4775j8+uuvm+zX/GXSu3dvk++66y6T/Tbp17fPmTPH5AEDBpicaxtFesHKz1PTW5+faObd2Pcuf3GjZS176nj52EtNrvr7/rntHFAMfwzb/v1tm/PPqeJ9hlNPPdXMmzVrlsn+uZx/TwR/fiHglz0AAAAASCA6ewAAAACQQAVzGaf/k+y0adNMfuONN0z2bzPerl07k+OXOPm3EL/ssssy7kvjxo1NPu+880y+9tprTeayzcLg/7Q/fPhwk6+88kqTn3vuOZOvueYak++7777U9Ny5c828q6++2mT/Uiqf/1r+Lcj9SxpQGGrXrm3yQw89ZPLzzz9v8tdff22yP1RDLvbcc8+M2W9zl156qcm5DD2CsrP//vub/NZbb5k8depUk//4xz+a7A+HkIk/LJH/WXrUUUeZXLduXZO7detm8umnn26yP8QMys7WV34+h7rkvJFZrdu6tj2nqcyXba7vd2G+dwEVwD9vj5cRSHY4K78/kO3waf4QSoWAX/YAAAAAIIHo7AEAAABAAtHZAwAAAIAEKpiaPf9a/5deesnkiy66yGT/9tNfffWVye3bty/xa3fo0MHk2267zeRDDjmkxNtC5eXfDt6/le/WrVtNHjhwoMkTJkwwecaMGalpv9bK16ZNG5P9Gr2GDRtmXB/J4A+5Ea8zyFaVKva7PH+4Dr9eqlGjRibXqVOn1K+N/PHr3PzcsWPHUm/P39b8+fNNPu6440w++uijTR41apTJfs2p32ZROXWeOyXfu5ASbLA1pqu6nmzyoNeW5bT9UY9cldP6yA+/zm7YsGGp6eJq9Pwhkfz7cBQijqwAAAAAkEB09gAAAAAggejsAQAAAEACFUzNnq9evXomjx8/3uRDDz3U5KuuKv111w888IDJu+++e6m3hcLh1/ANGDDA5ClTbN3Ca6+9ZvKXX36ZmvZrUfxx9vwx+vzx15AMmzZtMnnQoEEm33777SbvuuuuJjdr1szkeBuLT0tStWr28O6Pi4ft08MPP2yyP66ef+x58cUXU9O77LKLmTd06FCT/XEiX331VZP9cfT8mj+/vaP8vH/d30q/co38jR380wRbe/z53ZNNvnWRPQ7mLI9/K0qvX79+Jv/1r39NTRdXs+fX6CVhDFl+2QMAAACABKKzBwAAAAAJRGcPAAAAABKoYGv2fMuXLzf52WefLbNtX3DBBSb741W1bt26zF4LlcfmzZtNXrRokckfffSRyf4YVPE6PX/eiSeeaDI1etuHefPmmeyP2elbsGCByU2bNjU5Pn6jP++nn34y2R+zzx9XD8nkt4N77rkn4/J9+vQx+de//nXaZe+//36T/ePcgw8+aLL/OX3SSSeZvHDhQpP9ummUnVH//SY1XVUuw5K/FLxj69PVZK+s1t+67F27vdXpx6Gd1Nm2x/c32M/lbzfb9p2tWlXs337zOUeaXOWkXjltHxXD/2xt27ZtqbfVokWLXHen0uGXPQAAAABIIDp7AAAAAJBAdPYAAAAAIIEKtmZv/fr1Jh9wwAEmb9iwIeP68XqVHXfc0cxbunSpyc8//7zJdevWNfmJJ57IvLMoSH6NXqbaFUlq1aqVyVu3bk1N+23qhhtuMHn69Okm16zJ2D5JNGvWrIzzjzzS1ovsvPPOGZdv3Lhxatof46x3794m33zzzSYPGzbMZH9cPiTD22+/bfI777xjsj924/Dhw0u8bb9Gr0OHDibHj4HSL8f484+x/r764+Wi7Lgs6/Tixv7hOpPP7/F0VutPmfymyXPWbEqzZPkbctqBJte8+8k87Qmy8fLLL5vs1//6/GPV9oZf9gAAAAAggejsAQAAAEAC0dkDAAAAgAQq2CKNk08+2eTiavT8uryZM2empv26gt/85jcmb9pkryefO3euyStWrDC5SZMmGfcFldOHH35ost/GinP33XebHARBarpz585m3uzZs032x0Dzx0xDYfKPHVOnTs24/NChQ02uXr16xuXjdQjF1ZTeeeedJvfv39/k3XffPeP6KEzx41BRunbtanKNGjVK/Vo9e/Y02a+t92v2/H179107/ho1e+XnkDo/v89vr/8xq3Xf9ca6u+y+eWmWzL/ujeubfMxLE0x2zfetyN1BKfk1el26dDH5hx9+MNk/5x89enRq+oorrjDz/HP4yZMnm9y+ffus9rUy4pc9AAAAAEggOnsAAAAAkEB09gAAAAAggQq2Zm/+/PkZ5zdo0MBkv0aqdevWade9/vrrTb722mtN9q/vXb16tcnU7BWmp59+2uSVK1dmXH7x4sUm77uvvfbfr1fJZOzYsSYPGTKkxOui8vKPDUuWLDF5zz33NPnoo48ut33xx9GrWrVqub0WKo+KHF/qrbfeMnnAgAEZl99hhx1MPuaYY8p6l5DGefdemZq+pMeNedyT3AzrvJ/J1RvZcZBrjhhnsmu4a7nvE3K3du1ak3/3u9+ZvHHjxozr+32EvffeOzXt31/BP6dftWpVSXezYPDLHgAAAAAkEJ09AAAAAEggOnsAAAAAkEAFU7M3Z84ck7ds2WKyP0bUpZdeanKmGj1/bB9/PCpfrVq1TM5lXCJUHv6YT37u3r27yfvtZ2sFstm2b926dSXeFgqHX7/rH1v+/Oc/m+wfp6ZNm5ZxfjaOO+44k5s1a1bqbaFw+J9Xfq2mXxvjH6sy1fytWbPG5AsvvNDk4o5r/pi2jPVYcap0ODM1PWqm/f8e/Nueb4264QmTN3ttZNkmez7WpIZtYyt+/Mm+ttek9qyVeTzRuH4925hc/fbxJrtqnI8lQZ8+fUwuro7OH8M2XqMn2TFvv/zySzPPP+adf/75Jd7PQsEvewAAAACQQHT2AAAAACCB6OwBAAAAQAIVTM1eu3btTK5SxfZT7733XpPbtm1r8oYNG0weN+7nsVduuukmM8+/NtivyZswYYLJe+21V7rdRgHxa1P8PG/ePJO///57k/1amKeeeirttnzdunUr8X6icJ1yyikm+2N4Llu2zOTDDjvM5Ouuu87kffbZJzX9wQcflMUuImH2339/kzt37myy/3l20EEHmXzUUUelpl944QUzb9SoUSb7x0TfxRdfbPLIkSMzLo/y4+o1Sk1XPfJkO9PLl/95mMnBys9N/r5XD5Pr3jHc5HWXX2lylVr21LPeUy8Wv8PYrkyePNlk/xyqX79+Jnfp0iXj9jZv3pyajtfvFbXtJOKXPQAAAABIIDp7AAAAAJBAdPYAAAAAIIEKpmavuGtq/TqEXFSrZv+3TJo0yeSuXbuW2Wuh8jjwwANN9sd8Wr58uck9etg6hc8/t3UMixYtSk377feBBx4wuU0bO3YQkqlFixYmf/rppyb7bWrGjBkm33DDDSV+Lb+GdMSIESVeF8nVs2dPk//5z3+aPHDgQJPjY1AV9zlcp04dk/1x92699VaT/dp7FAa3U3OTG0yfnXH54uYD2erbt6/J/uedL34ev2LFCjOvdu3aJsfrlJOCIy0AAAAAJBCdPQAAAABIoIK5jPOss84y2b+0Mls1a9ZMTfs/2Q4bZm8zfOSRR+b0WigM/q17Bw0aZHKfPn1Mfu6550q87XPPPdfk7t27mxxvj9h+1K9f3+Rp06aZPHjwYJOHDh1qcrzd+LexP/XUU01u2rRpKfcSSXLGGWeYPH/+fJP9dpTp0k3/uOZftsnl6QDKQ6dOnUz2hy366KOPTB4zZkzabZ1//vkm16pVK8e9q3z4ZQ8AAAAAEojOHgAAAAAkEJ09AAAAAEiggqnZ82857tcRTJw40eQTTjjBZL9O4eyzz05NJ/H6XOSuV69eJh9//PEmT548OeP6F110UWrar8nzh/cAiuLX7PkZyJZ/7LnjjjsyZgCoaFu3bs1p/X322cfkN954I6ftFTp+2QMAAACABKKzBwAAAAAJRGcPAAAAABKoYAqHWrVqZfKECRMyZiBXVatWNXmPPfYwecCAARW5OwAAAEBW+GUPAAAAABKIzh4AAAAAJBCdPQAAAABIIDp7AAAAAJBAdPYAAAAAIIHo7AEAAABAAtHZAwAAAIAEckEQlHxh576R9Gn57Q7K2B5BEDTO905kgzZWcGhjqAgF1c5oYwWJNobyRhtDeSuyjWXV2QMAAAAAFAYu4wQAAACABKKzBwAAAAAJRGcPAAAAABKIzh4AAAAAJBCdPQAAAABIIDp7AAAAAJBAdPYAAAAAIIHo7AEAAABAAtHZAwAAAIAE+j9NnSdkahb0lwAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA3sAAACOCAYAAACIehHUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/NK7nSAAAACXBIWXMAAAsTAAALEwEAmpwYAAAdBUlEQVR4nO3deZRU1b328WfLKPNlXgLSwg0ORNSEGwSDKEG9UTREBTsSX0mcoyFXryaoCc5BNCEmKlmY9SI4MeR1Ql+nZImCKCJgQFHACcQhKoPIPLnvH+fQOb99u6u66K6uqtPfz1q1PE+fc6p2WZtTtavO72znvRcAAAAAIF32K3QDAAAAAAC1j8EeAAAAAKQQgz0AAAAASCEGewAAAACQQgz2AAAAACCFGOwBAAAAQAox2AMAAACAFKrXgz3n3KHOueedcxudc+86535Y6DahdtX2a+ycO9I5t8g5tzX+75EZti1zzj3lnNvgnPunc+4u51zDxPrBzrnFzrmvnHPvO+cuDPbv4Jx7KG77Bufcg4l1XZxzjzvn1jvnPnLOXRzse49zboVz7mvn3KiaPGdkV8r9LLHdZOecd879e+JvDzjnPo33XemcO7+KfcfG+w6pwdNGBqXcx5xzZzvnVjvntjjnHnPOtU2su8w5t9A5t8M5N6WSx/6ec2553M7ZzrnuNXneqFqx9jHn3EDn3Obg5p1zZ8Trmzjn/uCc+yTef6JzrlHivl9wzm1P7Lsisc455651zn0Y99/pzrlWNXneqFqx9rF4fQPn3M1xP9rknHvdOdcmXufidR/HbX/BOdc7sW+Vn8mcc73idV/E6591zh1ck+edM+99vbxJaihppaQrJDWQNFjSFkm9Ct02bsX5GktqLGm1pMslNZE0Os6Nq9j+KUlTJDWV1FnSG5JGx+saSdoo6SJJTtJ/SNos6YjE/nMlTZDUOt7+qMS62ZLuiP9+hKT1ko5PrL9U0vckLZQ0qtCvRZpvpd7P4u2+K+lFSV7Svyf+3ltSk3j5EEn/lPTtYN+e8WN+ImlIoV+PNN5KuY/FfWiTpGMltZD0kKTpifs+XdIwSX+WNCV43PbxfQ+PH/t2SfML/Xqk8VbMfaySbY+L+1TzOF+n6P2yraQOkuZLuiGx/QuSzq/ivs6VtFxSt7h/Pi5paqFfjzTeir2PSbpZ0vOSusfHsm9KahqvG6HoPa5H3PZxkhYn9q3yM5mk70g6L+6fjSTdJGl5nf6/L/SLX8BO9834Dckl/vacpJsK3TZuxfkaSzpR0sfB/X0o6T+r2P5tSScn8u2SJsXLnRR9sG6WWP+apB8lHmuVpAaV3G+LeN8Oib/dI+n+SrZ9SQz26GdV9LM4N5T0uqQ+CgZ7weMcLOlTSSOCvz8j6eS4vzLYo4+Fx7LfSnoosa6npJ2SWgaPcbP+92DvQkkvJ3JzSdskHVLo1yRtt2LuY5Vse6+kexN5oaThiXy2pDWJ/IKqHuz9P0lXJfIASduT/Zlb+vuYpH+L29azin1/JWlmIveWtD1ervZnsnhd23j7dnX1/75en8ZZib0jeaRXTV7j3pKW+vhfa2xp/PfK3CGp3DnXzDnXRdL3FX0wlvf+M0nTJP0kPnWgv6Jvk16K9z1a0gpJU51z65xzrznnBiWeQ/K/NX1eqH2l0s+k6FvROd77pZU+keiUqK2Kvv3+VNG3o3vXDZe0w3v/VGX7Iq9KpY/1lrRk7x15799TNNjrVc12JvfdIum9DO1E7SqKPmYa5FxzSWdKmlpJW5PLXZ1zrRN/G+ecW+ucm+ecOy7Lvk0kfaOKdqJ2FUsfO1zSbklnxqd4rnTOXZrYd7qknvEpmY0U/SK8d99cP5MdK+mf3vt1WZ9hLanPg70Vkj6XdJVzrpFz7kRJgyQ1K2yzUItq+zVuoeiUoqSNklpWsf0cRQedryR9pOjbx8cS66dJGitph6JTUK713q+J13VV9K3VbEWnG/xe0uPOufbe+02S5kn6jXOuqXPuW5LOqMHzQs2UbD9zznVTdPrd2Koa473/WfzYAyU9Et+PnHMtFf1q84tqPzPsq5LtY/vwWDVpJ/ZdsfexvU6XtFbRaed7PSPpFy6qc++s6HQ+6V9t/5Wi0++6KPrF5QnnXM/EvufH9Vyt422T+6L2FHMf66qoZKaXpIMUfaFwvXPuhHj9p4q+wFqh6OyC4Yq+KFUun8mcc10l3a3oVNY6U28He977XYrqBE5RVIfy35JmKuoASIFcX2Pn3LJEAffASjbZLCks3G6lqHYgvK/9FL2JPKLo1KP2ik4TGB+vP0TRN0X/R9F5570l/dI5d0p8F9skrfLe/1/v/S7v/XRJayQdE68fqeiAtEZRrcsDVT0v5FeJ97M7JN3ovQ/fMMPnuMd7/5KiN8RL4j9fr+g0lVWZ9kXNlXgfq/Zj1aSdqJli7mOBcyXdF/yac4uiU9H/IellRR/gd0n6LH5ur3rvN3nvd3jvpyr6YH5yvO9kRV9WvCBpmaIvWFXV88a+K/I+ti3+743e+23xmS7T9a9+MlZRPXI3RTV/N0h63jm3d0CX9TOZc66DotNWJ3rvp1X2nPOl3g72JMl7v9R7P8h73857f5Kib34WFLpdqD25vMbe+97e+xbxbW4lmyyT1Mc5l/ypvk/891BbSQdKuit+g1mnqM5g74Hjm5JWeu+f9d5/7b1fIen/KzqtQIpORfDBfVZk7/1q7/1Q730H730/RQcu+m6BlHA/+56k2+PTVv4Z/+0V59zZVTzVhopqrvbuOzqxbzdJM51zv6piX9RACfexZYouWCBJcs71UHSa3MpqPO1w3+aK+l9l7UQNFXEfk1RxJsJxku4L2rLNe3+Z976L976HpHWSFnnvv67qqSo+5S7us9d578u8913j9n0c31DLiriP7S1jSH7uSi4fKWmG9/4j7/1u7/0URYPFw+K2ZvxM5pz7N0UDvVne+1sqe7555YugaLNQN0Wdoqmin1qvlPSB4ivPcUvHrTZfY/3ryk+/UPRh5TJlvvLT+5LGKPqA3EbSo4ovVKDoA8tmRVejcnF+V9KF8fq2kjYo+hazgaJTCtZLah+vP1TRqQqNJf1Y0WktHYK2NlX0DeYF8fJ+hX490nor4X7WUdFpwntvXlG96P7xunJFp8o0kHSSoiunnRbv2y7Yd42iU1taFPr1SOOthPvY3tOmBir6Rv0B2atxNoyf1zhJ98fLDeN1HRSdlnVG/Pfx4mqc9a6PJba5RlF9cbhvF0kHxP3v6PhYdGK8rk187Goa3/dIJa4Aqei9tme872GS3tzbd7nVrz6m6DTPSfF9HarolNPvxeuuU3QaZydFP5SdE/ejNvH6Kj+TKfq1cYGigWZh/r8X+oUvcKe7XdEH6s2SnlYVV6HjVrq32n6NJR0laZGin/wXy06HcI2kpxP5SEWnhmyI/+HPlNQpsX5E/MaySdHP/eOVGJAp+nD0Rtz2hZIGJtb9l6Qv4oPNS5L6Bu18QdEH9+TtuEK/Hmm9lXI/Cx7X7227og/aL0r6UtGH9TckXZChzavE1TjpY5Ufy85WdJW8LYoubd82se76So5V1yfWD1F0caBtcRvKCv1apPVWzH0s3ma5pPMqeZxj4+PPVkU1VSMT6zooujrspvhYNl/SCYn1veJ9tioaKFxR6Nchzbdi7mOKvjR4Jm7b+5IuSqxrqqjW7lNF74eLlbjqpzJ8JlP0pb2P121O3A6sq//vLm4IAAAAACBF6nXNHgAAAACkFYM9AAAAAEghBnsAAAAAkEIM9gAAAAAghRjsAQAAAEAKNcxl4/bt2/uysrI8NQW1bdWqVVq7dq3LvmXxoI+VFvoY6sKiRYvWeu87FLod1UUfKz30MeQbfQz5VlUfy2mwV1ZWpoULF9Zeq5BXffv2LXQTckYfKy30MdQF59zqQrchF/Sx0kMfQ77Rx5BvVfUxTuMEAAAAgBRisAcAAAAAKcRgDwAAAABSiMEeAAAAAKQQgz0AAAAASCEGewAAAACQQgz2AAAAACCFGOwBAAAAQAox2AMAAACAFGKwBwAAAAApxGAPAAAAAFKIwR4AAAAApBCDPQAAAABIIQZ7AAAAAJBCDPYAAAAAIIUY7AEAAABACjHYAwAAAIAUYrAHAAAAACnEYA8AAAAAUojBHgAAAACkUMNCNwAoVd57k2fPnm3yJZdcUrG8cuXKjPc1ZswYk6+55hqTW7RoYbJzrtrtBIC9wuPWm2++afKcOXNMXrJkScXya6+9ZtaVl5ebPGrUKJM7deq0r80EgH22detWk2+66aaK5eQxTZKefvppkxs1amRyeNw74ogjaqOJdYpf9gAAAAAghRjsAQAAAEAKMdgDAAAAgBSiZk/Stm3bTJ41a5bJixcvNnnmzJkmn3vuuSZff/31tdc4FI2vv/7a5ClTpph8wQUXVLnvfvtl/l7ltttuy5g///xzk9u1a5fx/lCawmPRk08+afKiRYtMfu655yqW//GPf9ToscNarh/96Ecmn3/++SYPHjy4Ro+HurFr1y6Thw8fbvLjjz++z/cd9rnwuDVp0iSTzzjjDJOpPS5N4XvhmjVrTJ42bZrJ48ePN/nLL780Odv7Y9Ldd99t8sUXX1ztfZFeCxYsMPmss84yefXq1VXuGx6Hdu/ebfKgQYNM/vDDD01u1apVtdtZKPyyBwAAAAApxGAPAAAAAFKIwR4AAAAApFBqa/bC2pZXX33V5PPOO69iuV+/fmbdp59+avKGDRsyPtbNN99s8scff2zy1VdfbXKPHj0y3h+KU1iXkKlGT5IaNvzXP68bb7zRrAv7QHIOGElatmyZyXPnzjV52LBhGR8bpSGcC+i6664zecKECdW+r5rWP4X7z5gxw+THHnvM5Lffftvk7t271+jxUTv27Nlj8oMPPmhyrjV6zZs3r1gO6/927txp8vr1600O6wNfeeUVk48++miTw7Y3aNAgp7YiPzZt2mTyZZddZnLYx7IJa/RyOXZdddVVJq9duzbj+iZNmuTUNpSGDz74wOTvfve7Jod1d8m6utGjR5t14fUXwv4Yfva7//77Tb700kuzN7jA+GUPAAAAAFKIwR4AAAAApBCDPQAAAABIoZKt2du4caPJp556qslhbUA4h9QNN9xQsRye813T2pfJkyeb/NBDD5l8zz33mDxy5MgaPR7yI6w/GTp0aMbtGzdubHLyPPBwzpdQ+/btTR4yZIjJyfnUJOm0004zOZd5ilA44fxTYd1nOM9eNn369KlY7tWrl1kX1tVkE9ZXffHFFybv2LHD5O3bt+d0/6gbjzzyiMk/+clPMm4fHnvC2sy+fftWLG/ZssWsu/DCC01++OGHMz5WeBx75513TH7qqadMnjp1qsnhMRb58dVXX5l84oknmvzaa6+ZnO0z0znnnGNys2bNTE4ee8La38svv9zkJ554wuRwXuNjjjnG5OOPPz5j21AawmPPqFGjTA5r9MLX/dFHH61YDufFC+dqDD/Djx071uS//OUvJlOzBwAAAAAoCAZ7AAAAAJBCDPYAAAAAIIWKtmYvrAcJz+VfunSpyS+//HJO93/CCSdULOda2xIK5y267bbbTA5rXcI5a1CcwvkT33rrrYzb33XXXSZnqtML56saM2ZMxvueNGmSyb/+9a9NPuCAAzLuj8JYvny5yRdddJHJ2Wr0DjzwQJPDWoGBAwdWLGebTyrsc3/9619NDmtUQ2FdA/OFFofw/eWWW27JuH04t9306dNNzjRfYtjHwvnVwvlCBw8ebHI4j2Q2EydONJmavboRHqcWLlyYcftu3bqZ/OKLL5rctWtXk3OpMQ/n+wznrB0/frzJP/jBD0yeM2eOyeE8yt///ver3RYUzr333mtyOPdweNx68sknTd5///2rvO9169aZ/Lvf/S5jW7LNvV2M+GUPAAAAAFKIwR4AAAAApBCDPQAAAABIoaKt2Vu9erXJI0aMMLldu3Ymh3MJhfNihOf6N2/evGK5RYsWObUtrLl74IEHctofxWnr1q0mP/PMMxm3HzRokMlhH81k3rx5JmeriQjdeeedJo8bNy6n/VE3wuNQOFdQKFlLLEn33XefyR07dqz2Y4f1gOF8VNnqErp06WLyhAkTTG7UqFG124L8CWtTlixZYnLYZ3Kp0csmrOE79NBDTR4wYIDJ4RyA2STfp1F3vvzyS5PDeYrDPvP+++/nrS1hHwvr2//0pz+ZHB5jv/3tb5scPpfy8nKTw3mRURzuvvvujOvD+aoz1eiF1wQJ5wsN55kMlcK8eiF+2QMAAACAFGKwBwAAAAApxGAPAAAAAFKoaGv2Zs+ebXJ4nvXJJ59scjj/VE2sWbPG5LC+6o9//KPJCxYsMDlsa6hDhw41aB3yJZwvccWKFSY3bdrU5HAOqJYtW1b7sW699dac2haeUx7OeYbiEM6/Ex7HQqNGjTI5nE+xYcN9P0TPmjXL5Gw1eqGTTjrJ5Gzz+KEw3njjjYzrw/qSmtTo5epb3/qWydlq9o466iiTc5mPDbXHOZcx//jHP67L5hjh+2x4nAvrnrMZPXp0jduE/MtW7/6b3/wm4/pknd7w4cPNuldffTWntoR1oKWAIykAAAAApBCDPQAAAABIIQZ7AAAAAJBCRVOzF857cdNNN5k8fvx4k6+88soaPd769esrll9++eWMj71o0aKM9xWezx466KCDTB46dGh1mog6Fs5PFTr88MNNPuSQQ/LWlvbt25sc9v9WrVrl7bGx7z777DOTk8eZyoTzhdakRi+sFZ45c2ZO+4fzEl1yySX73Bbkz/Lly03ONi9YXdbohTZv3pzT9r/97W9Nrsm/B+TPYYcdVugmVChk/0bxOPfcc00O63+TcxFnm0cvdOaZZ5o8ePDgHFtXePyyBwAAAAApxGAPAAAAAFKoaM6RmDFjhsnh6VDhKUo7duwweffu3Sa/8847Jv/5z382OXlJ9Pfee8+sy3ZaZjYNGjQwObzMPpcwr38WL15scrZTg0eMGGEyp22WhnB6jsaNG5u8c+dOkydMmGDy1q1bTT7wwAOr/dhTpkwxOZw6JJvw1OHwsvkoDr169TI5fJ3C974nnnjC5LPPPtvkRo0a1Vrb5s+fb/Lvf//7jNs3a9bM5P79+9daW5A/4TQu5eXlBWqJ1LZtW5O7du1q8kcffWRyt27dTC7Fy+jXR8nTMCXpnHPOMTksW8hUxhBOeRS+L4enxoenk9d0jFAI/LIHAAAAACnEYA8AAAAAUojBHgAAAACkUNHU7IW1KqH777/f5Pvuu8/kt99+u9bbtK/C2pnwsq0oTeHlesM+G9affPLJJxXLJ510klmX7ZL8KE1lZWUm9+jRw+TwsvlhLXJYWwyE9tvPfkcbXnI8rH9/+OGHTQ7rVf7whz+Y3LFjx2q3ZcuWLSbfcccdJu/atSvj/mFtTOvWrav92Mif8HUIj1PvvvuuyQ8++KDJZ511lsk1mUIjvD7Dc889Z/KwYcNyur9w+9qsWUX+hLXG4WeqV155JeP+ffv2rVju1KmTWZetPv24446rRguLG7/sAQAAAEAKMdgDAAAAgBRisAcAAAAAKVQ0NXvZzpl96623TA7PIa/JvBdjx441eerUqSZ/+OGHOd1feG4xSsN5551n8u23325yOG/Z8ccfb/K1115r8qxZsyqWc63R69mzZ07bozi9/vrrJl922WUmP/vssyaHc0Jlk6wTDesQPvjgg5zu68orr8xpexSHcJ6x7t27m7x69WqTwzq5sAYqnIesXbt2VT720qVLTf78888ztvWKK64w+dRTT824PQpj0qRJJq9atcrkBQsWmBzWjSbf+yTp5JNPNnngwIFVPna2/jlv3jyTc/3sd/TRR+e0PYpD+DqH88LmcizZs2ePyeFxLNSnT59q33ex4pc9AAAAAEghBnsAAAAAkEIM9gAAAAAghYq2Zm/ChAkmh/O4dO7c2eT+/fubvG3bNpNvuOGGKh979+7dJr/00ksmhzUPoblz52Zcj9Jw0EEHmRzWXoa1BAsXLjT5hz/8Ya21pby8vNbuC4XTuHFjk++55x6TN23aZPLGjRtNDuuHhw4davIBBxxQsbxu3Tqzrnfv3hnbFtZA1Gb/Rd3p16+fydOmTTM5nI8q7HNr167NmGvTgAEDTA7nDERxaNWqlcl/+9vfTA5rjx944AGTw7kdw1yTay4cc8wxJifns5X+d30hEAqvv1AfcKQFAAAAgBRisAcAAAAAKcRgDwAAAABSqGhq9ho1amTy6NGjM+batGTJEpNnz55tcng+eXjOeN++ffPTMNSphg3tP4e77rrL5LA2JtvcLMl5+8aMGWPWzZkzZ1+aiJRp2bJlxhzO3ZhJprrkypx11lkmd+nSJaf9UZzC+vWwpimctyycXzGcn3H//fevWA7f+8LalzVr1mRsW4cOHTKuR3Fq0aKFyZMnTzY5PPbMmDEj4/2F8/Qla4/Dz1tnnHGGyeH78OWXX25yWBcNhN59991CN6HO8cseAAAAAKQQgz0AAAAASCEGewAAAACQQkVTs1dIgwYNyrg+PId83LhxJodzaSEdWrdubXI4t1Au2rRpk3F99+7dTW7WrNk+Pxbqp2XLluW0/ZFHHpmfhqCohPVWp59+usmnnHKKyTt27DA5ORdeeF9XX321ybfeemvGtoTz6aI0hfMjhu9fv/zlL+usLT/72c9MnjRpUsbtw7pToD7glz0AAAAASCEGewAAAACQQgz2AAAAACCFqNmTtG3bNpPDGr1wjr8BAwbkvU0ofVu2bKlYXrVqVcZthwwZYnKrVq3y0SSkzIYNGyqW33zzzYzbhvNIDhs2LB9NQolp0qRJxpy0c+dOk6dNm5bxvgcPHmwytciobRMnTjQ5/PwW6tatWz6bgxKwefNmk733JifnFpWksrKyfDcp7/hlDwAAAABSiMEeAAAAAKQQgz0AAAAASCFq9qphxIgRhW4CStDHH39csbx06dICtgRpdfHFF1csr1+/PuO2p512msnf+MY38tImpNfatWtNXr16dcbtDz74YJPD+dmAXCVr4SXp0Ucfzbh9nz598tkclKBwLsawzjOs2evcuXPe25RvHHkBAAAAIIUY7AEAAABACjHYAwAAAIAUqrc1e++8807F8tdff23WtW3b1uQjjjiiTtoEALl47LHHqr3td77znfw1BKjErl27Ct0EpMyePXtM/uKLLzJuX15ens/mIAXCefbCubXTgF/2AAAAACCFGOwBAAAAQAox2AMAAACAFKo3NXvh3Cw///nPK5bDuX/COpimTZvmrV2AJI0ZM6bQTUAJ2LFjh8lhrUEmI0eOrO3mABn99Kc/LXQTkDLbt283OdsxMJdjJOqnbPPspQG/7AEAAABACjHYAwAAAIAUqjencT777LMm//3vf69Y7tmzp1l3+OGH10mbgL0aN25c6CagBMybN8/k8DLkSZ07dza5ZcuWeWkTUJW5c+ea3L9//wK1BGkxefJkk8NT8ELZ1gP1Ab/sAQAAAEAKMdgDAAAAgBRisAcAAAAAKVRvavbat29f5br58+eb3KZNmzy3BvVBx44dK5bDutBrrrnG5C5dutRJm1B/TJkyxWRq9lBTzZs3N3n48OEmP//88yYzbREKrby8vNBNQJHp06ePyWFtcbg+DfhlDwAAAABSiMEeAAAAAKQQgz0AAAAASKF6U7M3ffp0k/v161ex3LZt27puDuqBZO3nypUrC9cQpEZZWZnJmeaQCutEgZpq3bq1yTNnzixQS1BfDRgwIOP6O++80+SuXbvmszkoQWEfCXMa8cseAAAAAKQQgz0AAAAASCEGewAAAACQQvWmZm/ixImFbgIA1EiPHj1M3r17d4FaAgB179hjjzWZYyCQHb/sAQAAAEAKMdgDAAAAgBRisAcAAAAAKeS899Xf2LkvJK3OX3NQy7p77zsUuhG5oI+VHPoY6kJJ9TP6WEmijyHf6GPIt0r7WE6DPQAAAABAaeA0TgAAAABIIQZ7AAAAAJBCDPYAAAAAIIUY7AEAAABACjHYAwAAAIAUYrAHAAAAACnEYA8AAAAAUojBHgAAAACkEIM9AAAAAEih/wErKTaZYZDxPQAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ] + } + ] +} \ No newline at end of file From 818ac3ec6cf89c3506395dc7bd8866b06f1c52c6 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 13 Mar 2023 14:38:26 -0700 Subject: [PATCH 30/35] applying fixes to PR review comments --- tensorflow_similarity/base_indexer.py | 14 ++++++++++---- tensorflow_similarity/indexer.py | 11 +++-------- tensorflow_similarity/stores/cached_store.py | 11 +++++++---- tensorflow_similarity/stores/memory_store.py | 3 --- tensorflow_similarity/stores/redis_store.py | 2 +- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index bdaa0d46..e0e111d1 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -23,14 +23,20 @@ class BaseIndexer(ABC): - def __init__(self, distance, embedding_output, embedding_size, evaluator, stat_buffer_size): + def __init__( + self, + distance: Union[Distance, str], + embedding_output: int, + embedding_size: int, + evaluator: Union[Evaluator, str], + stat_buffer_size: int, + ) -> None: distance = distance_canonicalizer(distance) self.distance = distance # needed for save()/load() self.embedding_output = embedding_output self.embedding_size = embedding_size # internal structure naming - # FIXME support custom objects self.evaluator_type = evaluator # code used to evaluate indexer performance @@ -157,11 +163,11 @@ def evaluate_classification( query_labels = tf.convert_to_tensor(np.array(target_labels)) # TODO(ovallis): The float type should be derived from the model. - lookup_distances = unpack_lookup_distances(lookups, dtype="float32") + lookup_distances = unpack_lookup_distances(lookups, dtype=tf.keras.backend.floatx()) lookup_labels = unpack_lookup_labels(lookups, dtype=query_labels.dtype) thresholds: FloatTensor = tf.cast( tf.convert_to_tensor(distance_thresholds), - dtype=lookup_distances.dtype, + dtype=tf.keras.backend.floatx(), ) results: dict[str, np.ndarray] = self.evaluator.evaluate_classification( diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 569c5e26..f6c4d8ea 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -102,7 +102,7 @@ def __init__( super().__init__(distance, embedding_output, embedding_size, evaluator, stat_buffer_size) # internal structure naming # FIXME support custom objects - self.search_type = search if isinstance(search, str) else type(search).__name__ + self.search_type = search if isinstance(search, str) else type(search).name if isinstance(search, Search): self.search: Search = search self.kv_store_type = kv_store if isinstance(kv_store, str) else type(kv_store).__name__ @@ -122,7 +122,7 @@ def _init_structures(self) -> None: self.search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) elif self.search_type == "linear": self.search = LinearSearch(distance=self.distance, dim=self.embedding_size) - elif not isinstance(self.search, Search): + elif not hasattr(self, "search") or not isinstance(self.search, Search): # self.search should have been already initialized raise ValueError("You need to either supply a known search " "framework name or a Search() object") @@ -131,15 +131,10 @@ def _init_structures(self) -> None: self.kv_store = MemoryStore() elif isinstance(self.kv_store_type, Store): self.kv_store = self.kv_store_type - elif not isinstance(self.kv_store, Store): + elif not hasattr(self, "search") or not isinstance(self.kv_store, Store): # self.kv_store should have been already initialized raise ValueError("You need to either supply a know key value " "store name or a Store() object") - if not self.search: - raise ValueError("search not initialized") - if not self.kv_store: - raise ValueError("kv_store not initialized") - # stats self._stats: DefaultDict[str, int] = defaultdict(int) self._lookup_timings_buffer: Deque[float] = deque([], maxlen=self.stat_buffer_size) diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 2afcdf80..02c987cb 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -31,7 +31,7 @@ class CachedStore(Store): """Efficient cached dataset store""" - def __init__(self, shard_size=1000000, path=".", num_items=0, **kw_args) -> None: + def __init__(self, shard_size: int = 1000000, path: str = ".", num_items: int = 0, **kw_args) -> None: # We are using a native python cached dictionary # db[id] = pickle((embedding, label, data)) self.db: list[dict[str, str]] = [] @@ -53,6 +53,9 @@ def __reopen_all_shards(self): for shard_no in range(len(self.db)): self.db[shard_no] = self.__make_new_shard(shard_no) + def __get_shard_no(self, idx: int) -> int: + return idx // self.shard_size + def add( self, embedding: FloatTensor, @@ -72,7 +75,7 @@ def add( Associated record id. """ idx = self.num_items - shard_no = idx // self.shard_size + shard_no = self.__get_shard_no(idx) if len(self.db) <= shard_no: self.__add_new_shard() self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, data)) @@ -105,7 +108,7 @@ def batch_add( idx = i + self.num_items label = None if labels is None else labels[i] rec_data = None if data is None else data[i] - shard_no = idx // self.shard_size + shard_no = self.__get_shard_no(idx) if len(self.db) <= shard_no: self.__add_new_shard() self.db[shard_no][str(idx)] = pickle.dumps((embedding, label, rec_data)) @@ -124,7 +127,7 @@ def get(self, idx: int) -> tuple[FloatTensor, int | None, Tensor | None]: record associated with the requested id. """ - shard_no = idx // self.shard_size + shard_no = self.__get_shard_no(idx) embedding, label, data = pickle.loads(self.db[shard_no][str(idx)]) return embedding, label, data diff --git a/tensorflow_similarity/stores/memory_store.py b/tensorflow_similarity/stores/memory_store.py index 6792cf4b..fbdc42c9 100644 --- a/tensorflow_similarity/stores/memory_store.py +++ b/tensorflow_similarity/stores/memory_store.py @@ -207,6 +207,3 @@ def to_data_frame(self, num_records: int = 0) -> PandasDataFrame: # forcing type from Any to PandasFrame df: PandasDataFrame = pd.DataFrame.from_dict(data) return df - - def get_config(self): - return super().get_config() diff --git a/tensorflow_similarity/stores/redis_store.py b/tensorflow_similarity/stores/redis_store.py index 2cad7610..4fd91418 100644 --- a/tensorflow_similarity/stores/redis_store.py +++ b/tensorflow_similarity/stores/redis_store.py @@ -29,7 +29,7 @@ class RedisStore(Store): """Efficient Redis dataset store""" - def __init__(self, host="localhost", port=6379, db=0, **kw_args) -> None: + def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0, **kw_args) -> None: # Currently does not support authentication self.host = host self.port = port From 8b4c4e1d518d4cb8e20933c6cbb36c0a07e241bc Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 13 Mar 2023 15:22:31 -0700 Subject: [PATCH 31/35] typo --- tensorflow_similarity/indexer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index f6c4d8ea..17dee58a 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -102,7 +102,7 @@ def __init__( super().__init__(distance, embedding_output, embedding_size, evaluator, stat_buffer_size) # internal structure naming # FIXME support custom objects - self.search_type = search if isinstance(search, str) else type(search).name + self.search_type = search if isinstance(search, str) else search.name if isinstance(search, Search): self.search: Search = search self.kv_store_type = kv_store if isinstance(kv_store, str) else type(kv_store).__name__ From 75d5ce1ea6f790b235c6b436cccce3422a45834b Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 13 Mar 2023 20:01:27 -0700 Subject: [PATCH 32/35] fix the tests for no normalization --- tensorflow_similarity/base_indexer.py | 1 + tensorflow_similarity/search/faiss_search.py | 32 +++++----- tensorflow_similarity/search/linear_search.py | 60 +++++++------------ tests/search/test_linear_search.py | 28 ++++----- 4 files changed, 51 insertions(+), 70 deletions(-) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index e0e111d1..a92711c1 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Mapping, MutableMapping, Sequence +from typing import Union import numpy as np import tensorflow as tf diff --git a/tensorflow_similarity/search/faiss_search.py b/tensorflow_similarity/search/faiss_search.py index f1241dd8..1b714076 100644 --- a/tensorflow_similarity/search/faiss_search.py +++ b/tensorflow_similarity/search/faiss_search.py @@ -96,27 +96,25 @@ def __init__( if distance == "cosine": # this is exact match using cosine/dot-product Distance self.index = faiss.IndexFlatIP(dim) - else: + elif distance == "l2": # this is exact match using L2 distance self.index = faiss.IndexFlatL2(dim) + else: + raise ValueError(f"distance {distance} not supported") def is_built(self): - return self.built - - def needs_building(self): - if self.algo == "flat": - return False - else: - return not self.index.is_trained + return self.algo == "flat" or self.index.is_trained - def build_index(self, samples, **kwargss): + def build_index(self, samples, normalize=True, **kwargss): if self.algo == "ivfpq": - if self.normalize: + if normalize: faiss.normalize_L2(samples) self.index.train(samples) # we must train the index to cluster into cells self.built = True - def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[int]], list[list[float]]]: + def batch_lookup( + self, embeddings: FloatTensor, k: int = 5, normalize: bool = True + ) -> tuple[list[list[int]], list[list[float]]]: """Find embeddings K nearest neighboors embeddings. Args: @@ -124,12 +122,12 @@ def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[i k: Number of nearest neighboors embedding to lookup. Defaults to 5. """ - if self.normalize: + if normalize: faiss.normalize_L2(embeddings) sims, indices = self.index.search(embeddings, k) return indices, sims - def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[float]]: + def lookup(self, embedding: FloatTensor, k: int = 5, normalize: bool = True) -> tuple[list[int], list[float]]: """Find embedding K nearest neighboors embeddings. Args: @@ -137,12 +135,12 @@ def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[fl k: Number of nearest neighboors embedding to lookup. Defaults to 5. """ int_embedding = np.array([embedding], dtype=np.float32) - if self.normalize: + if normalize: faiss.normalize_L2(int_embedding) sims, indices = self.index.search(int_embedding, k) return indices[0], sims[0] - def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): + def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, normalize: bool = True, **kwargs): """Add a single embedding to the search index. Args: @@ -151,7 +149,7 @@ def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): allow to lookup the data associated with a given embedding. """ int_embedding = np.array([embedding], dtype=np.float32) - if self.normalize: + if normalize: faiss.normalize_L2(int_embedding) if self.algo != "flat": self.index.add_with_ids(int_embedding) @@ -175,7 +173,7 @@ def batch_add( embeddings. verbose: Be verbose. Defaults to 1. """ - if self.normalize: + if normalize: faiss.normalize_L2(embeddings) if self.algo != "flat": # flat does not accept indexes as parameters and assumes incremental diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index 75e9323d..bc316fac 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -58,7 +58,9 @@ def is_built(self): def needs_building(self): return False - def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[int]], list[list[float]]]: + def batch_lookup( + self, embeddings: FloatTensor, k: int = 5, normalize: bool = True + ) -> tuple[list[list[int]], list[list[float]]]: """Find embeddings K nearest neighboors embeddings. Args: @@ -67,39 +69,17 @@ def batch_lookup(self, embeddings: FloatTensor, k: int = 5) -> tuple[list[list[i """ items = len(self.ids) - if self.distance.name == "cosine": - normalized_query = tf.math.l2_normalize(embeddings, axis=1) - sims = tf.matmul(normalized_query, tf.transpose(self.db[:items])) - similarity, id_idxs = tf.math.top_k(sims, k) - ids_array = np.array(self.ids) - return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(similarity) - elif self.distance.name in ("euclidean", "squared_euclidean"): - normalized_query = tf.math.l2_normalize(embeddings, axis=1) - items = len(self.ids) - assert ( - normalized_query.shape.as_list()[-1] == self.db.shape[-1] - ), "the last dimension should have the same size" - query_norms = tf.reduce_sum(tf.square(normalized_query), axis=1) - query_norms = tf.reshape(query_norms, [-1, 1]) # Only one column per row - - db_norms = tf.reduce_sum(tf.square(self.db[:items]), axis=1) - db_norms = tf.reshape(db_norms, [-1, 1]) # Only one column per row - - dists = query_norms - 2 * tf.matmul(normalized_query, tf.transpose(self.db[:items])) + db_norms - dists, id_idxs = tf.math.top_k(-dists, k) - dists = -dists - ids_array = np.array(self.ids) - return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(dists) - elif self.distance.name == "manhattan": - dists = tf.reduce_sum(tf.abs(tf.subtract(self.db[:items], tf.expand_dims(embeddings, 1))), axis=2) - dists, id_idxs = tf.math.top_k(-dists, k) - dists = -dists - ids_array = np.array(self.ids) - return list(np.array([ids_array[x.numpy()] for x in id_idxs])), list(dists) + if normalize: + query = tf.math.l2_normalize(embeddings, axis=1) else: - raise ValueError("Unsupported metric space") - - def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[float]]: + query = embeddings + sims = self.distance(query, self.db[:items]) + similarity, id_idxs = tf.math.top_k(sims, k) + id_idxs = id_idxs.numpy() + ids_array = np.array(self.ids) + return list(np.array([ids_array[x] for x in id_idxs])), list(similarity) + + def lookup(self, embedding: FloatTensor, k: int = 5, normalize: bool = True) -> tuple[list[int], list[float]]: """Find embedding K nearest neighboors embeddings. Args: @@ -107,10 +87,10 @@ def lookup(self, embedding: FloatTensor, k: int = 5) -> tuple[list[int], list[fl k: Number of nearest neighboors embedding to lookup. Defaults to 5. """ embeddings: FloatTensor = tf.convert_to_tensor([embedding], dtype=np.float32) - idxs, dists = self.batch_lookup(embeddings, k=k) + idxs, dists = self.batch_lookup(embeddings, k=k, normalize=normalize) return idxs[0], dists[0] - def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): + def add(self, embedding: FloatTensor, idx: int, normalize: bool = True, verbose: int = 1, **kwargs): """Add a single embedding to the search index. Args: @@ -118,7 +98,8 @@ def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): idx: Embedding id as in the index table. Returned with the embedding to allow to lookup the data associated with a given embedding. """ - int_embedding = tf.math.l2_normalize(np.array([embedding], dtype=np.float32), axis=1) + if normalize: + embedding = tf.math.l2_normalize(np.array([embedding], dtype=tf.keras.backend.floatx()), axis=1) items = len(self.ids) if items + 1 > self.db.shape[0]: # it's full @@ -126,7 +107,7 @@ def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, **kwargs): new_db[:items] = self.db self.db = new_db self.ids.append(idx) - self.db[items] = int_embedding + self.db[items] = embedding def batch_add( self, @@ -145,7 +126,8 @@ def batch_add( embeddings. verbose: Be verbose. Defaults to 1. """ - int_embeddings = tf.math.l2_normalize(embeddings, axis=1) + if normalize: + embeddings = tf.math.l2_normalize(embeddings, axis=1) items = len(self.ids) if items + len(embeddings) > self.db.shape[0]: # it's full @@ -156,7 +138,7 @@ def batch_add( new_db[:items] = self.db self.db = new_db self.ids.extend(idxs) - self.db[items : items + len(embeddings)] = int_embeddings + self.db[items : items + len(embeddings)] = embeddings def __make_file_path(self, path): return Path(path) / "index.pickle" diff --git a/tests/search/test_linear_search.py b/tests/search/test_linear_search.py index 0a86a0b1..5e091764 100644 --- a/tests/search/test_linear_search.py +++ b/tests/search/test_linear_search.py @@ -8,10 +8,10 @@ def test_index_match(): embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") search_index = LinearSearch("cosine", 3) - search_index.add(embs[0], 0) - search_index.add(embs[1], 1) + search_index.add(embs[0], 0, normalize=False) + search_index.add(embs[1], 1, normalize=False) - idxs, embs = search_index.lookup(target, k=2) + idxs, embs = search_index.lookup(target, k=2, normalize=False) assert len(embs) == 2 assert list(idxs) == [0, 1] @@ -36,13 +36,13 @@ def test_index_match_l2(): embs = np.array([[1, 1, 3], [3, 1, 2]], dtype="float32") search_index = LinearSearch("l2", 3) - search_index.add(embs[0], 0) - search_index.add(embs[1], 1) + search_index.add(embs[0], 0, normalize=False) + search_index.add(embs[1], 1, normalize=False) - idxs, embs = search_index.lookup(target, k=2) + idxs, embs = search_index.lookup(target, k=2, normalize=False) assert len(embs) == 2 - assert list(idxs) == [0, 1] + assert list(idxs) == [1, 0] def test_index_save(tmp_path): @@ -51,10 +51,10 @@ def test_index_save(tmp_path): k = 2 search_index = LinearSearch("cosine", 3) - search_index.add(embs[0], 0) - search_index.add(embs[1], 1) + search_index.add(embs[0], 0, normalize=False) + search_index.add(embs[1], 1, normalize=False) - idxs, embs = search_index.lookup(target, k=k) + idxs, embs = search_index.lookup(target, k=k, normalize=False) assert len(embs) == k assert list(idxs) == [0, 1] @@ -64,16 +64,16 @@ def test_index_save(tmp_path): search_index2 = LinearSearch("cosine", 3) search_index2.load(tmp_path) - idxs2, embs2 = search_index.lookup(target, k=k) + idxs2, embs2 = search_index.lookup(target, k=k, normalize=False) assert len(embs2) == k assert list(idxs2) == [0, 1] # add more # if the dtype is not passed we get an incompatible type error - search_index2.add(np.array([3.0, 3.0, 3.0], dtype="float32"), 3) - idxs3, embs3 = search_index2.lookup(target, k=3) + search_index2.add(np.array([3.0, 3.0, 3.0], dtype="float32"), 3, normalize=False) + idxs3, embs3 = search_index2.lookup(target, k=3, normalize=False) assert len(embs3) == 3 - assert list(idxs3) == [0, 3, 1] + assert list(idxs3) == [0, 1, 3] def test_batch_vs_single(tmp_path): From 621de2f3c8bff35e3e57dd4bed32eb56d39687a4 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 13 Mar 2023 20:17:00 -0700 Subject: [PATCH 33/35] add distance --- tensorflow_similarity/base_indexer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index a92711c1..b300377b 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -15,7 +15,7 @@ F1Score, make_classification_metric, ) -from .distances import distance_canonicalizer +from .distances import Distance, distance_canonicalizer from .evaluators import Evaluator, MemoryEvaluator from .matchers import ClassificationMatch, make_classification_matcher from .retrieval_metrics import RetrievalMetric From ea81b0b6582081462ee95b249c1a93e46b95fa2c Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 13 Mar 2023 22:36:59 -0700 Subject: [PATCH 34/35] fix typing --- tensorflow_similarity/base_indexer.py | 6 ++--- tensorflow_similarity/indexer.py | 2 +- tensorflow_similarity/search/linear_search.py | 26 +++++-------------- tensorflow_similarity/stores/cached_store.py | 2 +- 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/tensorflow_similarity/base_indexer.py b/tensorflow_similarity/base_indexer.py index b300377b..57b31603 100644 --- a/tensorflow_similarity/base_indexer.py +++ b/tensorflow_similarity/base_indexer.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Mapping, MutableMapping, Sequence -from typing import Union +from typing import Optional, Union import numpy as np import tensorflow as tf @@ -27,7 +27,7 @@ class BaseIndexer(ABC): def __init__( self, distance: Union[Distance, str], - embedding_output: int, + embedding_output: Optional[int], embedding_size: int, evaluator: Union[Evaluator, str], stat_buffer_size: int, @@ -44,7 +44,7 @@ def __init__( if self.evaluator_type == "memory": self.evaluator: Evaluator = MemoryEvaluator() elif isinstance(self.evaluator_type, Evaluator): - self.evaluator: Evaluator = self.evaluator_type + self.evaluator = self.evaluator_type else: raise ValueError("You need to either supply a know evaluator name " "or an Evaluator() object") diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index 17dee58a..d24db73c 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -66,7 +66,7 @@ def __init__( search: Union[Search, str] = "nmslib", kv_store: Union[Store, str] = "memory", evaluator: Union[Evaluator, str] = "memory", - embedding_output: int = None, + embedding_output: Optional[int] = None, stat_buffer_size: int = 1000, ) -> None: """Index embeddings to make them searchable via KNN diff --git a/tensorflow_similarity/search/linear_search.py b/tensorflow_similarity/search/linear_search.py index bc316fac..0754ff07 100644 --- a/tensorflow_similarity/search/linear_search.py +++ b/tensorflow_similarity/search/linear_search.py @@ -49,7 +49,7 @@ def __init__(self, distance: Distance | str, dim: int, verbose: int = 0, name: s f"| - name: {self.name}", ] cprint("\n".join(t_msg) + "\n", "green") - self.db = np.empty((INITIAL_DB_SIZE, dim), dtype=np.float32) + self.db: List[FloatTensor] = [] self.ids: List[int] = [] def is_built(self): @@ -73,7 +73,8 @@ def batch_lookup( query = tf.math.l2_normalize(embeddings, axis=1) else: query = embeddings - sims = self.distance(query, self.db[:items]) + db_tensor = tf.convert_to_tensor(self.db) + sims = self.distance(query, db_tensor) similarity, id_idxs = tf.math.top_k(sims, k) id_idxs = id_idxs.numpy() ids_array = np.array(self.ids) @@ -90,7 +91,7 @@ def lookup(self, embedding: FloatTensor, k: int = 5, normalize: bool = True) -> idxs, dists = self.batch_lookup(embeddings, k=k, normalize=normalize) return idxs[0], dists[0] - def add(self, embedding: FloatTensor, idx: int, normalize: bool = True, verbose: int = 1, **kwargs): + def add(self, embedding: FloatTensor, idx: int, verbose: int = 1, normalize: bool = True, **kwargs): """Add a single embedding to the search index. Args: @@ -100,14 +101,8 @@ def add(self, embedding: FloatTensor, idx: int, normalize: bool = True, verbose: """ if normalize: embedding = tf.math.l2_normalize(np.array([embedding], dtype=tf.keras.backend.floatx()), axis=1) - items = len(self.ids) - if items + 1 > self.db.shape[0]: - # it's full - new_db = np.empty((len(self.ids) + DB_SIZE_STEPS, self.dim), dtype=np.float32) - new_db[:items] = self.db - self.db = new_db self.ids.append(idx) - self.db[items] = embedding + self.db.append(embedding) def batch_add( self, @@ -128,17 +123,8 @@ def batch_add( """ if normalize: embeddings = tf.math.l2_normalize(embeddings, axis=1) - items = len(self.ids) - if items + len(embeddings) > self.db.shape[0]: - # it's full - new_db = np.empty( - (((items + len(embeddings) + DB_SIZE_STEPS) // DB_SIZE_STEPS) * DB_SIZE_STEPS, self.dim), - dtype=np.float32, - ) - new_db[:items] = self.db - self.db = new_db self.ids.extend(idxs) - self.db[items : items + len(embeddings)] = embeddings + self.db.extend(embeddings) def __make_file_path(self, path): return Path(path) / "index.pickle" diff --git a/tensorflow_similarity/stores/cached_store.py b/tensorflow_similarity/stores/cached_store.py index 02c987cb..a4cb016d 100644 --- a/tensorflow_similarity/stores/cached_store.py +++ b/tensorflow_similarity/stores/cached_store.py @@ -34,7 +34,7 @@ class CachedStore(Store): def __init__(self, shard_size: int = 1000000, path: str = ".", num_items: int = 0, **kw_args) -> None: # We are using a native python cached dictionary # db[id] = pickle((embedding, label, data)) - self.db: list[dict[str, str]] = [] + self.db: list[dict[str, bytes]] = [] self.shard_size = shard_size self.num_items: int = num_items self.path: str = path From 38c9ce96c1e56c3c8eba368470f4a864b740d400 Mon Sep 17 00:00:00 2001 From: Ali Zand Date: Mon, 20 Mar 2023 21:17:41 -0700 Subject: [PATCH 35/35] remove double definition --- tensorflow_similarity/indexer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tensorflow_similarity/indexer.py b/tensorflow_similarity/indexer.py index fd4f2523..d64ba8ea 100644 --- a/tensorflow_similarity/indexer.py +++ b/tensorflow_similarity/indexer.py @@ -119,7 +119,7 @@ def _init_structures(self) -> None: "(re)initialize internal storage structure" if self.search_type == "nmslib": - self.search: Search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) + self.search = NMSLibSearch(distance=self.distance, dim=self.embedding_size) elif self.search_type == "linear": self.search = LinearSearch(distance=self.distance, dim=self.embedding_size) elif isinstance(self.search_type, Search):