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 +4 -0
- copairs/compute.py +199 -0
- copairs/map/__init__.py +3 -0
- copairs/map/average_precision.py +94 -0
- copairs/map/filter.py +51 -0
- copairs/map/map.py +51 -0
- copairs/map/multilabel.py +151 -0
- copairs/matching.py +446 -0
- copairs/plot.py +78 -0
- copairs/replicating.py +207 -0
- copairs-0.4.1.dist-info/LICENSE +28 -0
- copairs-0.4.1.dist-info/METADATA +139 -0
- copairs-0.4.1.dist-info/RECORD +14 -0
- copairs-0.4.1.dist-info/WHEEL +4 -0
copairs/__init__.py
ADDED
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
|
copairs/map/__init__.py
ADDED
|
@@ -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
|