copairs 0.4.1__tar.gz

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-0.4.1/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Broad Institute
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
copairs-0.4.1/PKG-INFO ADDED
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.1
2
+ Name: copairs
3
+ Version: 0.4.1
4
+ Summary: Find pairs and compute metrics between them
5
+ License: LICENSE.txt
6
+ Keywords: pairwise,replication
7
+ Author: John Arevalo
8
+ Author-email: johnarevalo@gmail.com
9
+ Requires-Python: >=3.9,<3.12
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Requires-Dist: pandas (>=2.0.3)
16
+ Requires-Dist: statsmodels (>=0.14.2,<0.15.0)
17
+ Requires-Dist: tqdm (>=4.66.1,<5.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # copairs
21
+
22
+ Find pairs and compute metrics between them.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install git+https://github.com/cytomining/copairs.git@v0.4.1
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ### Data
33
+
34
+ Say you have a dataset with 20 samples taken in 3 plates `p1, p2, p3`,
35
+ each plate is composed of 5 wells `w1, w2, w3, w4, w5`, and each well
36
+ has one or more labels (`t1, t2, t3, t4`) assigned.
37
+
38
+ ```python
39
+ import pandas as pd
40
+ import random
41
+
42
+ random.seed(0)
43
+ n_samples = 20
44
+ dframe = pd.DataFrame({
45
+ 'plate': [random.choice(['p1', 'p2', 'p3']) for _ in range(n_samples)],
46
+ 'well': [random.choice(['w1', 'w2', 'w3', 'w4', 'w5']) for _ in range(n_samples)],
47
+ 'label': [random.choice(['t1', 't2', 't3', 't4']) for _ in range(n_samples)]
48
+ })
49
+ dframe = dframe.drop_duplicates()
50
+ dframe = dframe.sort_values(by=['plate', 'well', 'label'])
51
+ dframe = dframe.reset_index(drop=True)
52
+ ```
53
+
54
+ | | plate | well | label |
55
+ |---:|:--------|:-------|:--------|
56
+ | 0 | p1 | w2 | t4 |
57
+ | 1 | p1 | w3 | t2 |
58
+ | 2 | p1 | w3 | t4 |
59
+ | 3 | p1 | w4 | t1 |
60
+ | 4 | p1 | w4 | t3 |
61
+ | 5 | p2 | w1 | t1 |
62
+ | 6 | p2 | w2 | t1 |
63
+ | 7 | p2 | w3 | t1 |
64
+ | 8 | p2 | w3 | t2 |
65
+ | 9 | p2 | w3 | t3 |
66
+ | 10 | p2 | w4 | t2 |
67
+ | 11 | p2 | w5 | t1 |
68
+ | 12 | p2 | w5 | t3 |
69
+ | 13 | p3 | w1 | t3 |
70
+ | 14 | p3 | w1 | t4 |
71
+ | 15 | p3 | w4 | t2 |
72
+ | 16 | p3 | w5 | t2 |
73
+ | 17 | p3 | w5 | t4 |
74
+
75
+ ### Getting valid pairs
76
+
77
+ To get pairs of samples that share the same `label` but comes from different
78
+ `plate`s at different `well` positions:
79
+
80
+ ```python
81
+ from copairs import Matcher
82
+ matcher = Matcher(dframe, ['plate', 'well', 'label'], seed=0)
83
+ pairs_dict = matcher.get_all_pairs(sameby=['label'], diffby=['plate', 'well'])
84
+ ```
85
+
86
+ `pairs_dict` is a `label_id: pairs` dictionary containing the list of valid
87
+ pairs for every unique value of `labels`
88
+
89
+ ```
90
+ {'t4': [(0, 17), (0, 14), (17, 2), (2, 14)],
91
+ 't2': [(1, 16), (1, 10), (1, 15), (8, 16), (8, 15), (10, 16)],
92
+ 't1': [(3, 11), (3, 5), (3, 6), (3, 7)],
93
+ 't3': [(9, 4), (9, 13), (13, 4), (13, 12), (4, 12)]}
94
+ ```
95
+
96
+ ### Getting valid pairs from a multilabel column
97
+
98
+ For eficiency reasons, you may not want to have duplicated rows. You can
99
+ group all the labels in a single row and use `MatcherMultilabel` to find the
100
+ corresponding pairs:
101
+
102
+ ```python
103
+ dframe_multi = dframe.groupby(['plate', 'well'])['label'].unique().reset_index()
104
+ ```
105
+
106
+ | | plate | well | label |
107
+ |---:|:--------|:-------|:-------------------|
108
+ | 0 | p1 | w2 | ['t4'] |
109
+ | 1 | p1 | w3 | ['t2', 't4'] |
110
+ | 2 | p1 | w4 | ['t1', 't3'] |
111
+ | 3 | p2 | w1 | ['t1'] |
112
+ | 4 | p2 | w2 | ['t1'] |
113
+ | 5 | p2 | w3 | ['t1', 't2', 't3'] |
114
+ | 6 | p2 | w4 | ['t2'] |
115
+ | 7 | p2 | w5 | ['t1', 't3'] |
116
+ | 8 | p3 | w1 | ['t3', 't4'] |
117
+ | 9 | p3 | w4 | ['t2'] |
118
+ | 10 | p3 | w5 | ['t2', 't4'] |
119
+
120
+ ```python
121
+ from copairs import MatcherMultilabel
122
+ matcher_multi = MatcherMultilabel(dframe_multi,
123
+ columns=['plate', 'well', 'label'],
124
+ multilabel_col='label',
125
+ seed=0)
126
+ pairs_multi = matcher_multi.get_all_pairs(sameby=['label'],
127
+ diffby=['plate', 'well'])
128
+ ```
129
+
130
+ `pairs_multi` is also a `label_id: pairs` dictionary with the same
131
+ structure discussed before:
132
+
133
+ ```
134
+ {'t4': [(0, 10), (0, 8), (10, 1), (1, 8)],
135
+ 't2': [(1, 10), (1, 6), (1, 9), (5, 10), (5, 9), (6, 10)],
136
+ 't1': [(2, 7), (2, 3), (2, 4), (2, 5)],
137
+ 't3': [(5, 2), (5, 8), (8, 2), (8, 7), (2, 7)]}
138
+ ```
139
+
@@ -0,0 +1,119 @@
1
+ # copairs
2
+
3
+ Find pairs and compute metrics between them.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install git+https://github.com/cytomining/copairs.git@v0.4.1
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Data
14
+
15
+ Say you have a dataset with 20 samples taken in 3 plates `p1, p2, p3`,
16
+ each plate is composed of 5 wells `w1, w2, w3, w4, w5`, and each well
17
+ has one or more labels (`t1, t2, t3, t4`) assigned.
18
+
19
+ ```python
20
+ import pandas as pd
21
+ import random
22
+
23
+ random.seed(0)
24
+ n_samples = 20
25
+ dframe = pd.DataFrame({
26
+ 'plate': [random.choice(['p1', 'p2', 'p3']) for _ in range(n_samples)],
27
+ 'well': [random.choice(['w1', 'w2', 'w3', 'w4', 'w5']) for _ in range(n_samples)],
28
+ 'label': [random.choice(['t1', 't2', 't3', 't4']) for _ in range(n_samples)]
29
+ })
30
+ dframe = dframe.drop_duplicates()
31
+ dframe = dframe.sort_values(by=['plate', 'well', 'label'])
32
+ dframe = dframe.reset_index(drop=True)
33
+ ```
34
+
35
+ | | plate | well | label |
36
+ |---:|:--------|:-------|:--------|
37
+ | 0 | p1 | w2 | t4 |
38
+ | 1 | p1 | w3 | t2 |
39
+ | 2 | p1 | w3 | t4 |
40
+ | 3 | p1 | w4 | t1 |
41
+ | 4 | p1 | w4 | t3 |
42
+ | 5 | p2 | w1 | t1 |
43
+ | 6 | p2 | w2 | t1 |
44
+ | 7 | p2 | w3 | t1 |
45
+ | 8 | p2 | w3 | t2 |
46
+ | 9 | p2 | w3 | t3 |
47
+ | 10 | p2 | w4 | t2 |
48
+ | 11 | p2 | w5 | t1 |
49
+ | 12 | p2 | w5 | t3 |
50
+ | 13 | p3 | w1 | t3 |
51
+ | 14 | p3 | w1 | t4 |
52
+ | 15 | p3 | w4 | t2 |
53
+ | 16 | p3 | w5 | t2 |
54
+ | 17 | p3 | w5 | t4 |
55
+
56
+ ### Getting valid pairs
57
+
58
+ To get pairs of samples that share the same `label` but comes from different
59
+ `plate`s at different `well` positions:
60
+
61
+ ```python
62
+ from copairs import Matcher
63
+ matcher = Matcher(dframe, ['plate', 'well', 'label'], seed=0)
64
+ pairs_dict = matcher.get_all_pairs(sameby=['label'], diffby=['plate', 'well'])
65
+ ```
66
+
67
+ `pairs_dict` is a `label_id: pairs` dictionary containing the list of valid
68
+ pairs for every unique value of `labels`
69
+
70
+ ```
71
+ {'t4': [(0, 17), (0, 14), (17, 2), (2, 14)],
72
+ 't2': [(1, 16), (1, 10), (1, 15), (8, 16), (8, 15), (10, 16)],
73
+ 't1': [(3, 11), (3, 5), (3, 6), (3, 7)],
74
+ 't3': [(9, 4), (9, 13), (13, 4), (13, 12), (4, 12)]}
75
+ ```
76
+
77
+ ### Getting valid pairs from a multilabel column
78
+
79
+ For eficiency reasons, you may not want to have duplicated rows. You can
80
+ group all the labels in a single row and use `MatcherMultilabel` to find the
81
+ corresponding pairs:
82
+
83
+ ```python
84
+ dframe_multi = dframe.groupby(['plate', 'well'])['label'].unique().reset_index()
85
+ ```
86
+
87
+ | | plate | well | label |
88
+ |---:|:--------|:-------|:-------------------|
89
+ | 0 | p1 | w2 | ['t4'] |
90
+ | 1 | p1 | w3 | ['t2', 't4'] |
91
+ | 2 | p1 | w4 | ['t1', 't3'] |
92
+ | 3 | p2 | w1 | ['t1'] |
93
+ | 4 | p2 | w2 | ['t1'] |
94
+ | 5 | p2 | w3 | ['t1', 't2', 't3'] |
95
+ | 6 | p2 | w4 | ['t2'] |
96
+ | 7 | p2 | w5 | ['t1', 't3'] |
97
+ | 8 | p3 | w1 | ['t3', 't4'] |
98
+ | 9 | p3 | w4 | ['t2'] |
99
+ | 10 | p3 | w5 | ['t2', 't4'] |
100
+
101
+ ```python
102
+ from copairs import MatcherMultilabel
103
+ matcher_multi = MatcherMultilabel(dframe_multi,
104
+ columns=['plate', 'well', 'label'],
105
+ multilabel_col='label',
106
+ seed=0)
107
+ pairs_multi = matcher_multi.get_all_pairs(sameby=['label'],
108
+ diffby=['plate', 'well'])
109
+ ```
110
+
111
+ `pairs_multi` is also a `label_id: pairs` dictionary with the same
112
+ structure discussed before:
113
+
114
+ ```
115
+ {'t4': [(0, 10), (0, 8), (10, 1), (1, 8)],
116
+ 't2': [(1, 10), (1, 6), (1, 9), (5, 10), (5, 9), (6, 10)],
117
+ 't1': [(2, 7), (2, 3), (2, 4), (2, 5)],
118
+ 't3': [(5, 2), (5, 8), (8, 2), (8, 7), (2, 7)]}
119
+ ```
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "copairs"
3
+ version = "0.4.1"
4
+ description = "Find pairs and compute metrics between them"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9,<3.12"
7
+ license = {file = "LICENSE.txt"}
8
+ keywords = ["pairwise", "replication"]
9
+ authors = [
10
+ {name = "John Arevalo", email = "johnarevalo@gmail.com" }
11
+ ]
12
+ dependencies = [
13
+ "pandas",
14
+ "tqdm",
15
+ "statsmodels"
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ plot = ["plotly"]
20
+
21
+ [project.urls]
22
+ "Homepage" = "https://github.com/cytomining/copairs"
23
+ "Bug Reports" = "https://github.com/cytomining/copairs/issues"
24
+ "Source" = "https://github.com/cytomining/copairs/"
25
+
26
+ # [build-system]
27
+ # requires = ["setuptools"]
28
+ # build-backend = "setuptools.build_meta"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.poetry]
34
+ name = "copairs"
35
+ version = "0.4.1"
36
+ description = "Find pairs and compute metrics between them"
37
+ readme = "README.md"
38
+ license = "LICENSE.txt"
39
+ keywords = ["pairwise", "replication"]
40
+ authors = [
41
+ "John Arevalo <johnarevalo@gmail.com>",
42
+ ]
43
+ packages = [{include = "copairs", from= "src"}]
44
+
45
+ [tool.poetry.dependencies]
46
+ python = ">=3.9,<3.12"
47
+ tqdm = "^4.66.1"
48
+ pandas = ">=2.0.3"
49
+ statsmodels = "^0.14.2"
50
+
51
+ [tool.poetry.group.dev.dependencies]
52
+ jupytext = "^1.15.0"
53
+ ipdb = "^0.13.13"
54
+ jupyter = "^1.0.0"
55
+ ruff = "<0.2.0"
56
+
57
+ [build-system]
58
+ requires = ["poetry-core"]
59
+ build-backend = "poetry.core.masonry.api"
60
+
@@ -0,0 +1,4 @@
1
+ """
2
+ Package to create pairwise lists based on sameby and diffby criteria
3
+ """
4
+ from .matching import Matcher, MatcherMultilabel
@@ -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