copairs 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
copairs/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """
2
+ Package to create pairwise lists based on sameby and diffby criteria
3
+ """
4
+ from .matching import Matcher, MatcherMultilabel
copairs/compute.py ADDED
@@ -0,0 +1,199 @@
1
+ import itertools
2
+ import os
3
+ from multiprocessing.pool import ThreadPool
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+ import numpy as np
8
+ from tqdm.autonotebook import tqdm
9
+
10
+
11
+ def parallel_map(par_func, items):
12
+ """Execute par_func(i) for every i in items using ThreadPool and tqdm."""
13
+ num_items = len(items)
14
+ pool_size = min(num_items, os.cpu_count())
15
+ chunksize = num_items // pool_size
16
+ with ThreadPool(pool_size) as pool:
17
+ tasks = pool.imap_unordered(par_func, items, chunksize=chunksize)
18
+ for _ in tqdm(tasks, total=len(items), leave=False):
19
+ pass
20
+
21
+
22
+ def batch_processing(
23
+ pairwise_op: Callable[[np.ndarray, np.ndarray], np.ndarray],
24
+ ):
25
+ """Decorator adding the batch_size param to run the function with
26
+ multithreading using a list of paired indices"""
27
+
28
+ def batched_fn(feats: np.ndarray, pair_ix: np.ndarray, batch_size: int):
29
+ num_pairs = len(pair_ix)
30
+ result = np.empty(num_pairs, dtype=np.float32)
31
+
32
+ def par_func(i):
33
+ x_sample = feats[pair_ix[i : i + batch_size, 0]]
34
+ y_sample = feats[pair_ix[i : i + batch_size, 1]]
35
+ result[i : i + len(x_sample)] = pairwise_op(x_sample, y_sample)
36
+
37
+ parallel_map(par_func, np.arange(0, num_pairs, batch_size))
38
+
39
+ return result
40
+
41
+ return batched_fn
42
+
43
+
44
+ @batch_processing
45
+ def pairwise_corr(x_sample: np.ndarray, y_sample: np.ndarray) -> np.ndarray:
46
+ """
47
+ Compute pearson correlation between two matrices in a paired row-wise
48
+ fashion. `x_sample` and `y_sample` must be of the same shape.
49
+ """
50
+ x_mean = x_sample.mean(axis=1, keepdims=True)
51
+ y_mean = y_sample.mean(axis=1, keepdims=True)
52
+
53
+ x_center = x_sample - x_mean
54
+ y_center = y_sample - y_mean
55
+
56
+ numer = (x_center * y_center).sum(axis=1)
57
+
58
+ denom = (x_center**2).sum(axis=1) * (y_center**2).sum(axis=1)
59
+ denom = np.sqrt(denom)
60
+
61
+ corrs = numer / denom
62
+ return corrs
63
+
64
+
65
+ @batch_processing
66
+ def pairwise_cosine(x_sample: np.ndarray, y_sample: np.ndarray) -> np.ndarray:
67
+ x_norm = x_sample / np.linalg.norm(x_sample, axis=1)[:, np.newaxis]
68
+ y_norm = y_sample / np.linalg.norm(y_sample, axis=1)[:, np.newaxis]
69
+ c_sim = np.sum(x_norm * y_norm, axis=1)
70
+ return c_sim
71
+
72
+
73
+ def random_binary_matrix(n, m, k, rng):
74
+ """Generate a random binary matrix of n*m with exactly k values in 1 per row.
75
+ Args:
76
+ n: Number of rows.
77
+ m: Number of columns.
78
+ k: Number of 1's per row.
79
+
80
+ Returns:
81
+ A: Random binary matrix of n*m with exactly k values in 1 per row.
82
+ """
83
+ matrix = np.zeros((n, m), dtype=int)
84
+ matrix[:, :k] = 1
85
+ rng.permuted(matrix, axis=1, out=matrix)
86
+ return matrix
87
+
88
+
89
+ def average_precision(rel_k) -> np.ndarray:
90
+ """Compute average precision based on binary list sorted by relevance"""
91
+ tp = np.cumsum(rel_k, axis=1)
92
+ num_pos = tp[:, -1]
93
+ k = np.arange(1, rel_k.shape[1] + 1)
94
+ pr_k = tp / k
95
+ ap = (pr_k * rel_k).sum(axis=1) / num_pos
96
+ return ap
97
+
98
+
99
+ def ap_contiguous(rel_k_list, counts):
100
+ """Compute average precision from a list of contiguous values"""
101
+ cutoffs = to_cutoffs(counts)
102
+
103
+ num_pos = np.add.reduceat(rel_k_list, cutoffs)
104
+ shift = np.empty_like(num_pos)
105
+ shift[0], shift[1:] = 0, num_pos[:-1]
106
+
107
+ tp = rel_k_list.cumsum() - np.repeat(shift.cumsum(), counts)
108
+ k = np.arange(1, len(rel_k_list) + 1) - np.repeat(cutoffs, counts)
109
+
110
+ pr_k = tp / k
111
+ ap_scores = np.add.reduceat(pr_k * rel_k_list, cutoffs) / num_pos
112
+ null_confs = np.stack([num_pos, counts], axis=1)
113
+ return ap_scores, null_confs
114
+
115
+
116
+ def random_ap(num_perm: int, num_pos: int, total: int, seed) -> np.ndarray:
117
+ """Compute multiple average_precision scores generated at random"""
118
+ rng = np.random.default_rng(seed)
119
+ rel_k = random_binary_matrix(num_perm, total, num_pos, rng)
120
+ null_dist = average_precision(rel_k)
121
+ return null_dist
122
+
123
+
124
+ def null_dist_cached(num_pos, total, seed, null_size, cache_dir):
125
+ if seed is not None:
126
+ cache_file = cache_dir / f"n{total}_k{num_pos}.npy"
127
+ if cache_file.is_file():
128
+ null_dist = np.load(cache_file)
129
+ else:
130
+ null_dist = random_ap(null_size, num_pos, total, seed)
131
+ np.save(cache_file, null_dist)
132
+ else:
133
+ null_dist = random_ap(null_size, num_pos, total, seed)
134
+ return null_dist
135
+
136
+
137
+ def get_null_dists(confs, null_size, seed):
138
+ cache_dir = Path.home() / ".copairs" / f"seed{seed}" / f"ns{null_size}"
139
+ cache_dir.mkdir(parents=True, exist_ok=True)
140
+ num_confs = len(confs)
141
+ rng = np.random.default_rng(seed)
142
+ seeds = rng.integers(8096, size=num_confs)
143
+
144
+ null_dists = np.empty([len(confs), null_size], dtype=np.float32)
145
+
146
+ def par_func(i):
147
+ num_pos, total = confs[i]
148
+ null_dists[i] = null_dist_cached(num_pos, total, seeds[i], null_size, cache_dir)
149
+
150
+ parallel_map(par_func, np.arange(num_confs))
151
+ return null_dists
152
+
153
+
154
+ def p_values(ap_scores: np.ndarray, null_confs: np.ndarray, null_size: int, seed: int):
155
+ """Calculate p values for an array of ap_scores and null configurations. It uses the path
156
+ folder to cache null calculations.
157
+
158
+ Parameters
159
+ ----------
160
+ ap_scores : np.ndarray
161
+ Ap scores for which to calculate p value.
162
+ null_confs : np.ndarray
163
+ Number of average precisions calculated. It serves as an indicator of
164
+ how relevant is the resultant score.
165
+ null_size : int
166
+ seed : int
167
+ Random initializing value.
168
+
169
+ Examples
170
+ --------
171
+ FIXME: Add docs.
172
+
173
+
174
+ """
175
+ confs, rev_ix = np.unique(null_confs, axis=0, return_inverse=True)
176
+ null_dists = get_null_dists(confs, null_size, seed)
177
+ null_dists.sort(axis=1)
178
+ pvals = np.empty(len(ap_scores), dtype=np.float32)
179
+ for i, (ap_score, ix) in enumerate(zip(ap_scores, rev_ix)):
180
+ # Reverse to get from hi to low
181
+ num = null_size - np.searchsorted(null_dists[ix], ap_score)
182
+ pvals[i] = (num + 1) / (null_size + 1)
183
+ return pvals
184
+
185
+
186
+ def concat_ranges(start: np.ndarray, end: np.ndarray) -> np.ndarray:
187
+ """Create a 1-d array concatenating multiple ranges"""
188
+ slices = map(range, start, end)
189
+ slices = itertools.chain.from_iterable(slices)
190
+ count = (end - start).sum()
191
+ mask = np.fromiter(slices, dtype=np.int32, count=count)
192
+ return mask
193
+
194
+
195
+ def to_cutoffs(counts: np.ndarray):
196
+ """Convert a list of counts into cutoff indices."""
197
+ cutoffs = np.empty_like(counts)
198
+ cutoffs[0], cutoffs[1:] = 0, counts.cumsum()[:-1]
199
+ return cutoffs
@@ -0,0 +1,3 @@
1
+ from .map import mean_average_precision
2
+ from . import multilabel
3
+ from .average_precision import average_precision
@@ -0,0 +1,94 @@
1
+ import itertools
2
+ import logging
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from copairs import compute
8
+ from copairs.matching import Matcher, UnpairedException
9
+
10
+ from .filter import evaluate_and_filter, flatten_str_list, validate_pipeline_input
11
+
12
+ logger = logging.getLogger("copairs")
13
+
14
+
15
+ def build_rank_lists(pos_pairs, neg_pairs, pos_sims, neg_sims):
16
+ labels = np.concatenate(
17
+ [
18
+ np.ones(pos_pairs.size, dtype=np.int32),
19
+ np.zeros(neg_pairs.size, dtype=np.int32),
20
+ ]
21
+ )
22
+ ix = np.concatenate([pos_pairs.ravel(), neg_pairs.ravel()])
23
+ sim_all = np.concatenate([np.repeat(pos_sims, 2), np.repeat(neg_sims, 2)])
24
+ ix_sort = np.lexsort([1 - sim_all, ix])
25
+ rel_k_list = labels[ix_sort]
26
+ paired_ix, counts = np.unique(ix, return_counts=True)
27
+ return paired_ix, rel_k_list, counts
28
+
29
+
30
+ def average_precision(
31
+ meta, feats, pos_sameby, pos_diffby, neg_sameby, neg_diffby, batch_size=20000
32
+ ) -> pd.DataFrame:
33
+ columns = flatten_str_list(pos_sameby, pos_diffby, neg_sameby, neg_diffby)
34
+ validate_pipeline_input(meta, feats, columns)
35
+
36
+ # Critical!, otherwise the indexing wont work
37
+ meta = meta.reset_index(drop=True).copy()
38
+ logger.info("Indexing metadata...")
39
+ matcher = Matcher(*evaluate_and_filter(meta, columns), seed=0)
40
+
41
+ logger.info("Finding positive pairs...")
42
+ pos_pairs = matcher.get_all_pairs(sameby=pos_sameby, diffby=pos_diffby)
43
+ pos_total = sum(len(p) for p in pos_pairs.values())
44
+ if pos_total == 0:
45
+ raise UnpairedException("Unable to find positive pairs.")
46
+ pos_pairs = np.fromiter(
47
+ itertools.chain.from_iterable(pos_pairs.values()),
48
+ dtype=np.dtype((np.int32, 2)),
49
+ count=pos_total,
50
+ )
51
+
52
+ logger.info("Finding negative pairs...")
53
+ neg_pairs = matcher.get_all_pairs(sameby=neg_sameby, diffby=neg_diffby)
54
+ neg_total = sum(len(p) for p in neg_pairs.values())
55
+ if neg_total == 0:
56
+ raise UnpairedException("Unable to find negative pairs.")
57
+ neg_pairs = np.fromiter(
58
+ itertools.chain.from_iterable(neg_pairs.values()),
59
+ dtype=np.dtype((np.int32, 2)),
60
+ count=neg_total,
61
+ )
62
+
63
+ logger.info("Computing positive similarities...")
64
+ pos_sims = compute.pairwise_cosine(feats, pos_pairs, batch_size)
65
+
66
+ logger.info("Computing negative similarities...")
67
+ neg_sims = compute.pairwise_cosine(feats, neg_pairs, batch_size)
68
+
69
+ logger.info("Building rank lists...")
70
+ paired_ix, rel_k_list, counts = build_rank_lists(
71
+ pos_pairs, neg_pairs, pos_sims, neg_sims
72
+ )
73
+
74
+ logger.info("Computing average precision...")
75
+ ap_scores, null_confs = compute.ap_contiguous(rel_k_list, counts)
76
+
77
+ logger.info("Creating result DataFrame...")
78
+ meta["n_pos_pairs"] = 0
79
+ meta["n_total_pairs"] = 0
80
+ meta.loc[paired_ix, "average_precision"] = ap_scores
81
+ meta.loc[paired_ix, "n_pos_pairs"] = null_confs[:, 0]
82
+ meta.loc[paired_ix, "n_total_pairs"] = null_confs[:, 1]
83
+ logger.info("Finished.")
84
+ return meta
85
+
86
+
87
+ def p_values(dframe: pd.DataFrame, null_size: int, seed: int):
88
+ """Compute p-values"""
89
+ mask = dframe["n_pos_pairs"] > 0
90
+ pvals = np.full(len(dframe), np.nan, dtype=np.float32)
91
+ scores = dframe.loc[mask, "average_precision"].values
92
+ null_confs = dframe.loc[mask, ["n_pos_pairs", "n_total_pairs"]].values
93
+ pvals[mask] = compute.p_values(scores, null_confs, null_size, seed)
94
+ return pvals
copairs/map/filter.py ADDED
@@ -0,0 +1,51 @@
1
+ import itertools
2
+ import re
3
+ from typing import List, Tuple
4
+
5
+ import pandas as pd
6
+ import numpy as np
7
+
8
+
9
+ def validate_pipeline_input(meta, feats, columns):
10
+ if meta[columns].isna().any(axis=None):
11
+ raise ValueError("metadata columns should not have null values.")
12
+ if len(meta) != len(feats):
13
+ raise ValueError("meta and feats have different number of rows")
14
+ if np.isnan(feats).any():
15
+ raise ValueError("features should not have null values.")
16
+
17
+
18
+ def flatten_str_list(*args):
19
+ """create a single list with all the params given"""
20
+ columns = set()
21
+ for col in args:
22
+ if isinstance(col, str):
23
+ columns.add(col)
24
+ elif isinstance(col, dict):
25
+ columns.update(itertools.chain.from_iterable(col.values()))
26
+ else:
27
+ columns.update(col)
28
+ columns = list(columns)
29
+ return columns
30
+
31
+
32
+ def evaluate_and_filter(df, columns) -> Tuple[pd.DataFrame, List[str]]:
33
+ """Evaluate the query and filter the dataframe"""
34
+ parsed_cols = []
35
+ for col in columns:
36
+ if col in df.columns:
37
+ parsed_cols.append(col)
38
+ continue
39
+
40
+ column_names = re.findall(r"(\w+)\s*[=<>!]+", col)
41
+ valid_column_names = [col for col in column_names if col in df.columns]
42
+ if not valid_column_names:
43
+ raise ValueError(f"Invalid query or column name: {col}")
44
+
45
+ try:
46
+ df = df.query(col)
47
+ parsed_cols.extend(valid_column_names)
48
+ except:
49
+ raise ValueError(f"Invalid query expression: {col}")
50
+
51
+ return df, parsed_cols
copairs/map/map.py ADDED
@@ -0,0 +1,51 @@
1
+ import logging
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from statsmodels.stats.multitest import multipletests
6
+ from tqdm.contrib.concurrent import thread_map
7
+
8
+ from copairs import compute
9
+
10
+ logger = logging.getLogger("copairs")
11
+
12
+
13
+ def mean_average_precision(
14
+ ap_scores: pd.DataFrame, sameby, null_size: int, threshold: float, seed: int
15
+ ) -> pd.DataFrame:
16
+ ap_scores = ap_scores.query("~average_precision.isna() and n_pos_pairs > 0")
17
+ ap_scores = ap_scores.reset_index(drop=True).copy()
18
+
19
+ logger.info("Computing null_dist...")
20
+ null_confs = ap_scores[["n_pos_pairs", "n_total_pairs"]].values
21
+ null_confs, rev_ix = np.unique(null_confs, axis=0, return_inverse=True)
22
+ null_dists = compute.get_null_dists(null_confs, null_size, seed=seed)
23
+ ap_scores["null_ix"] = rev_ix
24
+
25
+ def get_p_value(params):
26
+ map_score, indices = params
27
+ null_dist = null_dists[rev_ix[indices]].mean(axis=0)
28
+ num = (null_dist > map_score).sum()
29
+ p_value = (num + 1) / (null_size + 1)
30
+ return p_value
31
+
32
+ logger.info("Computing p-values...")
33
+
34
+ map_scores = ap_scores.groupby(sameby, observed=True).agg(
35
+ {
36
+ "average_precision": ["mean", lambda x: list(x.index)],
37
+ }
38
+ )
39
+ map_scores.columns = ["mean_average_precision", "indices"]
40
+
41
+ params = map_scores[["mean_average_precision", "indices"]]
42
+ map_scores["p_value"] = thread_map(get_p_value, params.values, leave=False)
43
+ reject, pvals_corrected, alphacSidak, alphacBonf = multipletests(
44
+ map_scores["p_value"], method="fdr_bh"
45
+ )
46
+ map_scores["corrected_p_value"] = pvals_corrected
47
+ map_scores["below_p"] = map_scores["p_value"] < threshold
48
+ map_scores["below_corrected_p"] = map_scores["corrected_p_value"] < threshold
49
+ map_scores.drop(columns=["indices"], inplace=True)
50
+ map_scores.reset_index(inplace=True)
51
+ return map_scores
@@ -0,0 +1,151 @@
1
+ import itertools
2
+ import logging
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from copairs import compute
8
+ from copairs.matching import MatcherMultilabel, UnpairedException
9
+
10
+ from .filter import evaluate_and_filter, flatten_str_list, validate_pipeline_input
11
+
12
+ logger = logging.getLogger("copairs")
13
+
14
+
15
+ def create_neg_query_solver(neg_pairs, neg_sims):
16
+ # Melting and sorting by ix. neg_cutoffs splits the contiguous array
17
+ neg_ix = neg_pairs.ravel()
18
+ neg_sims = np.repeat(neg_sims, 2)
19
+
20
+ sort_ix = np.argsort(neg_ix)
21
+ neg_sims = neg_sims[sort_ix]
22
+
23
+ neg_ix, neg_counts = np.unique(neg_ix, return_counts=True)
24
+ neg_cutoffs = compute.to_cutoffs(neg_counts)
25
+
26
+ def negs_for(query: np.ndarray):
27
+ locs = np.searchsorted(neg_ix, query)
28
+ sizes = neg_counts[locs]
29
+ start = neg_cutoffs[locs]
30
+ end = start + sizes
31
+ slices = compute.concat_ranges(start, end)
32
+ batch_sims = neg_sims[slices]
33
+ return batch_sims, sizes
34
+
35
+ return negs_for
36
+
37
+
38
+ def build_rank_lists_multi(pos_pairs, pos_sims, pos_counts, negs_for):
39
+ ap_scores_list, null_confs_list, ix_list = [], [], []
40
+
41
+ start = 0
42
+ for end in pos_counts.cumsum():
43
+ mpos_pairs = pos_pairs[start:end]
44
+ mpos_sims = pos_sims[start:end]
45
+ start = end
46
+ query = np.unique(mpos_pairs)
47
+ neg_sims, neg_counts = negs_for(query)
48
+ neg_ix = np.repeat(query, neg_counts)
49
+ labels = np.concatenate(
50
+ [
51
+ np.ones(mpos_pairs.size, dtype=np.int32),
52
+ np.zeros(len(neg_sims), dtype=np.int32),
53
+ ]
54
+ )
55
+
56
+ ix = np.concatenate([mpos_pairs.ravel(), neg_ix])
57
+ sim_all = np.concatenate([np.repeat(mpos_sims, 2), neg_sims])
58
+ ix_sort = np.lexsort([1 - sim_all, ix])
59
+ rel_k_list = labels[ix_sort]
60
+ _, counts = np.unique(ix, return_counts=True)
61
+ ap_scores, null_confs = compute.ap_contiguous(rel_k_list, counts)
62
+ ap_scores_list.append(ap_scores)
63
+ null_confs_list.append(null_confs)
64
+ ix_list.append(query)
65
+ return ap_scores_list, null_confs_list, ix_list
66
+
67
+
68
+ def average_precision(
69
+ meta,
70
+ feats,
71
+ pos_sameby,
72
+ pos_diffby,
73
+ neg_sameby,
74
+ neg_diffby,
75
+ multilabel_col,
76
+ batch_size=20000,
77
+ ) -> pd.DataFrame:
78
+ columns = flatten_str_list(pos_sameby, pos_diffby, neg_sameby, neg_diffby)
79
+ validate_pipeline_input(meta, feats, columns)
80
+ # Critical!, otherwise the indexing wont work
81
+ meta = meta.reset_index(drop=True).copy()
82
+
83
+ logger.info("Indexing metadata...")
84
+ matcher = MatcherMultilabel(
85
+ *evaluate_and_filter(meta, columns), multilabel_col=multilabel_col, seed=0
86
+ )
87
+
88
+ logger.info("Finding positive pairs...")
89
+ pos_pairs = matcher.get_all_pairs(sameby=pos_sameby, diffby=pos_diffby)
90
+ pos_keys = pos_pairs.keys()
91
+ pos_counts = np.fromiter(map(len, pos_pairs.values()), dtype=np.int32)
92
+ pos_total = sum(pos_counts)
93
+ if pos_total == 0:
94
+ raise UnpairedException("Unable to find positive pairs.")
95
+ pos_pairs = np.fromiter(
96
+ itertools.chain.from_iterable(pos_pairs.values()),
97
+ dtype=np.dtype((np.int32, 2)),
98
+ count=pos_total,
99
+ )
100
+
101
+ logger.info("Finding negative pairs...")
102
+ neg_pairs = matcher.get_all_pairs(sameby=neg_sameby, diffby=neg_diffby)
103
+ neg_total = sum(len(p) for p in neg_pairs.values())
104
+ if neg_total == 0:
105
+ raise UnpairedException("Unable to find any negative pairs.")
106
+ neg_pairs = np.fromiter(
107
+ itertools.chain.from_iterable(neg_pairs.values()),
108
+ dtype=np.dtype((np.int32, 2)),
109
+ count=neg_total,
110
+ )
111
+
112
+ logger.info("Dropping dups in negative pairs...")
113
+ neg_pairs = np.unique(neg_pairs, axis=0)
114
+
115
+ logger.info("Computing positive similarities...")
116
+ pos_sims = compute.pairwise_cosine(feats, pos_pairs, batch_size)
117
+
118
+ logger.info("Computing negative similarities...")
119
+ neg_sims = compute.pairwise_cosine(feats, neg_pairs, batch_size)
120
+
121
+ logger.info("Computing AP per label...")
122
+ negs_for = create_neg_query_solver(neg_pairs, neg_sims)
123
+ ap_scores_list, null_confs_list, ix_list = build_rank_lists_multi(
124
+ pos_pairs, pos_sims, pos_counts, negs_for
125
+ )
126
+
127
+ logger.info("Creating result DataFrame...")
128
+ results = []
129
+ for i, key in enumerate(pos_keys):
130
+ result = pd.DataFrame(
131
+ {
132
+ "average_precision": ap_scores_list[i],
133
+ "n_pos_pairs": null_confs_list[i][:, 0],
134
+ "n_total_pairs": null_confs_list[i][:, 1],
135
+ "ix": ix_list[i],
136
+ }
137
+ )
138
+ if isinstance(key, tuple):
139
+ # Is a ComposedKey
140
+ for k, v in zip(key._fields, key):
141
+ result[k] = v
142
+ else:
143
+ result[multilabel_col] = key
144
+ results.append(result)
145
+ results = pd.concat(results).reset_index(drop=True)
146
+ meta = meta.drop(multilabel_col, axis=1)
147
+ results = meta.merge(results, right_on="ix", left_index=True).drop("ix", axis=1)
148
+ results["n_pos_pairs"] = results["n_pos_pairs"].fillna(0).astype(np.int32)
149
+ results["n_total_pairs"] = results["n_total_pairs"].fillna(0).astype(np.int32)
150
+ logger.info("Finished.")
151
+ return results