From 7dcc1472fda88f3b00cfe33094032538a2004614 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 26dbf150750f188e7ce9aad8ed01a98f47e90f1d 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 e201ac0f64e90d58783061fde7a66bb2457ba542 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 01abbe1fd4a2f27ff1a12b57018918efe02c0aab 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 f7f3576bba45bdbcf2d1ca15f1147369208eaca6 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 b6508b18c7fd5296493b103f511985ed09839535 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 672378f5e09a9a8b4c2dfd9644c386a613aeaea4 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 5ef37420da006bac32318bf5be65425e13b664ca 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 e81166740d6438c69e6c92c9ea590fb32e8944e4 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 3507f2f8d13415e1ac2713fc39b626e98ac59e84 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 3c131d1a39da60253cbe68dd2918bd08cda2e4f5 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 92502acea1430755f957e818f8a8b10d15bad5fb 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 71ebfdcf35f504717be60a26ee0872672e343b26 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 2f16f5a0fe614a98677846b559820596afcf15af 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 1c9728d5143b4a28dac6fd0d1d7eae130b69a50d 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 c97e41295083b4fe19420d58963174c7f4f38eee 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 b520f457ca8d5ba6131d91a0aef7ce91ee7d04d4 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 185d5b91e32e51d7badba26f7fd891d31cc1cb3b 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 0e28daa294c4c787f78bcb2167566137199f2c16 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 807a41a4280565782b2fdea1870c740bd8dc7126 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 2b5169816fd33a5c35e871e83fbf1e90ee1b53d5 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 45e3d477ca84c794b76451dfe0894e7161b578da 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 80e9fee00eadf6601d80af3b01dc526e1b0f110a 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 d32406f2f07ed4cdf247a9394473ce02eb464073 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 573ec4f7b3bf37e8a9d5c02689f799407edcafdc 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 904f06ea8a60ad3287b66ef426fcd2e90c61abf3 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 077d920c89c9be10a97615c634c5118853415c0c 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 691df9cbf49451899b789199b96957e4e74fbc25 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 09aa1cc0d4161bf1f67e279d3cb8104a698dddc1 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": "iVBORw0KGgoAAAANSUhEUgAAA3sAAACOCAYAAACIehHUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/NK7nSAAAACXBIWXMAAAsTAAALEwEAmpwYAAAg2klEQVR4nO3deZgU1b3G8ffIJquI+wJBUINCCCCKyxWuuF0XVNQgEgWvIiomMaLEqBEU4mNETLwuERI0KiiRgFuIghIwgEEQXBCNgCCgYZFNdpDl3D+qmPTvONMzzfRMdxffz/P0Y79TXdWn6WN3na761XHeewEAAAAAkmWfXDcAAAAAAJB9DPYAAAAAIIEY7AEAAABAAjHYAwAAAIAEYrAHAAAAAAnEYA8AAAAAEojBHgAAAAAk0F492HPOjXDOLXPOrXfOzXPO9cx1m5B92X6fnXOtnHOznHOb4/+2SvPYxs65151za51zy51zjzvnqqYsr+Kc+7VzbqlzboNz7gPnXP2U5U2cc2PjZaucc4NSlr3tnNvqnNsY3+amLLsr5e8bnXNbnHO7nHMHlue1o3h53se8c25TSl8YlrLsXufc9qCvNElZ3tE59378uhY653oFz93NObc43v4rzrkG5XndKFkB97G+zrk58WfYF865vsG2T3XOzYiXz3bO/Vew/KfxeuudczPD5ciefO1jzrkDnXPvOOdWO+e+cc5Nc86dlrKui79H/+2cWxd/NzZPWd7AOfdivP4q59zzzrl6KcsnOedWxq/7I+fcxeV53ShZAfexa5xzO4Pvyv+OlzUK/r4x/ky8LV5+gXNuarzd5c65Yc65uuV53Rnz3u+1N0nNJdWI7zeTtFzSCbluF7f8fZ8lVZe0WNKtkmpI+lmcq5fw+NclPSNpX0mHSvpY0s9Slv9a0kRJ35PkJLWQtG/Kcy2Q1EdS7XgbLVPWfVtSzzK2+15JE3P9XiT1lud9zEs6Ok2/GFHCsmqS1km6Ie6bJ0raKOmHKa95g6T2kupIekHSn3P9XiT1VsB97BeS2kiqKun78fN0jZc1kLRa0o8kVZF0laS1kvaPl7eTtEnSCXEfvEnSSklVcv1+JPGWr30s/tv3FR2gcJIukbRGUtV4eRdJSyU1ifvRA5LeT9n27yW9KamepP0kTZD025TlLVO21S7+XDss1+9HEm8F3MeukTS1jO06StJOSY3j3E3S/0iqJWl/SW9IGlKZ/+579ZE97/0n3vttu2N8a5rDJqECZPl9/m9FOy2PeO+3ee8fVfTB0LGExx8laZT3fqv3frmkcYo+7OSc21/SzyVd771f7CNzvPdb43WvkbTUe/9b7/2meBuzM22wc85J6i7p2UzXRdnkax8rpwaKdo6Gx33zPUn/knR8vPzHkv7qvZ/svd8o6R5Jl1b6L5Z7iULtY977Qd779733O7z3cyW9Kmn3L+anSlruvf+L936n936EosHcpfHyxpI+8d7P8tFe03OSDpR0cIavF2WQr30s/ttc7/2ueBs7Fe00N0hZd6r3fqH3fqekEfrP59Tu5a9479d779dJelkp/dd7P9t7vyPldVeT1HAPXzfSKOA+lonukiZ77xfF237Bez/Oe7/Ze79W0h/1n8/ASrFXD/YkyTn3e+fcZkmfSVqmaOSPhMni+9xc0ux4x2O32Sp5x+cRSV2dc7Wcc0dIOk/RB4wk/UDSDkmXx4f25znnbk5Z92RJi5xzb8SnnrztnPtBsP0H4mXv7D6loBinK9o5GlPG14g9kKd9bLfJcR97yTnXOFjWyTm3xjn3iXPupt1/9N6vkDRS0v+66HTjUxQdgZ6a0s6PUh6/QNK3ko7N4LUiAwXcx3a33yn6PPok9c/hwxSd4SBFv4BXcc61c85VkXStpA8VHQ1ABcjnPuacmy1pq6TXJA3z3n8dL/qzpKbOuWOdc9Uk9QjWfULShc65/eMfWS9T1LdStz3WObdV0nRFZ83MzPD1oowKtI9JUut4f2uec+4el3Iqe8r6Zflxvb3sZ2CF2+sHe9773pLqKvoCeknStvRroBBl8X2uo+jUtlTr4m0XZ7KiD571kr5S9AXySrzsSEWnlByr6BenyyXd65w7O2V5V0mPSjpc0t8kveqcqx4vv0PRaStHSPqDpL8654r7hayHpNHx0RdUkDztY5LUQdERkmaKTnUam/IlNUrScZIOknS9pH7OuStT1h0pqZ+i1zJF0t3e+y/3sJ0opwLtY6nuVbTf8ac4T5N0uHPuSudcNedcD0W/8teKl29Q9CPVVEWvtb+kXsHOHbIoj/uYvPctFZ1t0E3/+dFJigYMUyXNlbRF0WnBt6Ysf1/RKX+r49tORad2pm77wrht50t6Mz7CgwpQoH1ssqIfoQ5W9GPBlZJM/XHsvyQdIml0cQ2I9+96KPperTR7/WBPkuLTR6Yq2rm+qbTHozCV5X2Oj27sLrA9vZiHbFT0QZCqnqKdknBb+yj61eglRTV3Byo6LeDB+CFb4v8O8N5viU/R/LOiL5vdy6d679/w3n8rabCkAxTtnMt7P917vyE+feFZSe+krLu7DbUUffFxCmclyMM+pvg0y2+9999IukXRDwu7+9Cn3vulcbv/Ken/FP3oIOdcM0X9sbuiHaXmkn7hnLsg03Yiewqtj6Vs5yeK+tIFu0/j8t6vlnSxorrkFYrqWiYo2hGTpOsk/a+ivlddUU3fWOfc4cW9bmRHPvaxlLZt9d6PlPRL59wP4z/3U1RT3FBR7dV9kibG339S9KPWPEWDgHqKauFHFLPt7d77NySd45y7qLjXjewotD4WnyL8hfd+l/f+Y0kDFH9XBnpIGlPcj+vOuZMV1bZf7r2fV9xrrigM9qyqomZvb1Di++y9b+69rxPfphTzkE8ktYwP1e/WUsUfkm8gqZGkx+MB2WpFv2jvHpDtrr9L/ZU6PB0hk1+wvb57SlRnRUXGb2ewHZRfvvSxYpug7/aT4pa1kDTPez8+/oKbq+jo8nkp7dy9syUXXcWzhqKdKlS8guljzrlrJf1S0pne+6/MA73/h/f+RO99A0lXKzo6OCNe3ErSWO/9vLgPjlN0FOfUNM+N7MnnPlZN0ZktUtRPXvTef+Wj2tBnFO3IH5+yfKiPat83ShpSyrbZF6w8hdLHvtM8Bd+jzrmaKuHHdedca0Wnhl7rvf97muesGD4Prs6Ti5uiQ7FdFR0GriLpXEVX/boo123jlr/vs/5z9adbFO3Y/kTpr/60UNFOTlVJ9RUVhr+QsnyypKHxto6T9LWiHSIpujLUZklnxW2/VdEvktXjbZ2r6FfMqooulrFJ0rHB87+p6Mhhzt+LpN7yuY8pOiLSKm5XHUU1C3MlVYuXX6xop8hJOknSvyX1iJc1VfTLacd4eVNJnys6jW73ttcrOhWntqJfyrkaJ30s7GM/VlRjd1wJ226taKeqXrzuOynLeij68aBJ3AfPjj8Tm+X6PUnaLc/72MmKTo+rLqmmohKGDZIOj5f3V3TK3SGKDmJcHbe9frx8kqTH4nVrKjqF85/xsmaKfsCqGffDqxTVHrfJ9XuStFuB97HzJB2S0mfmSOofbL+bpEWSXPD3ForOXLgiZ//2uX7zc9jpDpL0D0nfKNph+VjRVRFz3jZu+f0+K9o5maXoNMv3JbVOWXaXpDdScitFR9XWSlql6HSSQ1KWH6Ho1IKN8QfRDcFzXapoB3t9vJ3mKa/rvfjD6BtJ70o6O1j3CEUXgCn2kujckt/HFA3U5ir6Qv1aUX3CMSnrjlRUw7JRUbH8z4J2dIm/1DYoOrXuQUn7pCzvJmlJvP1XJTXI9fuRxFuB97EvJG2P+9ju25CU5SMV1dmsk/SipINTljlFp0stifvgvyRdnev3I4m3PO9jHRRdDGqDojNV/iGpfcq6+yq6CMuyuO3vS/qflOVHSfpr/Fm3RtF37jHxsuMUXZRl93fpe5I65/r9SOKtwPvYYEUDtk2K9tUGKP5BK+Ux4yUNLKaNf5K0K/gM/KQy/+1d3BAAAAAAQIJQswcAAAAACcRgDwAAAAASiMEeAAAAACQQgz0AAAAASCAGewAAAACQQFUzefCBBx7oGzduXEFNQbYtWrRIq1atKmny5LxEHyss9DFUhlmzZq3y3h+U63aUFX2s8NDHUNHoY6hoJfWxjAZ7jRs31syZM7PXKlSotm3b5roJGaOPFRb6GCqDc25xrtuQCfpY4aGPoaLRx1DRSupjnMYJAAAAAAnEYA8AAAAAEojBHgAAAAAkEIM9AAAAAEggBnsAAAAAkEAM9gAAAAAggRjsAQAAAEACMdgDAAAAgARisAcAAAAACcRgDwAAAAASiMEeAAAAACQQgz0AAAAASCAGewAAAACQQAz2AAAAACCBGOwBAAAAQAIx2AMAAACABKqa6wYA+WL9+vUmDxs2zOSFCxea/MEHH5g8bdo0k733JT6Xc87khg0bmvzYY4+Z3KlTp7TrozDcdNNNJg8dOjRr2w7723333Wfy7bffbnKtWrWy9tzIrm+//bbo/qRJk8yyV1991eQhQ4Zk9bnDftSiRYui++Hn0mmnnWZytWrVstoW5Mb27dtN/uMf/2jyzTffbPI++2R23OD+++8vcVnz5s1N7tChg8n16tXL6LlQmHbs2GFy2Cf/9a9/mTxx4kST33nnnRK33atXL5PPPfdckzPtz4Ugea8IAAAAAMBgDwAAAACSiMEeAAAAACRQ3tTs/fjHPzb5hRdeSPv49u3bmzx+/Pi0ecWKFSVua/78+SYPHjw47XNn21VXXWVyaq1NkyZNKrUtSbZ69WqTf/rTn5r897//3eRVq1ZltP2wji6TurqvvvrK5M6dO5v89ttvm3z66adn1Dbkxscff2zyyJEjTc5m7WW4rbBmL6yTGTBggMk///nPTa5Ro0bW2obM9O/fv+j+oEGD0j422/W74fY+/fTTovtnnnmmWXbSSSeZHH6GUhdamBYsWGBy3759TQ5rmjLtg2Gdafj9l06fPn1MDj/natasmVFbkBsbN240Odznf+utt0weM2ZM1p47rHv+/PPPTU7ifjdH9gAAAAAggRjsAQAAAEACMdgDAAAAgATKWc3eU089ZXKmtSxTpkwxOZx7ZefOnSanm/MsVNlzmD3//PMmv/zyy0X3w/OaUXZr1qwx+ZRTTjE5rEsoTfXq1dMuv+OOO0xOV6+yadMmk3/961+n3fY111xj8owZM0w+4IAD0q6PyhHWnpxxxhkmb9iwweTws6Zjx45ptx/O7Rj28XTCeYvuuusuk8N5i8K6mn333bfMz4W9Q/g5dOGFF5oczn2FwtCsWTOTP/vsM5PDz5JMNWjQwOS5c+cW3Q9riceNG2fyww8/bHI4/+3w4cNNpoYvP3z55Zcmh9cdWLJkSUbbK893Z/i9ec4555g8Z84ck5Pw3ceRPQAAAABIIAZ7AAAAAJBADPYAAAAAIIFyVrPXrl07ky+44AKT//a3v5kc1ludf/75JofncX/zzTdpn/8nP/lJ0f1DDjkk7WNDRx55pMlXXnmlyZMnTzb5zjvvNHnWrFlptx/WG2LPVKlSxeSuXbua/Mknn5h83XXXmVytWjWTzz777Ky1bfv27SZPnTrV5HBevUWLFpkc1htSs5cfwlqAtWvXpn38448/bvKNN96Y9vHLli0z+cMPPyy6H9b+vvTSSyZv27Yt7bbDWpfLLrvM5E6dOqVdH9nTq1evovthrUuod+/eJof1UJl65ZVXTL777rvLvG5YU4pkaNiwYYVuP3W+xnAux7BmLxT21+XLl5t81FFHla9xyIrf//73Joc1euF++C233GLyJZdcYnI41+Oxxx5r8tKlS01Ove7BhAkTzLLy1qAWAo7sAQAAAEACMdgDAAAAgARisAcAAAAACZSzmr0WLVqYHNaXhOfQhvVX4Zxnt912m8mlzatXo0aNovvhub/ldfjhh5t80EEHZbR+OOcH9sx+++1ncjh/Ty6FdZnhPEZIpieffNLk66+/PqP1DzvssBLzeeedZ5aFtSthDd67776b9rm6d+9ucjgP36GHHpq+sdhjqXVGI0aMqNDnCuvsHnrooQp9PmDr1q0mP/3000X3b7/99rTrhvtr4TURMt3fQuVo2bJl2uVhjd4vf/nLjLY/f/58k0899VSTV69eXeK6b775pslJmFcvxJE9AAAAAEggBnsAAAAAkEAM9gAAAAAggXJWsxcK5zQLc2kq8xzbLVu2mDxq1CiTb775ZpM3b96cdnvhax04cGA5Wod8sWvXrqL7EydONMvCOdHC+qrQY489ZnLr1q3L2TpUhOOPP97kcJ69OnXqmOycq7C2hDV1b731lsnhfFYzZswwef369SaXNk8fciOco3PVqlUmT5o0yeSxY8eavHLlSpM3bdpU5uc+7bTTTH7wwQfLvC72XieffLLJc+bMKboffiaGc/yFNXqpc1Iif3Xp0sXkcG7tWrVqpV0/nDfvN7/5jcnPPfecyeH3V2o/GjRokFnWtGnTtM+dBBzZAwAAAIAEYrAHAAAAAAnEYA8AAAAAEihvavbyWTi/VL9+/UweM2ZMRtsL6wvDWprS5iNBfkqt0ZOkwYMHF90P6wxKE84VFNYlVK3K/7r5KHxf6tWrl6OWfNfixYtNrlu3bo5agmwK56OaNm1apT132L/btm1rcjifaDhfLvYOU6ZMMfnjjz82ObVO71e/+pVZduutt5oczp+LwhD+v1/ad2N4HYM2bdqY/PXXX2f0/KnXxghr6cPrcIT1g9meizsXCv8VAAAAAAC+g8EeAAAAACQQgz0AAAAASKCCKfxZs2aNyX/6059MHj58uMkDBgwwOayBSjVv3jyTf/e735m8YMECk0ubhyicS+umm24y+Y477jC5QYMGabeH3AjPCZ8/f37ax7/99tsmh7WdmejRo4fJ1OihNNu3bzd5yJAhJvfp08fksMY0VKNGDZPpgwi98cYbJof16GF91T//+U+Tjz76aJPpY8n073//O+3y1DnQqNGD9N0av5o1a5ZrewsXLiy637t3b7MszKeffrrJnTp1MvmWW24xOdN5wXOBI3sAAAAAkEAM9gAAAAAggRjsAQAAAEACFcwJ8lOnTjW5b9++aR9/ySWXVFhb6tevn/a5evbsafKpp55aYW1B9ixdutTkk046yeRly5ZVWlueeOIJk1etWmXylVdeaXLHjh1NTsK8MPiudevWmfz5558X3b/33nvNstdff71cz/XUU0+ZfMQRR5Rre6gYjz76qMl33XWXyatXr067fvi5Vrt2bZNT+1imwv7avHlzk7t06WJy//79TT7mmGNMZp6+wtS1a1eTb7zxRpOXLFlSdL9Dhw5m2eTJk03Op7lLUXHC62zMmDHD5I8++sjkcL7RUOrn2Pr169M+NpwXMsxvvvmmyQMHDjS5devWJlevXj3t81UG9ggBAAAAIIEY7AEAAABAAhXMaZyNGjUyOTzFLjzEW5GOO+44k59++ulKe25UnAceeMDkyjxtM7RlyxaTn3nmmbS5e/fuJoen4HFaZ2FKvVy0JLVv397k8vTR8DL54VQNF1988R5vG5WnTZs2Jo8bNy6j9cNpjcJLnH/22Wclrjtt2jSTx44da/L48ePTPveoUaPS5vvvv9/kX/ziFybzuVaYZs6caXLqpe7nzJljloWndb722msmp07bgOQKT+s866yzTA77VGjx4sVF98NT2z/88EOTH3roIZPnzp1r8ltvvWXyhAkTTA5PUw5LLA4++OC0ba0IfFICAAAAQAIx2AMAAACABGKwBwAAAAAJ5Lz3ZX5w27ZtfWnnxVaWTZs2mbxixYqM1k+9XPXw4cPNsrVr16ZdN7w09Y9+9KMSty1JderUyaht2dK2bVvNnDnT5eTJ91Au+1hYJ9e0aVOTS+tjN9xwg8mplxU/6qij0q67cuVKk/v162dyabUvoUmTJpkc1nplC32sYt12220mP/LII1nbdtgny3OJ/YrmnJvlvW+b63aUVSH1sWzatWuXyWH9VVgHmnrJ/bIIa/ouu+yyjNZPhz6WO6lTC919991mWVh/Hl7G/qqrrjI5vAR/kyZNstHErKCPFYYNGzaYHE7LddFFF5k8f/78tNsLa/TCx9etWzfTJpaopD7GkT0AAAAASCAGewAAAACQQAz2AAAAACCBCrZmL5u+/PJLk3/729+aHNb0hfMShYYNG2bytddeW47W7Tnqqcpn27ZtJpf2/0qNGjVMdm7P/+l37txpcjiP5Jlnnmly2NbmzZub/P7775tctWp2ptikj1WscB698LMpteZp9OjRGW077AN/+MMfTO7Ro0dG26tI1LokQ1gLE85nFc6rFwrrs5599tnsNEz0sXyxfft2k2fPnm1yOMdy+D1bq1YtkydPnmxyq1atytnCPUcfS4aNGzeaPHToUJP79u2bdv3p06ebfOKJJ2anYaJmDwAAAAD2Kgz2AAAAACCBGOwBAAAAQAJRs1cGs2bNMrl3794mv/feeyaH54y/++67Jrdo0SKLrSsZ9VTJEc4BGNbkLV682ORDDz3U5M8++8zkbM3rQh/LrR07dhTdD+fuadeuncnh3KShRo0amfzBBx+YXL9+/T1oYXZQ65JM4edS+LlWmrC2uTzoY4XpnnvuMfnhhx82Oaxn37x5s8lhrX1Foo8lU1iLfMIJJ5gczmF75513mlxarXImqNkDAAAAgL0Igz0AAAAASCAGewAAAACQQNmZbCvhwvNvX375ZZOPPPJIk8NzwpcvX25yZdXsITkWLlxoclijFzrrrLNMzlaNHvJL6lx5xx13nFn24Ycfmvz973/f5F27dpmcOmefJH311Vcm57JmD8kQ9rkBAwbkqCVIioEDB5rcuHFjk2+44QaTr776apNHjRpVIe3C3iPcv2rb1pbMhTV7ucCRPQAAAABIIAZ7AAAAAJBADPYAAAAAIIGo2dsD69evz3UTsJcZPXp0Ro8P50jD3mf16tW5bgJgPP/88ya/+OKLGa1/xRVXZLM5SKBu3bqZ3L9/f5PHjh1rcjjXY7NmzSqmYUiscEwwffr0HLWkZBzZAwAAAIAEYrAHAAAAAAnEYA8AAAAAEihnNXtbt25Nm2vUqGFyzZo1K7xNJXnuuedMDs8BD6XOfSVJtWvXznqbUH7ffvutyeFcdkcffbTJ4fuaTWH/HzdunMn3339/Rtvr06dPuduEwta5c2eTwznOQmF/D+erQmEI53kNvzudc1l7rk2bNpm8atUqkwcPHmzy+PHjy/V84fxVyA+l1Yi3bt26klry3f5+7rnnmvzMM8+Y/M0331Rwi5B0L7zwgslffPFF2sdffvnlFdmcYnFkDwAAAAASiMEeAAAAACQQgz0AAAAASKCc1eyFdXA33nijySeccILJkyZNMrlOnTpZa8vixYtNfuKJJ0z+y1/+kvbxoQEDBph8yimnlKN1yJadO3eaHPaxTz/91OSWLVuaXL16dZN/8IMfmHzPPfeY/L3vfa/Etrzyyism33nnnSbPmzevxHWL06ZNG5O7d++e0fooDGFt56JFi4ruP/nkk2bZmjVr0m5r3333Nfnxxx83OZufsUhv3bp1Ju+3335lXjesm7vssstMbtCggcn9+vXLsHX/EfaxKVOmmPzRRx/t8baLE9Zbde3aNavbx54J5xUrrZayQ4cOJofff6l1dtWqVcuoLVu2bDF5woQJJoc1evvsY49xhJ+DyI0lS5aYPHHiRJOvueaaSmyNFda7P/LII2lz6NBDDzU5rI+vDBzZAwAAAIAEYrAHAAAAAAnEYA8AAAAAEihnNXulmTVrlsn33XefyYMGDTI5nDtox44dJodzqo0YMaLoflhjt3Tp0rRtq1Klislh7db111+fdn3kRnguf1ijF5o9e3ba5TNnzjT52WefNTmsDUgV9s/ShH0urHENzxlP99zInnBuxgcffDDt8vJau3atyaXNb5XORRddZPLZZ5+9x9tC+YQ1uieeeGKZ1122bJnJ77zzjsnhvHsvvvhihq2rPOH8U0899ZTJ1JHmh3Auu549e5ocvm+TJ082ef/99zf5vPPOK7rfqFGjjNoSzt0YXlMh3DcMr8nQqlWrjJ4PFWP69Okm33HHHSafdtppJh9zzDEV3qbdwnmOS5trO6zRC/t/3bp1s9OwDLBHCAAAAAAJxGAPAAAAABKIwR4AAAAAJFDOavbCuU3CHM4n9fDDD5sc1jCF5/LPnTvX5Oeff36P2ilJzZs3Nzk857t9+/Z7vG1UnrAm6fzzzzf59ddfN7lp06YmL1iwIO32w7lYwpyJcI6+e++912Tm0csPYY3esGHDctSS7zrppJNMfuCBB0w++eSTK7M5SCOTGr1QOGdTOM/e8OHD93jb2RbW5IW1L8cee6zJVavm7WUF9mrhXHiPPvqoyeF8iKNHjzZ5yJAhJo8bN67Mz+29NzmsyWvcuLHJr732msnHH398mZ8Lleewww4zeeXKlSa3a9fO5Keffjrt8nB7qcJ5TcM654EDB5oc1hOGDjroIJPD+UfDfclc4MgeAAAAACQQgz0AAAAASKCcnSMRnobWoUMHk5s1a2bytm3bTA6nXiiPH/7whyb36dPH5E6dOplcv379rD03Kk84HcHLL79scnhoPzy1eMWKFSb369fP5JEjR5b43OFUCVdccYXJ4anC4XPXrl27xG0jd7p162bypEmTTC7t1N9M9erVy+TUy0+3bdvWLDvllFNMDk+9QjKFn0stW7ZM+/hRo0aZ/N5775X5uTp27GjyJZdcYvJ1111nctgHw3IMFKYaNWqYfMYZZ6TN4SnlqZemHzNmjFm2ZMkSk8855xyTGzZsaHLnzp1NDqeJQH4Kv6/CU9vDz6VLL73U5Fq1apkc7kOlCqdi27hxY5nbKX13/y3sz+GpxPmAI3sAAAAAkEAM9gAAAAAggRjsAQAAAEAC5c11jcNLzQ8dOtTk3r17m7x58+a02wsvO96zZ88SH9ulSxeT69Wrl3bbSIbwst4HHHBA2sc3adLE5BEjRqTNSL6w1njevHk5agkQCT+nwhr0UGnLgWwL97EuvPDCYu9j7xHW75Y23QEyw5E9AAAAAEggBnsAAAAAkEAM9gAAAAAggfKmZi8UzsMXZgAAAABAyTiyBwAAAAAJxGAPAAAAABKIwR4AAAAAJBCDPQAAAABIIAZ7AAAAAJBADPYAAAAAIIEY7AEAAABAAjnvfdkf7NxKSYsrrjnIsu957w/KdSMyQR8rOPQxVIaC6mf0sYJEH0NFo4+hohXbxzIa7AEAAAAACgOncQIAAABAAjHYAwAAAIAEYrAHAAAAAAnEYA8AAAAAEojBHgAAAAAkEIM9AAAAAEggBnsAAAAAkEAM9gAAAAAggRjsAQAAAEAC/T+NBhNXcjz2GwAAAABJRU5ErkJggg==\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": "iVBORw0KGgoAAAANSUhEUgAAA3sAAACOCAYAAACIehHUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/NK7nSAAAACXBIWXMAAAsTAAALEwEAmpwYAAAjjElEQVR4nO3debxV8/7H8fe3OWm4SZQkrjL7GTJLpBvdCDcRcY0/P3MuuVKuzJSkG5kz3YRrTsYuCjduQlKmTMk1q2hEtX5/rNWxPh/n7DN0ztlnr/N6Ph77Yb/32mvt77G/rbW/e6/P+oYoigQAAAAAyJY6+W4AAAAAAKDyMdgDAAAAgAxisAcAAAAAGcRgDwAAAAAyiMEeAAAAAGQQgz0AAAAAyCAGewAAAACQQbV6sBdC6BBCeDKEsCCE8FUI4foQQr18twuVp7Lf42R7L4QQloYQ3gshdM/x3NkhhMWp24oQwuOp5d1CCG+EEH4MIXwcQjjJrX9GCOGTZPn0EMKexbxGgxDCuyGEz1OPdXGvuziEEIUQ+lT070bJanIfSz3vz0kfODH1WMMQwk0hhK9DCPNDCI+HEDZw6/VL+teSEMJHIYQuqWWHJcsWhRDeCSEcXNG/GaUr4H52bghhVtJPPgkhnOvWuTSE8HayzYuK2ea6IYTxIYQfkr/9nor+zcitpvaxEEKrEMK/QwjfhxAWhhBeCSHskVr3JrfuTyGERanlLUMIjyT7sbkhhCNTy0IIYUgI4bPkWHtfCKFZRf9mlE0IoWMIYXkIYdwabmffpG8tTfraRqU8f0CyH1qSHL86JY/n7AfJ8W5q8jqTi9nugcl+bnHyvC1LeP3nkn1k9Y41oiiqtTdJT0q6U1IjSetLelvSmfluF7ea+x5LekXSSEmNJfWRtFDSumVYL0j6RNKfk1xf0g+S/i9ZtpOkxZL+J1m+i6QlknZMlp8i6VtJdd12h0h6UdLnOV57b0mLJDXJ9/uRxVtN7WOpx38n6T1JsySdmHr8r5LekrRe0va7JT2cWv4HSXMl7ar4i8ENJG2QLNtA0s+Seiav20vSUkmt8/1+ZPVW4P1sB0n1JG2W9Kl+qeXHJP3oMUkXFfN6LyXtbJ7sN7fP93uR1VtN7WNJezZL9kNB0sGS5kuqV8L6d0q6PZXvlXS/pLUl7an42LtVqv+9J2nDZPljku7K93uR9ZukZ5N/2+PWYButkveyb9JHrpb0ao7nnyhppqQtk370e0kty9IPJHWXdJikCyVNdtvtKOnHpG/Vk3S+pA99/5TUX/Hntaikvltl/7/z/YbnubO9K+mPqXy1pJvz3S5uNfM9ltRJ0k+SmqYee0nSyWVYt6tSAy7FH7AjSWulnvOapCOS+4dLmpZa1iR5fpvUYxsnf19P5R7s3SHpjny/F1m91dQ+lnr8JkmnSpos+yH8RknDU7mXpPdTeaqkE0p4rV0kfeMe+1bSbvl+P7J6K9R+Vsz6oyVdV8zj4+QGe5J6SPpU7ksubrWzjyXL6kg6MDke/ubLpeRYuUhS11T+WVKn1HP+Iemq5P6Dks5NLdtd0nKljs3cKr2f9ZP0T0kXac0GeydJmure+2WSNi+h38yTtG8J2ypTP1A8YJzsHjtd0hPutZalX0vxl1UfKP7ytNoHe7X6NE5JoyT1CyGslZy+1FPS0/ltEirZKFXee7yVpI+jKFqUeuyt5PHSHCPpoSiKlkhSFEVfK/628bgQQt0Qwm6SNpL0cvL8pyTVDSHsEkKoK+l4STMkfZXa5nWSBiveqRQrhNBE0qGS7ipDG1Exo1QD+5gkhRB2ltRZ8Qdxb6ykPUIIbUMIayn+1vGpZL26yXrrhhA+DCF8npzS1ThZd7qkd0MIvZP+e7DiD3Yzy/PHolxGqTD7mVLPC5K6SJpdxnbuKul9SXclp/C9FkLoWsZ1UX6jVEP7mCSFEGYq/gA+QdJtURR9U8y6fRR/8fRikjtJWhFF0Qc52hHc/YaKf61BJUtOjbxE0tmVsLmtFL+XkqSkv3yk4vtYu+S2dQhhXnIq58UhhPQ4aE36gV83SNo69dgVir9gTX+Gqza1fbD3ouJO8aOkzxV/gHk0nw1CpavM93htxacMpP0gqWmulZIP0ocqPrUk7V7FpwT8pPgbzyFRFM1Lli2S9JDiwd9PkoZKOilKviIKIRyi+NvuR0pp858kfSdpSinPQ8XVyD6WDNhukHR6FEWrilltjuJvOv+ruO1bKD4IS/Evz/WTbXaRtJ2k7SVdIElRFK1UfNrneMX9c7yk//MfzlCpCrWfpV2k+HPHHWVsZzvFv+69oPi0wmskPRZCaFXG9VE+NbKPrRZF0baSmkk6Ur9+MeodI+nu1cfKpB0/5mjH05JOTOoLm0s6L3l8rVztRIVdKmlsFEWfl/rM0pWnj7VL/ttD0jaS9pF0hKQTksfXpB/8S1LXEMLeIYQGir+Eb7B63RBCZ0l7KP6CPi9q7WAvGc0/LelhxT/9tlJcczAsn+1C5SnvexxCeCpV4N2/mKcsVnygSWumeGCWy58U1xcUDbhCCJtLuk/SnxXvFLaS9NcQQq/kKSdIOi55vIGkoyRNTH6FaSJpuKQzS3ld6bcHPlSimtzHFJ9SNzOKoldLWGeM4m8u10na/rCSX/b066/F10VR9GUURd8prr35Y/J3dFfcB/dW3D+7SrothLBdKe1EBRR4P1vdptMV7+96RVH0Uymvs9oySZ9GUTQ2iqJfoii6T/EXFHuUsh7KqYb3sSJRFC2PouheSYNCCP/j2tRe8T7p7nK043bFX7xOVvyL8wvJ45UxGEFKcnzoLunaMj4/fdGd9sU8pTx9bPUxbXgURQujKPpU0s1Kjmlag34QRdF7ij9rXS/pS8X/dt6R9Hny7+oGSQOiKFpR2raqTHWeM1qTbsmbEUlqnnrsYEmz8t02bjXzPVZ8Oshy2RqEF1VKDYKkSZIucY8dKulN99goSdcn96+XdK1bPiNZbztJvyg+HeArxQfGlcn9DqnnbyhphaTf5/u9yOqthvexRyUtSPWTnxV/67m6j82SdFDq+S2Sv6VVkucpdREOxR/C3kzuD5T0SDGvNzDf70kWb4Xcz5LnHK/4Q9MmObZdXM3eCYpPBUw/NjPdb7llv4+V8LwPJR3iHhsi6UX32OqavY6px+5WUrNXzHZ7JH21Tr7fk6zdJJ2l+MJzq/cVixUPwt6o4PZOkvRv914vVfE1e2spPgtlr9RjZ/vjWGn9QMXU7BWzbovkb9s8ub8q9Td/m/w7+0pSl2r7f5/vNz/PHe9jSYMUXz2nhaRHJI3Pd7u41dz3WNKrkkYovvLTISrl6mKKTx34zYBL8VWgFkvqpl+vCvWh4lM1pfhbog8kbZIs/8PqnVjyt6yfuv1J0hfJ/bqp1xjsD3zcalUfa+H6ydTk4NY8WX6H4lOFmys+ZXOwpP+m1r9E8UWDWiv+hv8lSZcmy7oqPj14uyRvL+l7ST3y/X5k9VbA/ay/4g82W5Sw3fpJG8ZLuiy5XzdZ1lLxQPIYSXUVf9k1X8kXEtxqTR/bVfGVDhsovrLneYp/vWnrnve+pOOL2e59in+1aaL4V+H01ThbKj7+BsVXaZyl5DjMrdL711puXzFC8YVRSr1CawnbWzd5L/skfWyYcl+N825JExWf5tlO8dU3TyhLP0j2P40knaz4S4tGkuqnlu+YPGddxRefGZ88HtzfvJPiwd4GkhpU2//7fL/5ee542yn+yXaB4g8u/5S0Xr7bxa3mvseSOiTbW5YcWLqnlvWXNNs9/3xJL5WwrcOSHcoixd8gDVPyLVKyg7hE0mfJ8nclHV3CdvZWMVfjTO/IuNXOPuaeN1n2apzrSLpH0jeKP4S9LGnn1PL6ik8/Waj4w/poSY1Sy09X/AXFIsUfEs/J93uR5VsB97NPFJ+JsDh1uym1/E7FH37St2NTy7songJgseIasmr7Nry23WpqH1P85dJbyb5m9Smee7nn7Kb4V6OmxazfUvEv0EsUH1OPTC3rlLRtqeJpQc7O9/tQW25aw6txJtvorvizzrKkr3VILbvJ7WuaKR74L1J85sqFkkJZ+oGkY4vZT92ZWv5yqn/erBKmukr+TUSq5qtxrv4jAQAAAAAZUmsv0AIAAAAAWcZgDwAAAAAyiMEeAAAAAGQQgz0AAAAAyCAGewAAAACQQfXK8+RWrVpFHTp0qKKmoLJ9+umn+u6770K+21Ee9LHCQh9DdXj99de/i6Jo3Xy3o6zoY4WHPoaqVnh9bJ2oQ/v2+W4GyuH1N2cU28fKNdjr0KGDpk+fXnmtQpXq3LlzvptQbvSxwkIfQ3UIIczNdxvKgz5WeOhjqGoF18fat9f0lyfnuxkoh9CkRbF9jNM4AQAAACCDGOwBAAAAQAYx2AMAAACADGKwBwAAAAAZxGAPAAAAADKIwR4AAAAAZBCDPQAAAADIIAZ7AAAAAJBBDPYAAAAAIIPq5bsBAAAAAFDdokXzTX5mq91N3u+dV00Oa7eo6iZVOn7ZAwAAAIAMYrAHAAAAABnEYA8AAAAAMoiaPQAAAAC1zqppz5j84bKfTd7vl+XV2ZwqwS97AAAAAJBBDPYAAAAAIIMyexrn3LlzTX7sscdMvvLKK4vuH3PMMWZZnz59TN5pp50quXXIgiiKTH788cdNvuWWW4ruP/HEE2ZZx44dTZ42bZrJLVq0qIQWAqhtli1bZvKCBQtMPvbYY02eNGmSyf379zd5k002Kbrfq1cvs2zLLbc0uWnTpuVqK7JpxYoVJr/yyismjxs3zuT0sbI0Bx10kMk33HCDyW3bti3ztgBJCu07mfzlzytNXjlikMn1Lr+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/TyPI0aMMNnPseznnKVG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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA3sAAACOCAYAAACIehHUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/NK7nSAAAACXBIWXMAAAsTAAALEwEAmpwYAAAdf0lEQVR4nO3deZRU1d3u8WfLPAcEFAUhr7l4MVdwwIRJQULMq66s8IoErsZIuBqNIRAUB4yIIg44BAPKlCVKEAEVccCZlRhwiiJB8PWNQpg1iIRBaGY8949zaM9v21XVRXd1VZ3+ftaqZT11htpNbU/VrnN+tV0QBAIAAAAAJMtR+W4AAAAAAKDyMdgDAAAAgARisAcAAAAACcRgDwAAAAASiMEeAAAAACQQgz0AAAAASCAGewAAAACQQNV6sOec6+Cc+7NzbodzbpVz7r/y3SZUvsp+nZ1zpzrn3nfO7Y7+e2qadds55150zm1zzm1yzj3onKsZWz7NOfexc+4r59wgb9tBzrlDzrldsVuvMp6jp3MucM6NjT02MNrvDufcZufcDOdc44r83Ugtz31sl3c75JybGC27xFu2O+orZ8S2P905tyha/rlzblj0eEvn3Gzn3GfR3/Wmc+77KdowPdrvdyrydyO1Au5jJzvnlkTHuG3OuYXOuZNj217nnPvQObfTObfGOXddbNkJZew7cM5dGy0/xzm3wjm33Tn3b+fcfOfc8RX5u5FaAfexLs6515xzW51zXzjnnnTOtSpjH7Wdc//jnNuY4jl+HvWvy2OPfSt6f9wc3W6tyN+M9Aq4j9V2zj3lnFsb9ZFe3rZ1nHNTovfIrc655+PHIudcs+j4VOKcW+ecu9jbvoVz7vHo797mnJtVkb87W9V2sOfCD9zPSlogqZmkX0p6zDnXPq8NQ6Wq7NfZOVc72t9jkppKmiHp2ejxskyStFlSK0mnSuop6erY8g+ivDTF9m8HQdAwdnvda08tSX+Q9DdvuzcldQ+CoImk/5BUU9JYodLlu4/F+4ekYyXtkfRktGyWt/xqSasV9TfnXHNJL0uaKuloSd+R9Gq064aS3pN0RvR3zZD0gnOuodfeHpJOPJK/FeVTyH1M0meSLora1VzSc5LmxJ9O0s+j5/lPSUOccwOj/a739n2KpK8kzYu2/UjSj4Ig+Jak4yStlDT5SP5mpFfgfayppGmS2klqK2mnpEfK2M11kr5I0Z6mkm6S9N/eovGS6kf7/p6kS51zvyjXH4msFHgfk6Q3JP1M0qYyNh8mqaukjgqPRdskTYwtf0jSfknHSLpE0mTn3Hdjy5+O9nuCpJaS7svqj62oIAiq5U3S/5G0S5KLPfaqpNvz3TZuhfs6SzpX0qfe/tZL+s8U6/+PpPNj+V5JU8tY7w1Jg7zHBkl6I0N7bpR0j6RHJY1NsU5DSX+S9GK+X48k3vLdx7xtL1M4mHMplv9F0uhYvlPSzCza9qWkM2K5pqS/K3wDDCR9J9+vRxJvxdLHov7wa0m702w/QdLEFMtGS/pLimV1JN0l6aN8vx5JvBVLH4uWny5pp/fYt6P32/MkbSxjmykKv+x6XdLlsce3SDozlm+StDjfr0cSb8XSxyRtlNTLe2yypHti+QJJH0f3Gygc6LWPLZ8p6e5YO9dKqpGvf/tqe2YvBaewMyLZKvI6f1fS8iD6PziyPHq8LA9IGuicqx+d8j9P4ZmU8jrNObfFOfeJc26Us5eAtpU0WNKYsjZ0zvVwzu1Q+C1ov6gtqBpV2cfiLpP0J2/bsEFhfzlb4cD/sC6Stjrn3oouYXreOXdCWTuOLo+pLWlV7OHhkhYFQbC8HG1D5SqoPuac2y5pr8Jvu+8sa0PnnJN0lr55duXwsp8r/HY+/vgJ0b73SBqh8MstVI2C6mMxZ+ubfWiiwoHaHn9l59z3JHVWOOAri/Pu8zmw6hRqH/M9LKm7c+4451x9hWfvXoqWtZd0MAiCT2LrfxBrRxdJH0ua4cLL0d9zzvUs5/NWiuo82PtY4eV11znnajnnzlV4iV39/DYLlayyX+eGknZ4j+2Q1CjF+osU/g//pcJvi5ZIeqacz7VI4UGwpcLB2v9VeJnKYRMkjQqCYFdZGwdB8EYQXsbZWuEZxbXlfF5kJ999TFLpYK6nvA/LMT9X+I31mthjrRW+6Q1TeHnJGkmzy9h3Y4XfVN4WBMGO6LE2kq6UdEuGvwcVV/B9LAgvtWwiaYjCs71luVXh546yLsHrofASqKe8/a6P9t1c0s2S/pGujThiBd/HouUdFR5z4rWf/6XwrMn8MtavobCcYkgQBF+VscuXJd3onGvkwprjweJzYK4URR9LYaWkDQrPJH4pqYO+/qK9YfRYqna0Vnh27y8KLx+9X+Hlps2zeP4KqbaDvSAIDkjqq/BU7CZJ10p6QuEHciREtq+zc+6/Y8W7Z5Wxyi5J/g+dNFZ49szf11EK30ieVniav7nC68rHlbPtq4MgWBMEwVdBEKxQeGC5KNr3jyU1CoJgbjn282nUjjmZ1kX28tnHPJcqvOx3TYrl3zhrovBb8PlBELwXBMFeSbdJ6uacaxJrbz1Jz0t6JwiCu2LbPiBpzOHBH3KnWPpYEAQlCs+e/Mk519Jr0xCFffCCIAj2lbH5ZZLmpfnyaqu+rsmpWdY6OHLF0MeiwdhLkoYFQbA4eqyBwrO9Q1Ps72qFZ3/eSbF8qMLj4EqF9V+zxefAnCiGPpbGQwovJT9a4ee5p/X1mb1M7dgjaW0QBA8HQXAgCII5CgeO3bN4/orJ1/WjhXiT9JakK/PdDm6F+zor/HZmo+w14utUxjXiCgd3gaQmscf6SvqwjHW/UbNXxjoDJC2N7j+g8JukTdFtj8IDzrMptu0haUe+/+2ry62q+pi33SeSBqdY1l1SicIvCOKPz5Q0PZabxfuswje3VyTNknSUt+12SZ/H+mCg8McRLs73v391uBVaH4utUzM6Hp0We2xw9Hz/kWKbegq/Ce+dYd+to37WLN///tXhVkh9TOEPs6yVdJX3+KmSDsSOQ1slHYrut1N4Jc222PL9UV97MMXz3ylpdr7/7avLrZD6WGx5WTV7H0r6SSx/KzoWNdfXNXv/K7b8T/q6Zu//SVrt7W95fH85/3fO9wudz5vCHxWoq/AU8giFlzDVyXe7uBXu66ywZmmdwsve6ii8ZGmdpNop1l+t8EdUakYHh/mSHvf2V1fhr2deEd0/Klp2nqRjovv/OzrYjI5yI4WXAxy+zVX4q2LNouWXSDohut9W0l8lPZ3v1yKpt3z2sWibbipjMBdbPk1hfYL/eO/og9CpkmpFfWhxtKyWwjN6z0iqWca2Lb0+GCisTaiX79cjibdC7WOSfijpNEk1FH6bPUHhL3TWjZZfovBDdoc0+75Y4Qd55z1+oaSTFF6F1ELhWYCl+X4tknor4D52vKR/ShpRxjY1vePQhVH/Ozbqk9/ylr8l6Rp9/YXWiQrP1tRQ+J67RdJ38/1aJPVWqH0sWlYnattGhQPJuoePSQovPZ+n8FL1WgrrQz+NbTtH4VnhBgq/XN1xuB8p/BJ1m8KrF2oovEJrq6TmVfbvnu8XPs+d7t7oBdil8HQsvySXwFtlv84KP9i8r/Db66Wy32DfJOmlWD5V4a9/bYveRJ5QNICLlr+u8ENy/NYrWnafwjMnJQoHjWMk1UrRpkcV+zVOSXdEB6yS6L/TJB2d79ciqbd89rHosalK8aua0RvWdkk/SLH8VwrrELYpHNy1iR7vGfXH3dHfdfh2Vor9BBxDq18fk9RfYR3dLoVndl+Q1DG2fI3CMy/xPjTF28crKuMX+ST9Jtq+ROGAcY6ktvl+LZJ6K+A+Njo6vsT70K4Uz9lLZfwaZ2z567K/xvlThYPD3ZKWKZzqI++vRVJvhdrHomVr9c3PY+2iZUcrvMJls8L30zckfS+2bTOFX4yWKPxF0Iu9fZ8laUX0dy9RivfRXN0Oj1gBAAAAAAlSbX+gBQAAAACSjMEeAAAAACQQgz0AAAAASCAGewAAAACQQAz2AAAAACCBamazcvPmzYN27drlqCmobGvXrtWWLVtcvtuRDfpYcaGPoSq8//77W4IgaJHvdpQXfaz40MeQa/Qx5FqqPpbVYK9du3ZasmRJ5bUKOdW5c+d8NyFr9LHiQh9DVXDOrct3G7JBHys+9DHkGn0MuZaqj3EZJwAAAAAkEIM9AAAAAEggBnsAAAAAkEAM9gAAAAAggRjsAQAAAEACMdgDAAAAgARisAcAAAAACcRgDwAAAAASiMEeAAAAACQQgz0AAAAASCAGewAAAACQQAz2AAAAACCBGOwBAAAAQAIx2AMAAACABGKwBwAAAAAJxGAPAAAAABKoZr4bAAAACsPSpUtNPvPMM0vvf/XVV2bZUUfZ74vvv/9+k6+88kqT69WrVxlNBABkgTN7AAAAAJBADPYAAAAAIIEY7AEAAABAAlWbmr27777b5Dlz5pTeX7FiRdpt/TqFtm3bmvzII4+YfM455xxJE1Hkdu/ebfLtt99eev+DDz4wy1566SWTa9WqZfJ7771ncqdOnSqjiShwQRCY/OGHH5q8aNEik+P9yu8zAwcONHnQoEEmH3PMMUfaTBSRkpISk1977TWTZ86cabJ/bHLOld73a/TiyyRpxIgRJr/88ssmjx071uTOnTunajYAVJr4cXDy5Mlm2ZgxY0zeuXOnyZdffrnJ06ZNM9k/DhYizuwBAAAAQAIx2AMAAACABGKwBwAAAAAJlJiavT179pg8evRok/35f+LX2Ga63tavU9i4caPJV1xxhcl+DSBzCyXTu+++a/KAAQNMXrduXcpt/T538OBBk3v27Gny+vXrTW7cuHG524nCdeDAAZP79+9v8rPPPnvE+162bJnJ99xzj8lTp041uV+/fiYXQx0CvmnXrl0m//SnPzX51VdfPeJ9Dx8+3GS/j/zxj380eeHChSY3bdrU5NmzZx9xW5A//u8YbNiwwWT/dR03bpzJ27dvN9n/jJXOQw89ZPJVV11V7m1Rffi/oRCvu9u8ebNZ5tcpHzp0yORevXqZPGXKFJNr1KhxpM2sMpzZAwAAAIAEYrAHAAAAAAnEYA8AAAAAEigxNXuzZs0y+fe//32VPbc/V1Dt2rWr7LlRddasWWNyjx49TPbr7uJ1dUOHDjXLHn30UZP92he/BsKfC+vXv/515gaj4Pi1AP5xK9savQYNGpTe9+v/9u/fb/LWrVtN9usD3377bZO7dOlist/2YqhTqI78Y0OmGr369eub7M/HOGrUqNL7LVq0SLuvkSNHmjx+/HiTf/GLX6TdHoXJn3dsyJAhJvvHsUwyzdeYznXXXWfyli1b0i6vU6dOVm1DcdqxY4fJ/nzX8ex/nqpZ0w6F/Pltk4AzewAAAACQQAz2AAAAACCBGOwBAAAAQAIVbc2ePy/GsGHDjnhfHTt2NHngwIEmB0Fg8pw5c0w+//zzTaaWJRlKSkpM9mtZ/Bo9/xrx+fPnl97358Xz5waaPn26ybfccovJ/vxV1OwVp6efftrkTDVMzZs3N/mZZ54xOV4v7PfXX/7ylybPmzcv7XP5tV0rV640+cUXXzR5xowZJlOrnB+vvPKKyc8991za9Rs1amTyU089ZfIPfvCDI25Ls2bNTL799tuz2t6vM/Vzw4YNj6xhyMqXX35p8rnnnmvye++9Z3KmmrtLL73UZL9ONF4/3LZtW7PMn9vx+eefN/nWW281uXv37ib778tIpgsvvNDkk046yeR777239H6meR39406rVq1MLsY5aDmzBwAAAAAJxGAPAAAAABKIwR4AAAAAJFDR1uz5dQl79+5Nu36nTp1Mjten+HUxmdxwww1ZrY/i9Mgjj5i8ePFik/3aggULFphcr169lPv+97//bfJ9992Xti3btm1LuxyFad++fSbfcccdadf357bz64P9Phfnzyflz33l10/17t3b5NGjR6dtm2/SpEkmU7NXNfw5oAYMGGDyrl27TPZr9J588kmTK1KjV9n+8Ic/mOzXmT744IMm+3PconJceeWVJi9ZsiTt+m3atDH5r3/9q8mtW7c2OVPNVNzcuXNNHjNmjMnjxo0z+Sc/+YnJixYtMvlf//qXyeedd16524LCsXDhQpP9z2f+Z6Z0fc7/XY6JEyea/Nvf/rbc+ypUxddiAAAAAEBGDPYAAAAAIIEY7AEAAABAAhVNzd6ePXtM9ueEyjTvxdKlSyu9TUi2hx56KO3ySy65xOR0NXp+Tak/B5o/r5GPefWKk1/H+cEHH5jcsmVLk7Op0cvEr+Hr0KGDyd26dTPZnwMwkwYNGhxZw1Ahy5cvN9mv0fP17dvX5D59+lR2k47Y6tWrTfbrQDds2GDyp59+ajI1e7mxfft2k/2aJv+45L+Olck/jt14440mT5gwwWR/vtEzzjjDZP9v8edVfvzxx4+oncgt/3W94oorTL755ptNzub9ya+t9+eQff/998u9r0LFmT0AAAAASCAGewAAAACQQAz2AAAAACCBiqZmr1atWiYPHz7cZH/+qltuucXkv//97yafdtpp5X7uAwcOmOzXDzZu3Ljc+0Lx8K8R940aNSrt8nidXv/+/c2yv/3tb1m1xa87QHFYsWJF2uV+LWZFavSydfrpp5ucqWbPP2YW41xDxWj//v0mjxw5Mu36/rxh/pxRheSmm24y2a/R8+fA9etMkRv+byD4+Wc/+1lVNsfw543051z+4Q9/mNX+hg4dWuE2IfdWrVpl8rp160y+7LLLyr2vQ4cOmez//oIvCfXpvFsDAAAAQAIx2AMAAACABGKwBwAAAAAJVDQ1ezVr2qaeeOKJJvvzlI0bN87kzz77zOQ2bdqU+7kPHjxosl9Dce2115r8m9/8xmR/nhgkg3+NuF/TdNddd5XezzSPnu+iiy4yuXfv3lm2Dvnwj3/8w+RMczZVZY2eL9P8bL4777zTZP+YjNx48cUXTfbnmvM9+OCDJjds2LDS21Refm3MzJkzTV6yZEna7X/1q1+Z3KJFi8ppGCrk5JNPzncTSuXzGIrCccwxx6RdvnLlytL7fq38woULTfbr0ZNw3OHMHgAAAAAkEIM9AAAAAEigor0Op0ePHib7l1JOmjTJZP9SzN27d5fer1+/ftrn8n8O2v8Z4jVr1pjMZZvJEL8MU5IuvfRSk5944om0OW7QoEEm+5cC+5f7+ZfI+X0Ohal9+/Ym+9MbxC8lkaTnn3/e5Isvvthkf8qZinjnnXdMvv/++9Ou7x8Xu3btWmltQfk9++yz+W5CufnvlWPHjjX54Ycfzmp/PXv2rHCbUPnuu+8+kwcOHJinlkjNmjUzuXXr1iZv3LjRZL+Eh2mNioN/maY/HYJf+tKhQweTp0yZUnp/8ODBZtk///lPk48//niT69Wrl11jCxBn9gAAAAAggRjsAQAAAEACMdgDAAAAgAQq2pq9Ro0amTxmzBiThw4davKqVatMjv90cOPGjdM+V40aNUz266deeeUVk/fs2WNyEq73rY78+qkf/ehHJr/99ttpt+/cuXPpff96c7+Wy9erV69ytBCFxv/JZn96jrlz55o8b948k/3azvHjx5vcsmXLcrelpKTE5AceeMDkAwcOpN3eryNt0qRJuZ8bleeZZ54xOQgCkzt27Ghy06ZNc9aW1atXm/z666+bPGLECJN37NiR1f796Ws4DuaH//+63+f8z1OzZs0yecCAASZXZJqWffv2mfzqq6+a3Ldv36z2569fmXXRyJ1jjz3W5I8++sjk66+/3uQFCxaYPGzYsNL7v/vd78wyv57dr/usW7dudo0tQJzZAwAAAIAEYrAHAAAAAAnEYA8AAAAAEqhoa/Yyad68edpcmeK1WZJUu3btnD0Xqo5fm+n3oR//+Mfl3tehQ4dMXr58edr1/TocFCd/Dqi2bduavG7dOpP9Ojm/PsWfI+roo49O+dx+H9u8eXPatl5zzTUmZ9O/kTv+ccjPfv26X2OeiV/78sknn6Rc169j9ucL9WU7P2ifPn2yWh+5MXXqVJPXrl1r8rvvvmuyX5v83HPPmXz++eebfNZZZ6V87kzHwDfffNPkbPtYly5dslofhcl/L5w9e3a5t43Psy19sxb55ptvPvKGFSjO7AEAAABAAjHYAwAAAIAEYrAHAAAAAAmU2Jq9itiwYUNW6/t1M9nWTCD5Pv7443w3AXnw/e9/32S/rsCfu3Hnzp0mb9myJW2uTN26dTPZnzMQ+dG7d2+T/Xn33nrrLZPbt29vcp06ddLu3+9Tfj1L3Jlnnmlyjx49TPb7uz//rV8f6LvooovSLkfV8Ocefu2110weMmSIyY899pjJ/vyhfvbn7cum7q579+4mf/bZZyb79YWA79FHHzXZP+b5x9wk4N0cAAAAABKIwR4AAAAAJBCDPQAAAABIIGr2yvDCCy+Y7F9f7mvYsGEum4MEWLVqVb6bgALQtWtXk/16E39OqREjRpi8Zs0ak+vVq1d6369l8etEM9Uit2jRIu1y5IdfDzVq1CiTH374YZM///zzrPZ/zjnnmPztb3/b5L59+5be79Wrl1lWv379tPu+++670y735zw75ZRT0q6P/PA/40yfPt3k2267zeS5c+em3Z8/T99xxx1Xet+v3+vXr5/Jfl3o8OHDTZ42bVra5wbWr19vsl/X3KBBg6psTpXgzB4AAAAAJBCDPQAAAABIIAZ7AAAAAJBA1OxJ2rNnj8ljx4412b+G3M9XXXVVbhoGINH8WpgLL7zQ5AsuuMDkffv2mRyfC8/f18iRI03OVD91+umnp28s8qJu3bom33vvvSZfc801Ju/fvz+r/fu1mpnq8CpT586dTc40JyAKgz8HZ9u2bU2+/vrrq6wtV199tclTp05Nu75f2wz477tJxJk9AAAAAEggBnsAAAAAkEAM9gAAAAAggajZk7Rs2TKTN23alHZ9f56X+BwxQFl27dplsj93Y3y+NElq165drpuEIuDXMKWrafJrtWbPnp1237179za5Kmu1UHlatWqV7yaU2r17t8klJSVp19+5c6fJe/fuNdmvVwR8kyZNMtn/TQVfmzZtctkcFAH/uNO4ceM8taTqcGYPAAAAABKIwR4AAAAAJBCDPQAAAABIIGr2jsCYMWNMZm4gZOLP/ePXFfg1e8cee2zO24Rk2bJli8nr1q1Lu/5JJ51ksj93FpCtAwcOmJxpzr8ZM2aYXLt2bZMnT55cOQ1DYvh1oPPnz0+7fseOHXPZHBSJ+By18+bNM8v841AS8e4OAAAAAAnEYA8AAAAAEojBHgAAAAAkEDV7+mYNnj8Hmp8bNGiQ8zYh2fw+NXTo0Dy1BNWVX18FVFSTJk1Mbtq0qcmZ6khPOeWUSm8TkuXQoUMmf/HFF2nXHzhwYC6bgyKxcePG0vubN282yzp16lTVzalynNkDAAAAgARisAcAAAAACcRgDwAAAAASqNrW7MXnpFq2bJlZ5s+BBlS2TPPsAbk2ePDgfDcB1VyNGjVM7tatW55agmKxd+9ek/36d1+m5ageVq5cWXrf/7xVv379qm5OlePMHgAAAAAkEIM9AAAAAEigansZ55dffll6P9NP97Zq1crk6vAzrQCSbfHixSZ37do1Ty1BUvXr189kv2Ri/PjxJp966qk5bhGK3fTp003OVHZDWQ4kadOmTaX3+/TpY5Y1bty4qptT5TizBwAAAAAJxGAPAAAAABKIwR4AAAAAJFC1rdk74YQTSu/fcMMNZtm4ceNMXrBggcn8TD6y1bFjR5P9eil/OZCtBg0amNy/f3+T//znP5tct27dnLcJ1dvIkSPTZiDXBg4cmO8moAAsXbq09H517BOc2QMAAACABGKwBwAAAAAJxGAPAAAAABKo2tbs1az59Z9+xx13mGV+Bipq4sSJaTNQUU2aNDH5iSeeyFNLACA3unXrlna5/97aunXrXDYHRWLChAn5bkJecWYPAAAAABKIwR4AAAAAJBCDPQAAAABIoGpbswcAAIDicfbZZ5t88ODBPLUEKB6c2QMAAACABGKwBwAAAAAJxGAPAAAAABLIBUFQ/pWd+0LSutw1B5WsbRAELfLdiGzQx4oOfQxVoaj6GX2sKNHHkGv0MeRamX0sq8EeAAAAAKA4cBknAAAAACQQgz0AAAAASCAGewAAAACQQAz2AAAAACCBGOwBAAAAQAIx2AMAAACABGKwBwAAAAAJxGAPAAAAABKIwR4AAAAAJND/BxT74xJhNZXPAAAAAElFTkSuQmCC\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": "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5PHjx5t8zz33lHjbp512msljxowxuVmzZiXeFpIr2PKjyWtO6Ziafn7RCjNvzppNOb1Ws5pVTR445nOTq551eU7bR2H45JNPUtPnnnuumTdr1iyTgyAwuXt3e+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+OXOPm3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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" + } + ] + } + ] +} \ No newline at end of file From 8719b0c6b11744419db98f8e203eb571851edbd3 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 7eab295be4cada66a88e1bfd44f7fbdde198f06a 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 ca3a803c4204551b1f7c3870aae09c975d2e0acd 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 7a5321c4ae006d65fd4ee35d50c6ba5eeb5cb601 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 f7c1d4c402dc3efe8b48a370df242d3bd0c6167b 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 caa206aa9b54841f2a863cd77e44884560cd5809 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):