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 +28 -0
- copairs-0.4.1/PKG-INFO +139 -0
- copairs-0.4.1/README.md +119 -0
- copairs-0.4.1/pyproject.toml +60 -0
- copairs-0.4.1/src/copairs/__init__.py +4 -0
- copairs-0.4.1/src/copairs/compute.py +199 -0
- copairs-0.4.1/src/copairs/map/__init__.py +3 -0
- copairs-0.4.1/src/copairs/map/average_precision.py +94 -0
- copairs-0.4.1/src/copairs/map/filter.py +51 -0
- copairs-0.4.1/src/copairs/map/map.py +51 -0
- copairs-0.4.1/src/copairs/map/multilabel.py +151 -0
- copairs-0.4.1/src/copairs/matching.py +446 -0
- copairs-0.4.1/src/copairs/plot.py +78 -0
- copairs-0.4.1/src/copairs/replicating.py +207 -0
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
|
+
|
copairs-0.4.1/README.md
ADDED
|
@@ -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,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,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
|