From d403acaaacf57ec4d24ab497ffe3b8e5e90866e6 Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Mon, 20 Nov 2023 10:50:37 -0500 Subject: [PATCH 1/3] add some tests; set up github actions CI; load synonyms file if available; modify buffer for geo dataframe cell lookups --- lib/inat_inferrer.py | 43 ++-- lib/model_results.py | 220 ------------------- lib/res_layer.py | 28 +++ lib/taxon.py | 2 +- lib/tf_gp_elev_model.py | 111 +++------- lib/tf_gp_model.py | 96 -------- lib/vision_inferrer.py | 32 +-- pytest.ini | 4 + tests/__init__.py | 0 tests/conftest.py | 39 ++++ tests/fixtures/elevation.csv | 2 + tests/fixtures/lamprocapnos_spectabilis.jpeg | Bin 0 -> 75493 bytes tests/fixtures/synonyms.csv | 2 + tests/fixtures/taxon_ranges/7.csv | 8 + tests/fixtures/taxonomy.csv | 21 ++ tests/fixtures/thresholds.csv | 3 + tests/test_inat_inferrer.py | 106 +++++++++ tests/test_model_taxonomy.py | 54 +++++ tests/test_model_taxonomy_dataframe.py | 56 +++++ tests/test_res_layer.py | 31 +++ tests/test_taxon.py | 17 ++ tests/test_tf_gp_elev_model.py | 43 ++++ tests/test_vision_inferrer.py | 26 +++ 23 files changed, 503 insertions(+), 441 deletions(-) delete mode 100644 lib/model_results.py create mode 100644 lib/res_layer.py delete mode 100644 lib/tf_gp_model.py create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/elevation.csv create mode 100644 tests/fixtures/lamprocapnos_spectabilis.jpeg create mode 100644 tests/fixtures/synonyms.csv create mode 100644 tests/fixtures/taxon_ranges/7.csv create mode 100644 tests/fixtures/taxonomy.csv create mode 100644 tests/fixtures/thresholds.csv create mode 100644 tests/test_inat_inferrer.py create mode 100644 tests/test_model_taxonomy.py create mode 100644 tests/test_model_taxonomy_dataframe.py create mode 100644 tests/test_res_layer.py create mode 100644 tests/test_taxon.py create mode 100644 tests/test_tf_gp_elev_model.py create mode 100644 tests/test_vision_inferrer.py diff --git a/lib/inat_inferrer.py b/lib/inat_inferrer.py index 1a6fb67..2ae5e92 100644 --- a/lib/inat_inferrer.py +++ b/lib/inat_inferrer.py @@ -24,6 +24,7 @@ class InatInferrer: def __init__(self, config): self.config = config self.setup_taxonomy(config) + self.setup_synonyms(config) self.setup_vision_model(config) self.setup_elevation_dataframe(config) self.setup_geo_model(config) @@ -35,8 +36,15 @@ def setup_taxonomy(self, config): config["tf_elev_thresholds"] if "tf_elev_thresholds" in config else None ) + def setup_synonyms(self, config): + self.synonyms = None + if "synonyms_path" in config: + if not os.path.exists(config["synonyms_path"]): + return None + self.synonyms = pd.read_csv(config["synonyms_path"]) + def setup_vision_model(self, config): - self.vision_inferrer = VisionInferrer(config["vision_model_path"], self.taxonomy) + self.vision_inferrer = VisionInferrer(config["vision_model_path"]) def setup_elevation_dataframe(self, config): self.geo_elevation_cells = None @@ -72,20 +80,6 @@ def setup_geo_model(self, config): elevation=list(self.geo_elevation_cells.elevation) ) - def prepare_image_for_inference(self, file_path): - mime_type = magic.from_file(file_path, mime=True) - # attempt to convert non jpegs - if mime_type != "image/jpeg": - im = Image.open(file_path) - image = im.convert("RGB") - else: - image = tf.io.read_file(file_path) - image = tf.image.decode_jpeg(image, channels=3) - image = tf.image.convert_image_dtype(image, tf.float32) - image = tf.image.central_crop(image, 0.875) - image = tf.image.resize(image, [299, 299], tf.image.ResizeMethod.NEAREST_NEIGHBOR) - return tf.expand_dims(image, 0) - def vision_predict(self, image, debug=False): if debug: start_time = time.time() @@ -125,7 +119,7 @@ def predictions_for_image(self, file_path, lat, lng, filter_taxon, score_without debug=False): if debug: start_time = time.time() - image = self.prepare_image_for_inference(file_path) + image = InatInferrer.prepare_image_for_inference(file_path) raw_vision_scores = self.vision_predict(image, debug) raw_geo_scores = self.geo_model_predict(lat, lng, debug) top100 = self.combine_results(raw_vision_scores, raw_geo_scores, filter_taxon, @@ -359,6 +353,21 @@ def h3_04_bounds(self, taxon_id): "nelng": geomodel_results["lng"].max() } + @staticmethod + def prepare_image_for_inference(file_path): + mime_type = magic.from_file(file_path, mime=True) + # attempt to convert non jpegs + if mime_type != "image/jpeg": + im = Image.open(file_path) + image = im.convert("RGB") + else: + image = tf.io.read_file(file_path) + image = tf.image.decode_jpeg(image, channels=3) + image = tf.image.convert_image_dtype(image, tf.float32) + image = tf.image.central_crop(image, 0.875) + image = tf.image.resize(image, [299, 299], tf.image.ResizeMethod.NEAREST_NEIGHBOR) + return tf.expand_dims(image, 0) + @staticmethod def add_lat_lng_to_h3_geo_dataframe(geo_df): geo_df = geo_df.h3.h3_to_geo() @@ -373,7 +382,7 @@ def filter_geo_dataframe_by_bounds(geo_df, bounds): # centroid outside the bounds while part of the polygon is within the bounds. Add # a small buffer to ensure this returns any cell whose polygon is # even partially within the bounds - buffer = 0.6 + buffer = 1.3 # similarly, the centroid may be on the other side of the antimedirian, so lookup # cells that might be just over the antimeridian on either side diff --git a/lib/model_results.py b/lib/model_results.py deleted file mode 100644 index 8b77f3a..0000000 --- a/lib/model_results.py +++ /dev/null @@ -1,220 +0,0 @@ -class ModelResults: - - def __init__(self, vision_results, geo_results, taxonomy): - self.taxonomy = taxonomy - self.vision_results = vision_results - self.geo_results = geo_results - # common_ancestor is currently being used as a first-pass filter to remove - # the least likely results and reduce the number of taxa whose scores to combine. - # NOTE: This may not be helpful and needs testing for accuracy and processing time - self.common_ancestor_threshold = 0.9 - self.common_ancestor_rank_level_threshold = 50 - # fine_common_ancestor is currently being used to return as a high-confidence - # non-leaf taxon that may get presented to a user - self.fine_common_ancestor_threshold = 0.85 - self.fine_common_ancestor_rank_level_threshold = 20 - # vision scores are raw unnormalized scores from the vision model - # geo scores are raw unnormalized scores from the vision model - # combined scores are the unnormalized product of vision and geo scores - # combined_agg scores are the unnormalized sum of combined scores the descendants of a taxon - self.scores = { - "vision": {}, - "vision_agg": {}, - "geo": {}, - "combined": {}, - "combined_agg": {}, - "recursive": {} - } - - self.aggregate_scores() - recursive_results = self.recursive_results() - self.scores["recursive"] = {} - top_x = sorted( - recursive_results, key=lambda x: self.scores["combined_agg"][x], reverse=True)[:100] - for index, arg in enumerate(top_x): - self.scores["recursive"][arg] = self.scores["combined_agg"][arg] - - def aggregate_scores(self): - self.ancestor_scores = {} - self.vision_sum_scores = 0 - # loop through all vision results, calculating the sum of vision scores for each ancestor - for arg in self.vision_results: - taxon = self.taxonomy.taxa[arg] - self.vision_sum_scores += self.vision_results[arg] - # add the score of this leaf result to all of its ancestors - for ancestor in taxon.ancestors: - if ancestor not in self.ancestor_scores: - self.ancestor_scores[ancestor] = 0 - self.ancestor_scores[ancestor] += self.vision_results[arg] - - # using only the vision results, calculate a highly-likely visual common ancestor - # that is no narrower than self.common_ancestor_rank_level_threshold (currently Class). - # Taxa outside the highly-likely visual common ancestor will be ignored - # NOTE: This may not be helpful and needs testing for accuracy and processing time - self.common_ancestor = self.calculate_common_ancestor( - self.ancestor_scores, self.vision_sum_scores, self.common_ancestor_threshold, - self.common_ancestor_rank_level_threshold) - - # loop through all taxa and combine geo and vision scores, calculating - # aggregate scores for non-leaf taxa as well - self.aggregate_scores_recursive() - - # after combining vision and geo scores, look for a potentially more-specific - # common ancestor using the combined scores and different thresholds. - # 0 represents the root taxon, so the combined aggretate score for 0 - # represents the sum of all combined scores of all leaves - sum_of_all_combined_scores = self.scores["combined_agg"][0] - self.fine_common_ancestor = self.calculate_common_ancestor( - self.scores["combined_agg"], sum_of_all_combined_scores, - self.fine_common_ancestor_threshold, self.fine_common_ancestor_rank_level_threshold) - - # given a set of scores, the sum of those scores (so we only need to calculate it once), - # a score threshold, a rank_level threshold, and optionall a taxon (if none is given it starts - # at the root of the taxonomy), resursively find the most specific node that is above - # the specified thresholds - def calculate_common_ancestor(self, ancestor_scores, sum_scores, score_threshold, - rank_level_threshold, taxon=None): - common_ancestor = taxon - taxon_id = 0 if taxon is None else taxon.id - # sort children from most- to least-likely - for child_id in sorted( - self.taxonomy.taxon_children[taxon_id], - key=lambda x: (ancestor_scores[x] if x in ancestor_scores else 0), - reverse=True): - # the child has no scores. This could be the result of pruning scores - # earlier on based on iconic_taxon. If there is no score, skip this branch - if child_id not in ancestor_scores: - break - # if the ratio of this score to the sum of all scores is below the - # score_threshold, then this taxon and its whole branch can be skipped - if (ancestor_scores[child_id] / sum_scores) < score_threshold: - break - child_taxon = self.taxonomy.taxa[child_id] - # if this taxon is below the rank_level_threshold, this branch can be skipped - if child_taxon.rank_level < rank_level_threshold: - continue - # this is a leaf, so return it - if child_id not in self.taxonomy.taxon_children: - return child_taxon - return self.calculate_common_ancestor(ancestor_scores, sum_scores, score_threshold, - rank_level_threshold, child_taxon) - return common_ancestor - - # takes a taxonID of the branch to score, and an indication if the branch is - # already known to be within the common ancestor branch - def aggregate_scores_recursive(self, taxon_id=0, in_common_ancestor=False): - vision_score = 0 - geo_score = 0 - combined_agg_score = 0 - # loop through all children of this iteration's taxon, or root taxon - for child_id in self.taxonomy.taxon_children[taxon_id]: - is_common_ancestor = False - # if there is a common ancestor, and this taxon is not yet known to be in it - if self.common_ancestor and not in_common_ancestor: - if child_id == self.common_ancestor.id: - # keep track that this taxon is the common ancestor, and resursive calls from - # this node down are also within the common ancestor - is_common_ancestor = True - elif child_id not in self.common_ancestor.ancestors: - # skip taxa that are not in the common ancestor branch - continue - # this taxon has children in the model - if child_id in self.taxonomy.taxon_children: - self.aggregate_scores_recursive(child_id, in_common_ancestor or is_common_ancestor) - else: - # this is a leaf taxon in the model - # record the vision and geo scores, using very low default scores for missing values - if child_id in self.vision_results: - child_vision_score = self.vision_results[child_id] - else: - child_vision_score = 0.00000001 - if len(self.geo_results) == 0: - child_geo_score = 1 - elif child_id in self.geo_results: - child_geo_score = self.geo_results[child_id] - else: - child_geo_score = 0.00000001 - self.scores["vision"][child_id] = child_vision_score - self.scores["vision_agg"][child_id] = child_vision_score - self.scores["geo"][child_id] = child_geo_score - # simple muliplication of vision and geo score to get a combined score - self.scores["combined"][child_id] = child_vision_score * child_geo_score - # also keeping track of scores aggregated up the tree. Since this is a leaf node, - # the aggregate branch score is equal to the combined score - self.scores["combined_agg"][child_id] = self.scores["combined"][child_id] - - child_vision_score = self.scores["vision_agg"][child_id] - child_geo_score = self.scores["geo"][child_id] - child_combined_agg_score = self.scores["combined_agg"][child_id] - - # vision scores can just be summed as they'll add up to 1 - vision_score += child_vision_score - # all maintain a sum of the combined scores in the branch. This will not add - # up to 1 and can be a wide range of values. Useful when compared to the sum - # of the combined scores for the entire tree - combined_agg_score += child_combined_agg_score - - # geo scores do not add up to 1, so have the geo score of a - # taxon be the max of the scores of its children - if child_geo_score > geo_score: - geo_score = child_geo_score - # scores have been calculated and summed for all this taxon's descendants, - # so reccord the final scores for this branch - self.scores["vision_agg"][taxon_id] = vision_score - self.scores["geo"][taxon_id] = geo_score - self.scores["combined_agg"][taxon_id] = combined_agg_score - - def recursive_results(self, taxon_id=0): - children = self.taxonomy.taxon_children[taxon_id] - # 0 represents the root taxon, so the combined aggretate score for 0 - # represents the sum of all combined scores of all leaves - sum_of_all_combined_scores = self.scores["combined_agg"][0] - # ignore children whose combined score ratio is less than 0.01 - scored_children = list(filter(lambda x: x in self.scores["combined_agg"] and ( - (self.scores["combined_agg"][x] / sum_of_all_combined_scores) >= 0.0001), children)) - if not scored_children: - return [taxon_id] - # sort children by score from most- to least-likely - scored_children = sorted(scored_children, key=lambda x: self.scores["combined_agg"][x], - reverse=True) - - results = [] - for child_id in scored_children: - # recursively repeat for descendants - if child_id in self.taxonomy.taxon_children: - child_results = self.recursive_results(child_id) - if child_results: - results = results + child_results - else: - results.append(child_id) - return results - - # prints to the console a tree prepresenting the most likely taxa and their - # aggregate combined score ratio. e.g. if all combined scores add up to 0.5 - # and a taxon has a combined score of 0.1, its combined score ratio will be 20%, or 0.2 - def print(self, taxon_id=0, ancestor_prefix=""): - children = self.taxonomy.taxon_children[taxon_id] - # 0 represents the root taxon, so the combined aggretate score for 0 - # represents the sum of all combined scores of all leaves - sum_of_all_commbined_scores = self.scores["combined_agg"][0] - # ignore children whose combined score ration is less than 0.01 - scored_children = list(filter(lambda x: x in self.scores["combined_agg"] and ( - (self.scores["combined_agg"][x] / sum_of_all_commbined_scores) >= 0.005), children)) - # sort children by score from most- to least-likely - scored_children = sorted(scored_children, key=lambda x: self.scores["combined_agg"][x], - reverse=True) - - index = 0 - for child_id in scored_children: - # some logic for visual tree indicators when printing - last_in_branch = (index == len(scored_children) - 1) - index += 1 - icon = "└──" if last_in_branch else "├──" - prefixIcon = " " if last_in_branch else "│ " - taxon = self.taxonomy.taxa[child_id] - # print the taxon with its combined score ratio - combined_score_ratio = self.scores["combined_agg"][child_id] / self.scores["combined_agg"][0] - print(f'{ancestor_prefix}{icon}{taxon.name} ({child_id}) :: {combined_score_ratio:.10f}') - # recursively repeat for descendants - if child_id in self.taxonomy.taxon_children: - self.print(child_id, f'{ancestor_prefix}{prefixIcon}') diff --git a/lib/res_layer.py b/lib/res_layer.py new file mode 100644 index 0000000..dc7ade3 --- /dev/null +++ b/lib/res_layer.py @@ -0,0 +1,28 @@ +import tensorflow as tf + + +class ResLayer(tf.keras.layers.Layer): + def __init__(self): + super(ResLayer, self).__init__() + self.w1 = tf.keras.layers.Dense( + 256, + activation="relu", + kernel_initializer="he_normal" + ) + self.w2 = tf.keras.layers.Dense( + 256, + activation="relu", + kernel_initializer="he_normal" + ) + self.dropout = tf.keras.layers.Dropout(rate=0.5) + self.add = tf.keras.layers.Add() + + def call(self, inputs): + x = self.w1(inputs) + x = self.dropout(x) + x = self.w2(x) + x = self.add([x, inputs]) + return x + + def get_config(self): + return {} diff --git a/lib/taxon.py b/lib/taxon.py index bc8b703..63e169a 100644 --- a/lib/taxon.py +++ b/lib/taxon.py @@ -14,7 +14,7 @@ class Taxon: def __init__(self, row): for key in row: - setattr(self, key, row[key]) + self.set(key, row[key]) def set(self, attr, val): setattr(self, attr, val) diff --git a/lib/tf_gp_elev_model.py b/lib/tf_gp_elev_model.py index ad9ebbd..ebaad95 100644 --- a/lib/tf_gp_elev_model.py +++ b/lib/tf_gp_elev_model.py @@ -2,37 +2,11 @@ import numpy as np import math import os +from lib.res_layer import ResLayer os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" -class ResLayer(tf.keras.layers.Layer): - def __init__(self): - super(ResLayer, self).__init__() - self.w1 = tf.keras.layers.Dense( - 256, - activation="relu", - kernel_initializer="he_normal" - ) - self.w2 = tf.keras.layers.Dense( - 256, - activation="relu", - kernel_initializer="he_normal" - ) - self.dropout = tf.keras.layers.Dropout(rate=0.5) - self.add = tf.keras.layers.Add() - - def call(self, inputs): - x = self.w1(inputs) - x = self.dropout(x) - x = self.w2(x) - x = self.add([x, inputs]) - return x - - def get_config(self): - return {} - - class TFGeoPriorModelElev: def __init__(self, model_path): @@ -48,26 +22,9 @@ def __init__(self, model_path): ) def predict(self, latitude, longitude, elevation): - norm_lat = latitude / 90.0 - norm_lng = longitude / 180.0 - norm_loc = tf.stack([norm_lng, norm_lat]) - - if elevation > 0: - norm_elev = elevation / 6574 - elif elevation == 0: - norm_elev = 0.0 - else: - norm_elev = elevation / 32768 - - norm_elev = tf.expand_dims(norm_elev, axis=0) - encoded_loc = tf.concat([ - tf.sin(norm_loc * math.pi), - tf.cos(norm_loc * math.pi), - norm_elev - ], axis=0) - + encoded_loc = TFGeoPriorModelElev.encode_loc([latitude], [longitude], [elevation]) return self.gpmodel(tf.convert_to_tensor( - tf.expand_dims(encoded_loc, axis=0) + tf.expand_dims(encoded_loc[0], axis=0) ), training=False)[0] def features_for_one_class_elevation(self, latitude, longitude, elevation): @@ -82,36 +39,8 @@ def features_for_one_class_elevation(self, latitude, longitude, elevation): Returns: numpy array: scores for class of interest at each location """ - def encode_loc(latitude, longitude, elevation): - latitude = np.array(latitude) - longitude = np.array(longitude) - elevation = np.array(elevation) - elevation = elevation.astype("float32") - grid_lon = longitude.astype('float32') / 180.0 - grid_lat = latitude.astype('float32') / 90.0 - - elevation[elevation > 0] = elevation[elevation > 0] / 6574.0 - elevation[elevation < 0] = elevation[elevation < 0] / 32768.0 - norm_elev = elevation - - if np.isscalar(grid_lon): - grid_lon = np.array([grid_lon]) - if np.isscalar(grid_lat): - grid_lat = np.array([grid_lat]) - if np.isscalar(norm_elev): - norm_elev = np.array([norm_elev]) - - norm_loc = tf.stack([grid_lon, grid_lat], axis=1) - - encoded_loc = tf.concat([ - tf.sin(norm_loc * math.pi), - tf.cos(norm_loc * math.pi), - tf.expand_dims(norm_elev, axis=1), - - ], axis=1) - return encoded_loc - - encoded_loc = encode_loc(latitude, longitude, elevation) + + encoded_loc = TFGeoPriorModelElev.encode_loc(latitude, longitude, elevation) loc_emb = self.gpmodel.layers[0](encoded_loc) # res layers - feature extraction @@ -131,3 +60,33 @@ def eval_one_class_elevation_from_features(self, features, class_of_interest): transpose_b=True ) ).numpy() + + @staticmethod + def encode_loc(latitude, longitude, elevation): + latitude = np.array(latitude) + longitude = np.array(longitude) + elevation = np.array(elevation) + elevation = elevation.astype("float32") + grid_lon = longitude.astype("float32") / 180.0 + grid_lat = latitude.astype("float32") / 90.0 + + elevation[elevation > 0] = elevation[elevation > 0] / 6574.0 + elevation[elevation < 0] = elevation[elevation < 0] / 32768.0 + norm_elev = elevation + + # if np.isscalar(grid_lon): + # grid_lon = np.array([grid_lon]) + # if np.isscalar(grid_lat): + # grid_lat = np.array([grid_lat]) + # if np.isscalar(norm_elev): + # norm_elev = np.array([norm_elev]) + + norm_loc = tf.stack([grid_lon, grid_lat], axis=1) + + encoded_loc = tf.concat([ + tf.sin(norm_loc * math.pi), + tf.cos(norm_loc * math.pi), + tf.expand_dims(norm_elev, axis=1), + + ], axis=1) + return encoded_loc diff --git a/lib/tf_gp_model.py b/lib/tf_gp_model.py deleted file mode 100644 index 1e12ade..0000000 --- a/lib/tf_gp_model.py +++ /dev/null @@ -1,96 +0,0 @@ -import tensorflow as tf -import numpy as np -import math -import os - -os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' - - -class ResLayer(tf.keras.layers.Layer): - def __init__(self): - super(ResLayer, self).__init__() - self.w1 = tf.keras.layers.Dense( - 256, - activation="relu", - kernel_initializer="he_normal" - ) - self.w2 = tf.keras.layers.Dense( - 256, - activation="relu", - kernel_initializer="he_normal" - ) - self.dropout = tf.keras.layers.Dropout(rate=0.5) - self.add = tf.keras.layers.Add() - - def call(self, inputs): - x = self.w1(inputs) - x = self.dropout(x) - x = self.w2(x) - x = self.add([x, inputs]) - return x - - def get_config(self): - return {} - - -class TFGeoPriorModel: - - def __init__(self, model_path, taxonomy): - self.taxonomy = taxonomy - # initialize the geo model for inference - self.gpmodel = tf.keras.models.load_model( - model_path, - custom_objects={'ResLayer': ResLayer}, - compile=False - ) - - def predict(self, latitude, longitude): - norm_lat = np.array([float(latitude)]) / 90.0 - norm_lng = np.array([float(longitude)]) / 180.0 - norm_loc = tf.stack([norm_lng, norm_lat], axis=1) - encoded_loc = tf.concat([ - tf.sin(norm_loc * math.pi), - tf.cos(norm_loc * math.pi) - ], axis=1) - return self.gpmodel.predict([encoded_loc], verbose=0)[0] - - def eval_one_class(self, latitude, longitude, class_of_interest): - """Evalutes the model for a single class and multiple locations - - Args: - latitude (list): A list of latitudes - longitude (list): A list of longitudes (same length as latitude) - class_of_interest (int): The single class to eval - - Returns: - numpy array: scores for class of interest at each location - """ - def encode_loc(latitude, longitude): - latitude = np.array(latitude) - longitude = np.array(longitude) - grid_lon = longitude.astype('float32') / 180.0 - grid_lat = latitude.astype('float32') / 90.0 - norm_loc = tf.stack([grid_lon, grid_lat], axis=1) - encoded_loc = tf.concat([ - tf.sin(norm_loc * math.pi), - tf.cos(norm_loc * math.pi) - ], axis=1) - return encoded_loc - - encoded_loc = encode_loc(latitude, longitude) - loc_emb = self.gpmodel.layers[0](encoded_loc) - - # res layers - feature extraction - x = self.gpmodel.layers[1](loc_emb) - x = self.gpmodel.layers[2](x) - x = self.gpmodel.layers[3](x) - x = self.gpmodel.layers[4](x) - - # process just the one class - return tf.keras.activations.sigmoid( - tf.matmul( - x, - tf.expand_dims(self.gpmodel.layers[5].weights[0][:,class_of_interest], axis=0), - transpose_b=True - ) - ).numpy() diff --git a/lib/vision_inferrer.py b/lib/vision_inferrer.py index 938c2e6..f1c7482 100644 --- a/lib/vision_inferrer.py +++ b/lib/vision_inferrer.py @@ -1,14 +1,10 @@ import tensorflow as tf -import os -import hashlib -import pickle class VisionInferrer: - def __init__(self, model_path, taxonomy): + def __init__(self, model_path): self.model_path = model_path - self.taxonomy = taxonomy self.prepare_tf_model() # initialize the TF model given the configured path @@ -21,32 +17,6 @@ def prepare_tf_model(self): self.vision_model = tf.keras.models.load_model(self.model_path, compile=False) - # given a unique key, generate a path where vision results can be cached - def cache_path_for_request(self, cache_key): - if cache_key: - cache_hash = hashlib.md5(cache_key.encode()).hexdigest() - return os.path.join("./lib", "vision_cache", cache_hash) - - # given a path, return vision results cached at that path - def cached_results(self, cache_path): - if cache_path and os.path.exists(cache_path): - with open(cache_path, "rb") as handle: - results = pickle.loads(handle.read()) - return results - - # given a path, cache vision results in a file at that path - def cache_results(self, cache_path, results): - if cache_path is not None: - with open(cache_path, "wb+") as cache_file: - pickle.dump(results, cache_file) - - # only return results for up to 500 taxa, or until the scores are very low, whichever - # comes first - # NOTE: This may not be helpful and needs testing for accuracy and processing time - def results_fully_populated(self, results, score): - number_of_results = len(results) - return (number_of_results >= 500 and score < 0.00000001) or number_of_results >= 5000 - # given an image object (usually coming from prepare_image_for_inference), # calculate vision results for the image def process_image(self, image): diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0d519d7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +filterwarnings = + ignore:.*ml_dtypes.*:DeprecationWarning diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7a5660a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +import pytest +import os +from unittest.mock import MagicMock +from lib.inat_inferrer import InatInferrer +from lib.model_taxonomy_dataframe import ModelTaxonomyDataframe + + +@pytest.fixture() +def taxonomy(): + yield ModelTaxonomyDataframe( + os.path.realpath(os.path.dirname(__file__) + "/fixtures/taxonomy.csv"), + os.path.realpath(os.path.dirname(__file__) + "/fixtures/thresholds.csv") + ) + + +@pytest.fixture() +def taxon(request, taxonomy): + results = taxonomy.df.query(f'name == "{request.param}"') + yield results.iloc[0] + + +@pytest.fixture() +def inatInferrer(request, mocker): + config = { + "vision_model_path": "vision_model_path", + "tf_geo_elevation_model_path": "tf_geo_elevation_model_path", + "taxonomy_path": + os.path.realpath(os.path.dirname(__file__) + "/fixtures/taxonomy.csv"), + "elevation_h3_r4": + os.path.realpath(os.path.dirname(__file__) + "/fixtures/elevation.csv"), + "tf_elev_thresholds": + os.path.realpath(os.path.dirname(__file__) + "/fixtures/thresholds.csv"), + "taxon_ranges_path": + os.path.realpath(os.path.dirname(__file__) + "/fixtures/taxon_ranges"), + "synonyms_path": + os.path.realpath(os.path.dirname(__file__) + "/fixtures/synonyms.csv") + } + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + return InatInferrer(config) diff --git a/tests/fixtures/elevation.csv b/tests/fixtures/elevation.csv new file mode 100644 index 0000000..d4a36f8 --- /dev/null +++ b/tests/fixtures/elevation.csv @@ -0,0 +1,2 @@ +h3_04,elevation +842a339ffffffff,10 \ No newline at end of file diff --git a/tests/fixtures/lamprocapnos_spectabilis.jpeg b/tests/fixtures/lamprocapnos_spectabilis.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..ecd5cce9fd0e46ad4ea22f81b654fd851a33ce93 GIT binary patch literal 75493 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 z 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/synonyms.csv b/tests/fixtures/synonyms.csv new file mode 100644 index 0000000..2b83ef1 --- /dev/null +++ b/tests/fixtures/synonyms.csv @@ -0,0 +1,2 @@ +taxon_id,synonym_id,synonym_rank_level,synonym_name +979668,979668,10.0,Corvus enca diff --git a/tests/fixtures/taxon_ranges/7.csv b/tests/fixtures/taxon_ranges/7.csv new file mode 100644 index 0000000..3353fc6 --- /dev/null +++ b/tests/fixtures/taxon_ranges/7.csv @@ -0,0 +1,8 @@ +84441a5ffffffff +8444a91ffffffff +8444a93ffffffff +8444a95ffffffff +8444a99ffffffff +8444a9bffffffff +8444a9dffffffff +8444ad3ffffffff diff --git a/tests/fixtures/taxonomy.csv b/tests/fixtures/taxonomy.csv new file mode 100644 index 0000000..38503c6 --- /dev/null +++ b/tests/fixtures/taxonomy.csv @@ -0,0 +1,21 @@ +parent_taxon_id,taxon_id,rank_level,leaf_class_id,iconic_class_id,spatial_class_id,name +,1,70,,,1,Animalia +1,2,60,,,2,Chordata +2,355675,57,,,3,Vertebrata +355675,3,50,,,4,Aves +3,4,40,,,5,Gruiformes +4,5,30,,,6,Aramidae +5,6,20,,,7,Aramus +6,7,10,1,1,8,Aramus guarauna +3,71262,40,,,9,Cariamiformes +71262,12,30,,,10,Cariamidae +12,13,20,,,11,Cariama +13,14,10,2,1,12,Cariama cristata +,47126,70,,,13,Plantae +47126,211194,60,,,14,Tracheophyta +211194,47125,57,,,15,Angiospermae +47125,47124,50,,,16,Magnoliopsida +47124,71289,40,,,17,Saxifragales +71289,47131,30,,,18,Grossulariaceae +47131,47130,20,,,19,Ribes +47130,47129,10,3,2,20,Ribes californicum diff --git a/tests/fixtures/thresholds.csv b/tests/fixtures/thresholds.csv new file mode 100644 index 0000000..ceaa228 --- /dev/null +++ b/tests/fixtures/thresholds.csv @@ -0,0 +1,3 @@ +,taxon_id,thres,area +0,7,0.1,1000000.1 +1,14,0.2,1000000.1 diff --git a/tests/test_inat_inferrer.py b/tests/test_inat_inferrer.py new file mode 100644 index 0000000..9ba1a1c --- /dev/null +++ b/tests/test_inat_inferrer.py @@ -0,0 +1,106 @@ +import tensorflow as tf +import pandas as pd +import os +import pytest +from unittest.mock import MagicMock +from lib.res_layer import ResLayer +from lib.model_taxonomy_dataframe import ModelTaxonomyDataframe + + +class TestInatInferrer: + def test_initialization(self, inatInferrer): + assert isinstance(inatInferrer.taxonomy, ModelTaxonomyDataframe) + assert isinstance(inatInferrer.synonyms, pd.DataFrame) + assert isinstance(inatInferrer.geo_elevation_cells, pd.DataFrame) + tf.keras.models.load_model.assert_any_call( + inatInferrer.config["vision_model_path"], + compile=False + ) + tf.keras.models.load_model.assert_any_call( + inatInferrer.config["tf_geo_elevation_model_path"], + custom_objects={'ResLayer': ResLayer}, + compile=False + ) + + def test_predictions_for_image(self, inatInferrer): + test_image_path = \ + os.path.realpath(os.path.dirname(__file__) + "/fixtures/lamprocapnos_spectabilis.jpeg") + scores = inatInferrer.predictions_for_image( + file_path=test_image_path, + lat=42, + lng=-71, + filter_taxon=None, + score_without_geo=False, + debug=True + ) + assert isinstance(scores, pd.DataFrame) + assert "leaf_class_id" in scores.columns + assert "parent_taxon_id" in scores.columns + assert "taxon_id" in scores.columns + assert "rank_level" in scores.columns + assert "iconic_class_id" in scores.columns + assert "vision_score" in scores.columns + assert "geo_score" in scores.columns + assert "normalized_vision_score" in scores.columns + assert "normalized_geo_score" in scores.columns + assert "combined_score" in scores.columns + assert "geo_threshold" in scores.columns + + def test_geo_model_predict_with_no_location(self, inatInferrer): + assert inatInferrer.geo_model_predict(lat=None, lng=None) is None + assert inatInferrer.geo_model_predict(lat="", lng="") is None + + @pytest.mark.parametrize("taxon", ["Aramus guarauna"], indirect=True) + def test_lookup_taxon(self, inatInferrer, taxon): + assert inatInferrer.lookup_taxon(taxon["taxon_id"])["name"] == taxon["name"] + + def test_lookup_taxon_with_no_taxon(self, inatInferrer): + assert inatInferrer.lookup_taxon(None) is None + + def test_lookup_taxon_with_invalid_taxon(self, inatInferrer): + with pytest.raises(KeyError): + assert inatInferrer.lookup_taxon(999999999) is None + + def test_aggregate_results(self, inatInferrer): + test_image_path = \ + os.path.realpath(os.path.dirname(__file__) + "/fixtures/lamprocapnos_spectabilis.jpeg") + scores = inatInferrer.predictions_for_image( + file_path=test_image_path, + lat=42, + lng=-71, + filter_taxon=None, + score_without_geo=False, + debug=True + ) + scores.normalized_vision_score = 0.5 + scores.normalized_geo_score = 0.5 + scores.combined_score = 0.25 + scores.geo_threshold = 0.001 + aggregated_scores = inatInferrer.aggregate_results( + leaf_scores=scores, + filter_taxon=None, + score_without_geo=False, + debug=True + ) + assert "aggregated_vision_score" in aggregated_scores.columns + assert "aggregated_geo_score" in aggregated_scores.columns + assert "aggregated_geo_threshold" in aggregated_scores.columns + assert "aggregated_combined_score" in aggregated_scores.columns + assert "normalized_aggregated_combined_score" in aggregated_scores.columns + + @pytest.mark.parametrize("taxon", ["Aramus guarauna"], indirect=True) + def test_h3_04_taxon_range_comparison(self, mocker, inatInferrer, taxon): + inatInferrer.h3_04_geo_results_for_taxon = MagicMock(return_value={ + "aa": "0.1", + "ab": "0.1" + }) + inatInferrer.h3_04_taxon_range = MagicMock(return_value={ + "ab": "0.1", + "bb": "0.1" + }) + range_comparison_results = inatInferrer.h3_04_taxon_range_comparison(taxon["taxon_id"]) + assert range_comparison_results == { + "aa": 0, + "ab": 0.5, + "bb": 1 + } diff --git a/tests/test_model_taxonomy.py b/tests/test_model_taxonomy.py new file mode 100644 index 0000000..167fff7 --- /dev/null +++ b/tests/test_model_taxonomy.py @@ -0,0 +1,54 @@ +import pytest +import os +from lib.model_taxonomy import ModelTaxonomy + + +@pytest.fixture() +def taxonomy(): + yield ModelTaxonomy( + os.path.realpath(os.path.dirname(__file__) + "/fixtures/taxonomy.csv") + ) + + +@pytest.fixture() +def taxon(request, taxonomy): + yield next(v for k, v in taxonomy.taxa.items() if v.name == request.param) + + +class TestModelTaxonomyDataframe: + def test_raise_error_on_missing_path(self): + with pytest.raises(FileNotFoundError): + ModelTaxonomy( + os.path.realpath("nonsense") + ) + + @pytest.mark.parametrize("taxon", ["Aramus guarauna"], indirect=True) + def test_loading_mapping(self, taxon): + assert taxon.id == 7 + assert taxon.parent_id == 6 + assert taxon.rank_level == 10 + assert taxon.leaf_class_id == 1 + assert taxon.name == "Aramus guarauna" + + @pytest.mark.parametrize("taxon", ["Aramus guarauna"], indirect=True) + def test_nested_set_assigning(self, taxon): + assert taxon.left == 7 + assert taxon.right == 8 + + def test_children_of_root(self, taxonomy): + children = taxonomy.taxon_children[0] + assert len(children) == 2 + assert taxonomy.taxa[children[0]].name == "Animalia" + assert taxonomy.taxa[children[1]].name == "Plantae" + + @pytest.mark.parametrize("taxon", ["Animalia"], indirect=True) + def test_children_of_taxon(self, taxonomy, taxon): + children = taxonomy.taxon_children[taxon.id] + assert len(children) == 1 + assert taxonomy.taxa[children[0]].name == "Chordata" + + def test_print(self, capsys, taxonomy): + taxonomy.print() + captured = capsys.readouterr() + assert "├──Animalia :: 0:23" in captured.out + assert "│ └──Chordata :: 1:22" in captured.out diff --git a/tests/test_model_taxonomy_dataframe.py b/tests/test_model_taxonomy_dataframe.py new file mode 100644 index 0000000..15d8d37 --- /dev/null +++ b/tests/test_model_taxonomy_dataframe.py @@ -0,0 +1,56 @@ +import pytest +from lib.model_taxonomy_dataframe import ModelTaxonomyDataframe + + +class TestModelTaxonomyDataframe: + @pytest.mark.parametrize("taxon", ["Aramus guarauna"], indirect=True) + def test_loading_mapping(self, taxon): + assert taxon["taxon_id"] == 7 + assert taxon["parent_taxon_id"] == 6 + assert taxon["rank_level"] == 10 + assert taxon["leaf_class_id"] == 1 + assert taxon["iconic_class_id"] == 1 + assert taxon["spatial_class_id"] == 8 + assert taxon["name"] == "Aramus guarauna" + assert taxon["geo_threshold"] == 0.1 + + @pytest.mark.parametrize("taxon", ["Aramus guarauna"], indirect=True) + def test_nested_set_assigning(self, taxon): + assert taxon["left"] == 7 + assert taxon["right"] == 8 + + @pytest.mark.parametrize("taxon", ["Aramus guarauna"], indirect=True) + def test_geo_threshold_assigning(self, taxon): + assert taxon["geo_threshold"] == 0.1 + + def test_children_of_root(self, taxonomy): + children = ModelTaxonomyDataframe.children(taxonomy.df, 0) + assert len(children.index) == 2 + assert children.iloc[0]["name"] == "Animalia" + assert children.iloc[1]["name"] == "Plantae" + + @pytest.mark.parametrize("taxon", ["Animalia"], indirect=True) + def test_children_of_taxon(self, taxonomy, taxon): + children = ModelTaxonomyDataframe.children(taxonomy.df, taxon["taxon_id"]) + assert len(children.index) == 1 + assert children.iloc[0]["name"] == "Chordata" + + def test_print(self, capsys, taxonomy): + ModelTaxonomyDataframe.print(taxonomy.df) + captured = capsys.readouterr() + assert "├──Animalia :: 0:23" in captured.out + assert "│ └──Chordata :: 1:22" in captured.out + + def test_print_with_aggregated_combined_score(self, capsys, taxonomy): + taxonomy.df["aggregated_combined_score"] = 1 + ModelTaxonomyDataframe.print(taxonomy.df) + captured = capsys.readouterr() + assert "├──Animalia :: 0:23" in captured.out + assert "│ └──Chordata :: 1:22" in captured.out + + def test_print_with_lambda(self, capsys, taxonomy): + ModelTaxonomyDataframe.print(taxonomy.df, display_taxon_lambda=( + lambda row: "customformat" + )) + captured = capsys.readouterr() + assert "customformat" in captured.out diff --git a/tests/test_res_layer.py b/tests/test_res_layer.py new file mode 100644 index 0000000..f803472 --- /dev/null +++ b/tests/test_res_layer.py @@ -0,0 +1,31 @@ +import tensorflow as tf +import unittest.mock as mock +from lib.res_layer import ResLayer +from unittest.mock import MagicMock + + +class TestResLayer: + def test_initialization(self): + res_layer = ResLayer() + assert isinstance(res_layer.w1, tf.keras.layers.Dense) + assert isinstance(res_layer.w2, tf.keras.layers.Dense) + assert isinstance(res_layer.dropout, tf.keras.layers.Dropout) + assert isinstance(res_layer.add, tf.keras.layers.Add) + + def test_call(self, mocker): + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + res_layer = ResLayer() + inputs = tf.keras.Input((256,)) + res_layer.call(inputs) + call_w1 = mock.create_autospec(res_layer.w1.call) + call_dropout = mock.create_autospec(res_layer.dropout.call) + call_w2 = mock.create_autospec(res_layer.w1.call) + call_add = mock.create_autospec(res_layer.add.call) + call_w1.assert_called_once + call_dropout.assert_called_once + call_w2.assert_called_once + call_add.assert_called_once + + def test_get_config(self): + res_layer = ResLayer() + assert res_layer.get_config() == {} diff --git a/tests/test_taxon.py b/tests/test_taxon.py new file mode 100644 index 0000000..171cd48 --- /dev/null +++ b/tests/test_taxon.py @@ -0,0 +1,17 @@ +from lib.taxon import Taxon + + +class TestTaxon: + def test_initialization(self): + taxon = Taxon({"id": 0, "name": "Life"}) + assert taxon.name == "Life" + + def test_is_or_descendant_of_self(self): + taxon = Taxon({"id": 1}) + assert taxon.is_or_descendant_of(taxon) + + def test_is_or_descendant_of_taxon(self): + parent_taxon = Taxon({"id": 1, "left": 0, "right": 3}) + child_taxon = Taxon({"id": 2, "left": 1, "right": 2}) + assert child_taxon.is_or_descendant_of(parent_taxon) + assert not parent_taxon.is_or_descendant_of(child_taxon) diff --git a/tests/test_tf_gp_elev_model.py b/tests/test_tf_gp_elev_model.py new file mode 100644 index 0000000..65f0dae --- /dev/null +++ b/tests/test_tf_gp_elev_model.py @@ -0,0 +1,43 @@ +import pytest +import tensorflow as tf +from lib.res_layer import ResLayer +from lib.tf_gp_elev_model import TFGeoPriorModelElev +from unittest.mock import MagicMock + + +class TestTfGpModel: + def test_initialization_with_unknown_model_path(self): + with pytest.raises(OSError): + TFGeoPriorModelElev("model_path") + + def test_initialization(self, mocker): + model_path = "model_path" + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + TFGeoPriorModelElev(model_path) + tf.keras.models.load_model.assert_called_once_with( + model_path, + custom_objects={'ResLayer': ResLayer}, + compile=False + ) + + def test_predict(self, mocker): + model_path = "model_path" + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + tf_gp_model = TFGeoPriorModelElev(model_path) + tf_gp_model.predict(0, 0, 0) + + def test_features_for_one_class_elevation(self, mocker): + model_path = "model_path" + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + tf_gp_model = TFGeoPriorModelElev(model_path) + tf_gp_model.features_for_one_class_elevation([0], [0], [0]) + + def test_eval_one_class_elevation_from_features(self, mocker): + model_path = "model_path" + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + mocker.patch("tensorflow.keras.activations.sigmoid", return_value=MagicMock()) + mocker.patch("tensorflow.matmul", return_value=MagicMock()) + mocker.patch("tensorflow.expand_dims", return_value=MagicMock()) + tf_gp_model = TFGeoPriorModelElev(model_path) + tf_gp_model.eval_one_class_elevation_from_features("features", "class_of_interest") + tf.keras.activations.sigmoid.assert_called_once diff --git a/tests/test_vision_inferrer.py b/tests/test_vision_inferrer.py new file mode 100644 index 0000000..18830bf --- /dev/null +++ b/tests/test_vision_inferrer.py @@ -0,0 +1,26 @@ +import tensorflow as tf +from unittest.mock import MagicMock +from lib.vision_inferrer import VisionInferrer + + +class TestVisionInferrer: + def test_initialization(self, mocker): + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + model_path = "model_path" + inferrer = VisionInferrer(model_path) + assert inferrer.model_path == model_path + tf.keras.models.load_model.assert_called_once_with( + model_path, + compile=False + ) + + def test_process_image(self, mocker): + mocker.patch("tensorflow.keras.models.load_model", return_value=MagicMock()) + model_path = "model_path" + inferrer = VisionInferrer(model_path) + theimage = "theimage" + inferrer.process_image(theimage) + inferrer.vision_model.assert_called_once_with( + tf.convert_to_tensor(theimage), + training=False + ) From f20c3d74915397c6b7aab8f655af18470c43eb1d Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Mon, 20 Nov 2023 10:51:13 -0500 Subject: [PATCH 2/3] add github actions config --- .github/workflows/CI.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/CI.yml diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..3039c81 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,23 @@ +name: inatVisionAPI CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v4 + - name: Use Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + architecture: 'x86' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Test with pytest + run: | + coverage run -m pytest -s && coverage report --show-missing From 959dab50cde740e9202c6b706c9a1a31e46224f1 Mon Sep 17 00:00:00 2001 From: Patrick Leary Date: Mon, 20 Nov 2023 11:27:26 -0500 Subject: [PATCH 3/3] add to requirements; fix CI python version; add CI slack integration --- .github/workflows/CI.yml | 15 +++++++++++++-- requirements.txt | 5 +++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3039c81..9bd7a08 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -11,8 +11,7 @@ jobs: - name: Use Python uses: actions/setup-python@v4 with: - python-version: '3.x' - architecture: 'x86' + python-version: '3.11.6' cache: 'pip' - name: Install dependencies run: | @@ -21,3 +20,15 @@ jobs: - name: Test with pytest run: | coverage run -m pytest -s && coverage report --show-missing + + notify: + name: Notify Slack + needs: build + if: ${{ success() || failure() }} + runs-on: ubuntu-20.04 + steps: + - uses: iRoachie/slack-github-actions@v2.3.2 + if: env.SLACK_WEBHOOK_URL != null + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_BUILDS_WEBHOOK_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/requirements.txt b/requirements.txt index 609548f..ff24d40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,8 @@ flake8 h3 h3pandas prison +pytest +pytest-cov +pytest-mock +coverage +tensorflow