opdiv 0.1.0__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.
opdiv-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miroslav Lzicar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
opdiv-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: opdiv
3
+ Version: 0.1.0
4
+ Summary: Molecular portfolio selection and diversity evaluation
5
+ Author: Miroslav Lzicar
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/mireklzicar/opdiv
8
+ Project-URL: Issues, https://github.com/mireklzicar/opdiv/issues
9
+ Keywords: cheminformatics,diversity,portfolio,molecule-selection
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=1.23
19
+ Requires-Dist: ortools<10,>=9.15
20
+ Provides-Extra: chem
21
+ Requires-Dist: rdkit>=2023.9; extra == "chem"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: build>=1.2; extra == "dev"
25
+ Requires-Dist: twine>=5; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # OPDiv
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/opdiv.svg)](https://pypi.org/project/opdiv/)
32
+
33
+ Molecular portfolio selection and diversity evaluation.
34
+
35
+ Choose exactly **k** candidates with the highest mean score while enforcing a
36
+ pairwise similarity ceiling. Use the resulting mean to evaluate a screening or
37
+ generative method's candidate archive.
38
+
39
+ Implements **Optimal Portfolio Diversity (OPDiv)**, its score-ordered greedy
40
+ approximation **GPDiv**, from
41
+ *Measuring Diversity of Top-K Molecules as an Optimal Portfolio Selection Problem*
42
+ by Miroslav Lžičař (preprint draft, September 2026).
43
+
44
+ ## Install
45
+
46
+ Python 3.10 or newer:
47
+
48
+ ```bash
49
+ pip install opdiv
50
+ ```
51
+
52
+ For development, from this repository:
53
+
54
+ ```bash
55
+ pip install -e '.[chem,dev]'
56
+ ```
57
+
58
+ The core requires NumPy and OR-Tools. The optional `chem` extra adds RDKit for
59
+ building Morgan/Tanimoto similarities from SMILES.
60
+
61
+ ## Select a portfolio
62
+
63
+ ```python
64
+ from opdiv import select, opdiv, gpdiv
65
+
66
+ # The paper's counterexample: A conflicts with B and C; B and C are compatible.
67
+ scores = [16.0, 12.0, 11.0, 6.0] # A, B, C, D; larger is better
68
+ conflicts = [
69
+ [0, 1, 1, 0],
70
+ [1, 0, 0, 0],
71
+ [1, 0, 0, 0],
72
+ [0, 0, 0, 0],
73
+ ]
74
+
75
+ result = select(scores, k=2, conflicts=conflicts)
76
+ print(result.indices) # (1, 2): B and C
77
+ print(result.value) # 11.5
78
+ print(result.status) # optimal
79
+
80
+ # Metric-only calls also perform selection internally.
81
+ print(opdiv(scores, k=2, conflicts=conflicts)) # 11.5
82
+ print(gpdiv(scores, k=2, conflicts=conflicts)) # 11.0: A and D
83
+
84
+ greedy = select(scores, k=2, conflicts=conflicts, method="greedy")
85
+ ```
86
+
87
+ `indices` are zero-based input positions, sorted by descending score, then input
88
+ position. Index a Python list with `[items[i] for i in result.indices]`, or a
89
+ DataFrame with `df.iloc[list(result.indices)]`. Scores are **utilities**: for
90
+ lower-is-better docking energies, pass their negatives.
91
+
92
+ ## From molecules or custom similarities
93
+
94
+ Install RDKit support with `pip install 'opdiv[chem]'`.
95
+
96
+ ```python
97
+ from opdiv import select, tanimoto_similarity
98
+
99
+ smiles = ["CCO", "CCCO", "c1ccccc1", "CC(=O)O"]
100
+ scores = [0.9, 0.8, 0.7, 0.6]
101
+ similarities = tanimoto_similarity(smiles) # Morgan radius 2, 2048 bits, no chirality
102
+
103
+ result = select(scores, k=2, similarities=similarities, max_similarity=0.4)
104
+ selected_smiles = [smiles[i] for i in result.indices]
105
+ ```
106
+
107
+ Any finite symmetric similarity matrix works, including shape or electrostatic
108
+ comparisons. A pair conflicts exactly when **similarity > max_similarity**;
109
+ equality is allowed, and no numerical epsilon is added. The similarity diagonal
110
+ is ignored. Asymmetric matrices must be explicitly symmetrized before use.
111
+
112
+ Alternatively, supply a symmetric boolean/0–1 `conflicts` matrix with a false
113
+ diagonal. For distances, construct conflicts with `distances < min_distance`
114
+ and set the diagonal to false. Use `<=` if equality should conflict. With neither
115
+ matrix, `select(scores, k)` returns ordinary top-k.
116
+
117
+ Filter ineligible candidates and resolve molecular identity **before selection**.
118
+ The SMILES helper rejects invalid/empty molecules and duplicate canonical
119
+ isomeric SMILES; it does not normalize salts or tautomers. Matrix-based calls
120
+ assume each row is a distinct eligible candidate.
121
+
122
+ ## Feasibility and bounds
123
+
124
+ ```python
125
+ result = select(scores, k=2, similarities=similarities,
126
+ max_similarity=0.4, time_limit=10.0)
127
+ print(result.status, result.value, result.upper_bound, result.gap)
128
+ ```
129
+
130
+ | Status | Meaning |
131
+ |---|---|
132
+ | `optimal` | Optimum certified within the numerical tolerance below; inspect `gap`. |
133
+ | `feasible` | Full portfolio found; its mean is a lower bound on OPDiv. |
134
+ | `incomplete` | Greedy stopped short; another full portfolio may exist. |
135
+ | `infeasible` | The requested capacity is proven infeasible. |
136
+ | `unknown` | Search stopped without finding a full portfolio or proving infeasibility. |
137
+
138
+ `value` is the mean of a full selection, or `None` when underfilled. A partial
139
+ greedy result retains its indices, but has no GPDiv value at the requested k.
140
+ `upper_bound` bounds the optimum; `gap` is the absolute difference from `value`.
141
+ Neither repeating candidates nor relaxing the diversity threshold fills a result.
142
+
143
+ `opdiv(...)` raises `MetricUndefinedError` unless optimality is established;
144
+ `gpdiv(...)` raises it when its metric is undefined. The exception
145
+ has a `.result` containing the selection and available bounds. Use `select(...)`
146
+ when you want to inspect a time-limited result rather than require a scalar metric.
147
+
148
+ Optimal selection uses [OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver),
149
+ with binary variables, exactly k selections, and one exclusion constraint per
150
+ conflict edge. A complete greedy portfolio supplies a solver hint and an objective
151
+ lower bound. One search worker and a fixed seed make runs reproducible within a
152
+ solver version.
153
+
154
+ Scores are centered and rescaled before conversion to integer coefficients at
155
+ precision 1e-9 in normalized units. The largest k coefficient-rounding errors
156
+ are included in the returned upper bound. `optimal` means CP-SAT proved the
157
+ integer optimum and the original-score mean gap is at most 1e-8 times the
158
+ centered score scale (the largest absolute centered score, or 1 for constant
159
+ scores), subject to floating-point arithmetic. A small nonzero `gap` can remain
160
+ due to rounding; extremely close portfolios can be indistinguishable at this
161
+ precision. Returned `value` always uses the original scores.
162
+
163
+ There is no default time limit; `time_limit` limits the solver only. On a stopped
164
+ search, a complete greedy portfolio is retained if it beats the solver's candidate.
165
+ Greedy ties follow input order. Equally optimal selections can differ between
166
+ solver versions. Pairwise matrices require O(m²) memory; difficult conflict graphs
167
+ can be expensive to optimize.
168
+
169
+ This package uses the paper's CP-SAT backend and OPDiv/GPDiv definitions. It keeps
170
+ a direct full-graph model; the experiment runner's clique compression, score-prefix
171
+ relaxations, and benchmark pipeline are not included. Its normalized integer
172
+ scaling also differs from the experiment runner's fixed score multiplier.
173
+ For reproduction, use identical eligible candidates, row ordering, scores, and
174
+ conflict graphs: experiment-specific threshold tolerances must be encoded in
175
+ `conflicts` explicitly. The molecular helper matches the paper's non-chiral
176
+ fingerprint settings; compute fingerprints from the original archived graphs
177
+ when reproducing the archive experiments.
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ python -m pytest
183
+ ruff check .
184
+ python -m build
185
+ python -m twine check dist/*
186
+ ```
187
+
188
+ Tests compare optimal results and rounding-aware bounds with exhaustive enumeration,
189
+ exercise the paper's greedy failure, and cover threshold boundaries, negative
190
+ utilities, time-limit outcomes, input validation, and optional molecular support.
191
+
192
+ The source repository is [mireklzicar/opdiv](https://github.com/mireklzicar/opdiv);
193
+ [Deep-MedChem/opdiv](https://github.com/Deep-MedChem/opdiv) is its organization fork.
194
+
195
+ ## Attribution
196
+
197
+ The portfolio metrics and their molecular evaluation application are introduced
198
+ in the paper above. The underlying graph optimization and score-ordered greedy
199
+ algorithms are established methods; this package does not claim their invention.
200
+ See the paper for the scientific formulation and prior work. MIT licensed.
opdiv-0.1.0/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # OPDiv
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/opdiv.svg)](https://pypi.org/project/opdiv/)
4
+
5
+ Molecular portfolio selection and diversity evaluation.
6
+
7
+ Choose exactly **k** candidates with the highest mean score while enforcing a
8
+ pairwise similarity ceiling. Use the resulting mean to evaluate a screening or
9
+ generative method's candidate archive.
10
+
11
+ Implements **Optimal Portfolio Diversity (OPDiv)**, its score-ordered greedy
12
+ approximation **GPDiv**, from
13
+ *Measuring Diversity of Top-K Molecules as an Optimal Portfolio Selection Problem*
14
+ by Miroslav Lžičař (preprint draft, September 2026).
15
+
16
+ ## Install
17
+
18
+ Python 3.10 or newer:
19
+
20
+ ```bash
21
+ pip install opdiv
22
+ ```
23
+
24
+ For development, from this repository:
25
+
26
+ ```bash
27
+ pip install -e '.[chem,dev]'
28
+ ```
29
+
30
+ The core requires NumPy and OR-Tools. The optional `chem` extra adds RDKit for
31
+ building Morgan/Tanimoto similarities from SMILES.
32
+
33
+ ## Select a portfolio
34
+
35
+ ```python
36
+ from opdiv import select, opdiv, gpdiv
37
+
38
+ # The paper's counterexample: A conflicts with B and C; B and C are compatible.
39
+ scores = [16.0, 12.0, 11.0, 6.0] # A, B, C, D; larger is better
40
+ conflicts = [
41
+ [0, 1, 1, 0],
42
+ [1, 0, 0, 0],
43
+ [1, 0, 0, 0],
44
+ [0, 0, 0, 0],
45
+ ]
46
+
47
+ result = select(scores, k=2, conflicts=conflicts)
48
+ print(result.indices) # (1, 2): B and C
49
+ print(result.value) # 11.5
50
+ print(result.status) # optimal
51
+
52
+ # Metric-only calls also perform selection internally.
53
+ print(opdiv(scores, k=2, conflicts=conflicts)) # 11.5
54
+ print(gpdiv(scores, k=2, conflicts=conflicts)) # 11.0: A and D
55
+
56
+ greedy = select(scores, k=2, conflicts=conflicts, method="greedy")
57
+ ```
58
+
59
+ `indices` are zero-based input positions, sorted by descending score, then input
60
+ position. Index a Python list with `[items[i] for i in result.indices]`, or a
61
+ DataFrame with `df.iloc[list(result.indices)]`. Scores are **utilities**: for
62
+ lower-is-better docking energies, pass their negatives.
63
+
64
+ ## From molecules or custom similarities
65
+
66
+ Install RDKit support with `pip install 'opdiv[chem]'`.
67
+
68
+ ```python
69
+ from opdiv import select, tanimoto_similarity
70
+
71
+ smiles = ["CCO", "CCCO", "c1ccccc1", "CC(=O)O"]
72
+ scores = [0.9, 0.8, 0.7, 0.6]
73
+ similarities = tanimoto_similarity(smiles) # Morgan radius 2, 2048 bits, no chirality
74
+
75
+ result = select(scores, k=2, similarities=similarities, max_similarity=0.4)
76
+ selected_smiles = [smiles[i] for i in result.indices]
77
+ ```
78
+
79
+ Any finite symmetric similarity matrix works, including shape or electrostatic
80
+ comparisons. A pair conflicts exactly when **similarity > max_similarity**;
81
+ equality is allowed, and no numerical epsilon is added. The similarity diagonal
82
+ is ignored. Asymmetric matrices must be explicitly symmetrized before use.
83
+
84
+ Alternatively, supply a symmetric boolean/0–1 `conflicts` matrix with a false
85
+ diagonal. For distances, construct conflicts with `distances < min_distance`
86
+ and set the diagonal to false. Use `<=` if equality should conflict. With neither
87
+ matrix, `select(scores, k)` returns ordinary top-k.
88
+
89
+ Filter ineligible candidates and resolve molecular identity **before selection**.
90
+ The SMILES helper rejects invalid/empty molecules and duplicate canonical
91
+ isomeric SMILES; it does not normalize salts or tautomers. Matrix-based calls
92
+ assume each row is a distinct eligible candidate.
93
+
94
+ ## Feasibility and bounds
95
+
96
+ ```python
97
+ result = select(scores, k=2, similarities=similarities,
98
+ max_similarity=0.4, time_limit=10.0)
99
+ print(result.status, result.value, result.upper_bound, result.gap)
100
+ ```
101
+
102
+ | Status | Meaning |
103
+ |---|---|
104
+ | `optimal` | Optimum certified within the numerical tolerance below; inspect `gap`. |
105
+ | `feasible` | Full portfolio found; its mean is a lower bound on OPDiv. |
106
+ | `incomplete` | Greedy stopped short; another full portfolio may exist. |
107
+ | `infeasible` | The requested capacity is proven infeasible. |
108
+ | `unknown` | Search stopped without finding a full portfolio or proving infeasibility. |
109
+
110
+ `value` is the mean of a full selection, or `None` when underfilled. A partial
111
+ greedy result retains its indices, but has no GPDiv value at the requested k.
112
+ `upper_bound` bounds the optimum; `gap` is the absolute difference from `value`.
113
+ Neither repeating candidates nor relaxing the diversity threshold fills a result.
114
+
115
+ `opdiv(...)` raises `MetricUndefinedError` unless optimality is established;
116
+ `gpdiv(...)` raises it when its metric is undefined. The exception
117
+ has a `.result` containing the selection and available bounds. Use `select(...)`
118
+ when you want to inspect a time-limited result rather than require a scalar metric.
119
+
120
+ Optimal selection uses [OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver),
121
+ with binary variables, exactly k selections, and one exclusion constraint per
122
+ conflict edge. A complete greedy portfolio supplies a solver hint and an objective
123
+ lower bound. One search worker and a fixed seed make runs reproducible within a
124
+ solver version.
125
+
126
+ Scores are centered and rescaled before conversion to integer coefficients at
127
+ precision 1e-9 in normalized units. The largest k coefficient-rounding errors
128
+ are included in the returned upper bound. `optimal` means CP-SAT proved the
129
+ integer optimum and the original-score mean gap is at most 1e-8 times the
130
+ centered score scale (the largest absolute centered score, or 1 for constant
131
+ scores), subject to floating-point arithmetic. A small nonzero `gap` can remain
132
+ due to rounding; extremely close portfolios can be indistinguishable at this
133
+ precision. Returned `value` always uses the original scores.
134
+
135
+ There is no default time limit; `time_limit` limits the solver only. On a stopped
136
+ search, a complete greedy portfolio is retained if it beats the solver's candidate.
137
+ Greedy ties follow input order. Equally optimal selections can differ between
138
+ solver versions. Pairwise matrices require O(m²) memory; difficult conflict graphs
139
+ can be expensive to optimize.
140
+
141
+ This package uses the paper's CP-SAT backend and OPDiv/GPDiv definitions. It keeps
142
+ a direct full-graph model; the experiment runner's clique compression, score-prefix
143
+ relaxations, and benchmark pipeline are not included. Its normalized integer
144
+ scaling also differs from the experiment runner's fixed score multiplier.
145
+ For reproduction, use identical eligible candidates, row ordering, scores, and
146
+ conflict graphs: experiment-specific threshold tolerances must be encoded in
147
+ `conflicts` explicitly. The molecular helper matches the paper's non-chiral
148
+ fingerprint settings; compute fingerprints from the original archived graphs
149
+ when reproducing the archive experiments.
150
+
151
+ ## Development
152
+
153
+ ```bash
154
+ python -m pytest
155
+ ruff check .
156
+ python -m build
157
+ python -m twine check dist/*
158
+ ```
159
+
160
+ Tests compare optimal results and rounding-aware bounds with exhaustive enumeration,
161
+ exercise the paper's greedy failure, and cover threshold boundaries, negative
162
+ utilities, time-limit outcomes, input validation, and optional molecular support.
163
+
164
+ The source repository is [mireklzicar/opdiv](https://github.com/mireklzicar/opdiv);
165
+ [Deep-MedChem/opdiv](https://github.com/Deep-MedChem/opdiv) is its organization fork.
166
+
167
+ ## Attribution
168
+
169
+ The portfolio metrics and their molecular evaluation application are introduced
170
+ in the paper above. The underlying graph optimization and score-ordered greedy
171
+ algorithms are established methods; this package does not claim their invention.
172
+ See the paper for the scientific formulation and prior work. MIT licensed.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opdiv"
7
+ version = "0.1.0"
8
+ description = "Molecular portfolio selection and diversity evaluation"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Miroslav Lzicar"}]
14
+ keywords = ["cheminformatics", "diversity", "portfolio", "molecule-selection"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering :: Chemistry",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = ["numpy>=1.23", "ortools>=9.15,<10"]
23
+
24
+ [project.optional-dependencies]
25
+ chem = ["rdkit>=2023.9"]
26
+ dev = ["pytest>=7", "build>=1.2", "twine>=5", "ruff>=0.6"]
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/mireklzicar/opdiv"
30
+ Issues = "https://github.com/mireklzicar/opdiv/issues"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ opdiv = ["py.typed"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py310"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "B"]
opdiv-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ """Molecular portfolio selection and diversity evaluation."""
2
+
3
+ from ._core import (
4
+ MetricUndefinedError,
5
+ Portfolio,
6
+ gpdiv,
7
+ opdiv,
8
+ select,
9
+ )
10
+ from ._similarity import conflicts_from_similarity, tanimoto_similarity
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "MetricUndefinedError",
15
+ "Portfolio",
16
+ "conflicts_from_similarity",
17
+ "gpdiv",
18
+ "opdiv",
19
+ "select",
20
+ "tanimoto_similarity",
21
+ ]
@@ -0,0 +1,273 @@
1
+ """Fixed-cardinality portfolio objectives, independent of molecular representation."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ import numpy as np
7
+ from numpy.typing import ArrayLike, NDArray
8
+ from ortools.sat.python import cp_model
9
+
10
+ from ._similarity import conflicts_from_similarity
11
+
12
+ _OBJECTIVE_SCALE = 1_000_000_000
13
+ _OPTIMALITY_TOLERANCE = 1e-8
14
+
15
+ Status = Literal["optimal", "feasible", "infeasible", "incomplete", "unknown"]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Portfolio:
20
+ """Selection and its mean-utility bounds at the requested capacity.
21
+
22
+ ``indices`` refer to input rows, ordered by descending score, then input
23
+ index. ``value`` is None unless exactly k compatible candidates were found.
24
+ ``upper_bound`` bounds OPDiv, including integer-coefficient rounding error.
25
+ Optimality is numerical: the certified mean gap is at most 1e-8 times the
26
+ centered score scale, subject to floating-point arithmetic.
27
+ """
28
+
29
+ indices: tuple[int, ...]
30
+ k: int
31
+ value: float | None
32
+ upper_bound: float | None
33
+ status: Status
34
+
35
+ @property
36
+ def is_complete(self) -> bool:
37
+ return len(self.indices) == self.k
38
+
39
+ @property
40
+ def gap(self) -> float | None:
41
+ """Absolute gap in mean-utility units, or None without a full portfolio."""
42
+ if self.value is None or self.upper_bound is None:
43
+ return None
44
+ return max(0.0, self.upper_bound - self.value)
45
+
46
+
47
+ class MetricUndefinedError(ValueError):
48
+ """No full portfolio or no proven optimum; inspect ``result`` for details."""
49
+
50
+ def __init__(self, metric: str, result: Portfolio):
51
+ self.result = result
52
+ super().__init__(
53
+ f"{metric} is unavailable (status={result.status}, "
54
+ f"selected={len(result.indices)}/{result.k}); use select() to inspect bounds"
55
+ )
56
+
57
+
58
+ def _inputs(scores: ArrayLike, k: int) -> NDArray[np.float64]:
59
+ q = np.asarray(scores, dtype=float)
60
+ if q.ndim != 1 or not np.isfinite(q).all():
61
+ raise ValueError("scores must be a one-dimensional array of finite utilities")
62
+ if isinstance(k, bool) or not isinstance(k, (int, np.integer)) or k < 1:
63
+ raise ValueError("k must be a positive integer")
64
+ return q
65
+
66
+
67
+ def _graph(
68
+ m: int,
69
+ conflicts: ArrayLike | None,
70
+ similarities: ArrayLike | None,
71
+ max_similarity: float | None,
72
+ ) -> NDArray[np.bool_]:
73
+ if similarities is not None:
74
+ if conflicts is not None or max_similarity is None:
75
+ raise ValueError("Supply similarities with max_similarity, or conflicts, not both")
76
+ a = conflicts_from_similarity(similarities, max_similarity)
77
+ else:
78
+ if max_similarity is not None:
79
+ raise ValueError("max_similarity requires similarities")
80
+ if conflicts is None:
81
+ return np.zeros((m, m), dtype=bool)
82
+ raw = np.asarray(conflicts)
83
+ if not np.isin(raw, [0, 1]).all():
84
+ raise ValueError("conflicts must contain only booleans or 0/1")
85
+ a = raw.astype(bool)
86
+ if a.shape != (m, m) or not np.array_equal(a, a.T):
87
+ raise ValueError("The pairwise matrix must be symmetric and match the number of scores")
88
+ if np.diag(a).any():
89
+ raise ValueError("conflicts must have a false diagonal")
90
+ return a
91
+
92
+
93
+ def _result(q, picks, k, upper, status) -> Portfolio:
94
+ indices = tuple(sorted((int(i) for i in picks), key=lambda i: (-q[i], i)))
95
+ # Divide first to avoid overflowing the sum of finite scores.
96
+ value = float(np.sum(q[list(indices)] / k)) if len(indices) == k else None
97
+ return Portfolio(indices, int(k), value, upper, status)
98
+
99
+
100
+ def _greedy(q, a, k) -> list[int]:
101
+ blocked = np.zeros(len(q), dtype=bool)
102
+ picks = []
103
+ for i in np.argsort(-q, kind="stable"):
104
+ if not blocked[i]:
105
+ picks.append(int(i))
106
+ blocked |= a[i]
107
+ blocked[i] = True
108
+ if len(picks) == k:
109
+ break
110
+ return picks
111
+
112
+
113
+ def select(
114
+ scores: ArrayLike,
115
+ k: int,
116
+ *,
117
+ similarities: ArrayLike | None = None,
118
+ max_similarity: float | None = None,
119
+ conflicts: ArrayLike | None = None,
120
+ method: Literal["optimal", "greedy"] = "optimal",
121
+ time_limit: float | None = None,
122
+ ) -> Portfolio:
123
+ """Select exactly k candidates maximizing mean score under pairwise exclusion.
124
+
125
+ Larger scores are better: negate docking energies before calling. Supply
126
+ either a similarity matrix and ceiling (equality allowed), a boolean conflict
127
+ matrix, or neither for ordinary top-k. Candidates must already be eligible
128
+ and distinct; matrix inputs cannot establish molecular identity.
129
+
130
+ ``optimal`` uses OR-Tools CP-SAT optimization without a time limit
131
+ by default. ``time_limit`` limits solver seconds, excluding graph preparation.
132
+ Integer rounding is included in the returned upper bound. Optimality is
133
+ certified within 1e-8 times the centered score scale. A stopped search
134
+ returns a feasible lower bound if available. ``greedy`` scans by score with
135
+ input-order tie breaking;
136
+ underfilling does not establish infeasibility. Equally optimal portfolios may
137
+ differ across solver versions; returned indices are always score-sorted.
138
+ """
139
+ q = _inputs(scores, k)
140
+ a = _graph(len(q), conflicts, similarities, max_similarity)
141
+ if method not in ("optimal", "greedy"):
142
+ raise ValueError("method must be 'optimal' or 'greedy'")
143
+ if time_limit is not None:
144
+ if not np.isscalar(time_limit) or not np.isfinite(time_limit) or time_limit <= 0:
145
+ raise ValueError("time_limit must be positive and finite")
146
+ if method != "optimal":
147
+ raise ValueError("time_limit applies only to optimal selection")
148
+ if k > len(q):
149
+ return _result(q, [], k, None, "infeasible")
150
+ top = np.argsort(-q, kind="stable")[:k]
151
+ upper = float(np.sum(q[top] / k))
152
+ if not a[np.ix_(top, top)].any():
153
+ return _result(q, top, k, upper, "optimal")
154
+ picks = _greedy(q, a, k)
155
+ if method == "greedy":
156
+ return _result(q, picks, k, upper, "feasible" if len(picks) == k else "incomplete")
157
+
158
+ # Center/rescale before integer conversion so offsets and units do not
159
+ # erase score differences. At fixed k this preserves the exact objective.
160
+ center = float(np.max(q) / 2 + np.min(q) / 2)
161
+ centered = q - center
162
+ scale = float(np.max(np.abs(centered))) or 1.0
163
+ normalized = centered / scale
164
+ weights = np.rint(normalized * _OBJECTIVE_SCALE).astype(np.int64)
165
+ # For ANY size-k set, the largest k coefficient errors bound mean error.
166
+ errors = np.abs(normalized - weights / _OBJECTIVE_SCALE)
167
+ rounding_error = float(np.sum(np.sort(errors)[-k:] / k))
168
+
169
+ model = cp_model.CpModel()
170
+ x = [model.new_bool_var(f"x{i}") for i in range(len(q))]
171
+ model.add(sum(x) == k)
172
+ ii, jj = np.where(np.triu(a, 1))
173
+ for i, j in zip(ii, jj, strict=True):
174
+ model.add_at_most_one(x[i], x[j])
175
+ objective = sum(int(w) * var for w, var in zip(weights, x, strict=True))
176
+ model.maximize(objective)
177
+ if len(picks) == k:
178
+ chosen = set(picks)
179
+ model.add(objective >= sum(int(weights[i]) for i in picks))
180
+ for i, var in enumerate(x):
181
+ model.add_hint(var, int(i in chosen))
182
+ else:
183
+ picks = []
184
+
185
+ solver = cp_model.CpSolver()
186
+ solver.parameters.num_search_workers = 1
187
+ solver.parameters.random_seed = 20260914
188
+ solver.parameters.relative_gap_limit = 0
189
+ solver.parameters.absolute_gap_limit = 0
190
+ if time_limit is not None:
191
+ solver.parameters.max_time_in_seconds = float(time_limit)
192
+ code = solver.solve(model)
193
+ if code == cp_model.INFEASIBLE:
194
+ if picks:
195
+ raise RuntimeError("Solver reported infeasibility despite a feasible greedy portfolio")
196
+ return _result(q, [], k, None, "infeasible")
197
+ if code not in (cp_model.OPTIMAL, cp_model.FEASIBLE, cp_model.UNKNOWN):
198
+ raise RuntimeError(f"Portfolio solver failed: {solver.solution_info()}")
199
+
200
+ status = "feasible" if picks else "unknown"
201
+ if code in (cp_model.OPTIMAL, cp_model.FEASIBLE):
202
+ candidate = [i for i, var in enumerate(x) if solver.value(var)]
203
+ if len(candidate) != k or a[np.ix_(candidate, candidate)].any():
204
+ raise RuntimeError("Solver returned an invalid portfolio")
205
+ if not picks or np.sum(normalized[candidate]) > np.sum(normalized[picks]):
206
+ picks = candidate
207
+ # UNKNOWN can expose an uninitialized solver bound; retain top-k then.
208
+ normalized_upper = (
209
+ solver.best_objective_bound / (_OBJECTIVE_SCALE * k) + rounding_error
210
+ )
211
+ # Small outward allowance for arithmetic in scaling and bound conversion.
212
+ normalized_upper += 16 * np.finfo(float).eps
213
+ with np.errstate(over="ignore"):
214
+ upper = min(upper, float(normalized_upper * scale + center))
215
+ value = float(np.sum(q[picks] / k))
216
+ upper = max(upper, value)
217
+ normalized_gap = normalized_upper - float(np.sum(normalized[picks] / k))
218
+ status = (
219
+ "optimal"
220
+ if code == cp_model.OPTIMAL and normalized_gap <= _OPTIMALITY_TOLERANCE
221
+ else "feasible"
222
+ )
223
+ return _result(q, picks, k, upper, status)
224
+
225
+
226
+ def opdiv(
227
+ scores: ArrayLike,
228
+ k: int,
229
+ *,
230
+ similarities: ArrayLike | None = None,
231
+ max_similarity: float | None = None,
232
+ conflicts: ArrayLike | None = None,
233
+ time_limit: float | None = None,
234
+ ) -> float:
235
+ """Optimal Portfolio Diversity: the best feasible size-k mean utility.
236
+
237
+ Raise MetricUndefinedError for infeasibility or an unproven optimum.
238
+ Optimality uses the numerical tolerance documented by select().
239
+ The exception's result retains any feasible portfolio and its bounds.
240
+ """
241
+ result = select(
242
+ scores,
243
+ k,
244
+ similarities=similarities,
245
+ max_similarity=max_similarity,
246
+ conflicts=conflicts,
247
+ time_limit=time_limit,
248
+ )
249
+ if result.status != "optimal" or result.value is None:
250
+ raise MetricUndefinedError("OPDiv", result)
251
+ return result.value
252
+
253
+
254
+ def gpdiv(
255
+ scores: ArrayLike,
256
+ k: int,
257
+ *,
258
+ similarities: ArrayLike | None = None,
259
+ max_similarity: float | None = None,
260
+ conflicts: ArrayLike | None = None,
261
+ ) -> float:
262
+ """Greedy Portfolio Diversity; raise MetricUndefinedError if underfilled."""
263
+ result = select(
264
+ scores,
265
+ k,
266
+ similarities=similarities,
267
+ max_similarity=max_similarity,
268
+ conflicts=conflicts,
269
+ method="greedy",
270
+ )
271
+ if result.value is None:
272
+ raise MetricUndefinedError("GPDiv", result)
273
+ return result.value
@@ -0,0 +1,65 @@
1
+ """Pairwise conflict graphs and optional molecular fingerprints."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import numpy as np
6
+ from numpy.typing import ArrayLike, NDArray
7
+
8
+
9
+ def conflicts_from_similarity(similarities: ArrayLike, max_similarity: float) -> NDArray[np.bool_]:
10
+ """Build a graph with an edge exactly when s(i, j) > max_similarity.
11
+
12
+ Equality is compatible. Require a finite, exactly symmetric square matrix;
13
+ the diagonal is ignored. Similarities need not be in [0, 1]. No implicit
14
+ symmetrization or threshold tolerance is applied.
15
+ """
16
+ s = np.asarray(similarities, dtype=float)
17
+ if s.ndim != 2 or s.shape[0] != s.shape[1]:
18
+ raise ValueError("similarities must be a square matrix")
19
+ if not np.isfinite(s).all() or not np.array_equal(s, s.T):
20
+ raise ValueError("similarities must be finite and symmetric")
21
+ if not np.isscalar(max_similarity) or not np.isfinite(max_similarity):
22
+ raise ValueError("max_similarity must be finite")
23
+ graph = s > max_similarity
24
+ np.fill_diagonal(graph, False)
25
+ return graph
26
+
27
+
28
+ def tanimoto_similarity(
29
+ smiles: Sequence[str], *, radius: int = 2, n_bits: int = 2048
30
+ ) -> NDArray[np.float64]:
31
+ """Morgan bit-fingerprint Tanimoto matrix; requires ``opdiv[chem]``.
32
+
33
+ Uses no chirality, matching the paper, and preserves input order. Invalid, empty, or duplicate
34
+ canonical isomeric SMILES raise ValueError. No salt/tautomer normalization
35
+ is performed; establish your molecular identity policy before calling.
36
+ """
37
+ try:
38
+ from rdkit import Chem, DataStructs
39
+ from rdkit.Chem import rdFingerprintGenerator
40
+ except ImportError as exc:
41
+ raise ImportError("Install molecular support with: pip install 'opdiv[chem]'") from exc
42
+
43
+ if isinstance(smiles, str):
44
+ raise ValueError("smiles must be a sequence of SMILES strings")
45
+ for name, value, minimum in [("radius", radius, 0), ("n_bits", n_bits, 1)]:
46
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer)) or value < minimum:
47
+ raise ValueError(f"{name} must be an integer >= {minimum}")
48
+ generator = rdFingerprintGenerator.GetMorganGenerator(
49
+ radius=int(radius), fpSize=int(n_bits), includeChirality=False
50
+ )
51
+ fingerprints, seen = [], set()
52
+ for i, text in enumerate(smiles):
53
+ mol = Chem.MolFromSmiles(text) if isinstance(text, str) and text.strip() else None
54
+ if mol is None or mol.GetNumAtoms() == 0:
55
+ raise ValueError(f"Invalid or empty SMILES at index {i}: {text!r}")
56
+ identity = Chem.MolToSmiles(mol, isomericSmiles=True)
57
+ if identity in seen:
58
+ raise ValueError(f"Duplicate molecule at index {i}: {text!r}")
59
+ seen.add(identity)
60
+ fingerprints.append(generator.GetFingerprint(mol))
61
+ matrix = np.eye(len(fingerprints), dtype=float)
62
+ for i in range(1, len(fingerprints)):
63
+ matrix[i, :i] = DataStructs.BulkTanimotoSimilarity(fingerprints[i], fingerprints[:i])
64
+ matrix[:i, i] = matrix[i, :i]
65
+ return matrix
File without changes
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: opdiv
3
+ Version: 0.1.0
4
+ Summary: Molecular portfolio selection and diversity evaluation
5
+ Author: Miroslav Lzicar
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/mireklzicar/opdiv
8
+ Project-URL: Issues, https://github.com/mireklzicar/opdiv/issues
9
+ Keywords: cheminformatics,diversity,portfolio,molecule-selection
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=1.23
19
+ Requires-Dist: ortools<10,>=9.15
20
+ Provides-Extra: chem
21
+ Requires-Dist: rdkit>=2023.9; extra == "chem"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: build>=1.2; extra == "dev"
25
+ Requires-Dist: twine>=5; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # OPDiv
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/opdiv.svg)](https://pypi.org/project/opdiv/)
32
+
33
+ Molecular portfolio selection and diversity evaluation.
34
+
35
+ Choose exactly **k** candidates with the highest mean score while enforcing a
36
+ pairwise similarity ceiling. Use the resulting mean to evaluate a screening or
37
+ generative method's candidate archive.
38
+
39
+ Implements **Optimal Portfolio Diversity (OPDiv)**, its score-ordered greedy
40
+ approximation **GPDiv**, from
41
+ *Measuring Diversity of Top-K Molecules as an Optimal Portfolio Selection Problem*
42
+ by Miroslav Lžičař (preprint draft, September 2026).
43
+
44
+ ## Install
45
+
46
+ Python 3.10 or newer:
47
+
48
+ ```bash
49
+ pip install opdiv
50
+ ```
51
+
52
+ For development, from this repository:
53
+
54
+ ```bash
55
+ pip install -e '.[chem,dev]'
56
+ ```
57
+
58
+ The core requires NumPy and OR-Tools. The optional `chem` extra adds RDKit for
59
+ building Morgan/Tanimoto similarities from SMILES.
60
+
61
+ ## Select a portfolio
62
+
63
+ ```python
64
+ from opdiv import select, opdiv, gpdiv
65
+
66
+ # The paper's counterexample: A conflicts with B and C; B and C are compatible.
67
+ scores = [16.0, 12.0, 11.0, 6.0] # A, B, C, D; larger is better
68
+ conflicts = [
69
+ [0, 1, 1, 0],
70
+ [1, 0, 0, 0],
71
+ [1, 0, 0, 0],
72
+ [0, 0, 0, 0],
73
+ ]
74
+
75
+ result = select(scores, k=2, conflicts=conflicts)
76
+ print(result.indices) # (1, 2): B and C
77
+ print(result.value) # 11.5
78
+ print(result.status) # optimal
79
+
80
+ # Metric-only calls also perform selection internally.
81
+ print(opdiv(scores, k=2, conflicts=conflicts)) # 11.5
82
+ print(gpdiv(scores, k=2, conflicts=conflicts)) # 11.0: A and D
83
+
84
+ greedy = select(scores, k=2, conflicts=conflicts, method="greedy")
85
+ ```
86
+
87
+ `indices` are zero-based input positions, sorted by descending score, then input
88
+ position. Index a Python list with `[items[i] for i in result.indices]`, or a
89
+ DataFrame with `df.iloc[list(result.indices)]`. Scores are **utilities**: for
90
+ lower-is-better docking energies, pass their negatives.
91
+
92
+ ## From molecules or custom similarities
93
+
94
+ Install RDKit support with `pip install 'opdiv[chem]'`.
95
+
96
+ ```python
97
+ from opdiv import select, tanimoto_similarity
98
+
99
+ smiles = ["CCO", "CCCO", "c1ccccc1", "CC(=O)O"]
100
+ scores = [0.9, 0.8, 0.7, 0.6]
101
+ similarities = tanimoto_similarity(smiles) # Morgan radius 2, 2048 bits, no chirality
102
+
103
+ result = select(scores, k=2, similarities=similarities, max_similarity=0.4)
104
+ selected_smiles = [smiles[i] for i in result.indices]
105
+ ```
106
+
107
+ Any finite symmetric similarity matrix works, including shape or electrostatic
108
+ comparisons. A pair conflicts exactly when **similarity > max_similarity**;
109
+ equality is allowed, and no numerical epsilon is added. The similarity diagonal
110
+ is ignored. Asymmetric matrices must be explicitly symmetrized before use.
111
+
112
+ Alternatively, supply a symmetric boolean/0–1 `conflicts` matrix with a false
113
+ diagonal. For distances, construct conflicts with `distances < min_distance`
114
+ and set the diagonal to false. Use `<=` if equality should conflict. With neither
115
+ matrix, `select(scores, k)` returns ordinary top-k.
116
+
117
+ Filter ineligible candidates and resolve molecular identity **before selection**.
118
+ The SMILES helper rejects invalid/empty molecules and duplicate canonical
119
+ isomeric SMILES; it does not normalize salts or tautomers. Matrix-based calls
120
+ assume each row is a distinct eligible candidate.
121
+
122
+ ## Feasibility and bounds
123
+
124
+ ```python
125
+ result = select(scores, k=2, similarities=similarities,
126
+ max_similarity=0.4, time_limit=10.0)
127
+ print(result.status, result.value, result.upper_bound, result.gap)
128
+ ```
129
+
130
+ | Status | Meaning |
131
+ |---|---|
132
+ | `optimal` | Optimum certified within the numerical tolerance below; inspect `gap`. |
133
+ | `feasible` | Full portfolio found; its mean is a lower bound on OPDiv. |
134
+ | `incomplete` | Greedy stopped short; another full portfolio may exist. |
135
+ | `infeasible` | The requested capacity is proven infeasible. |
136
+ | `unknown` | Search stopped without finding a full portfolio or proving infeasibility. |
137
+
138
+ `value` is the mean of a full selection, or `None` when underfilled. A partial
139
+ greedy result retains its indices, but has no GPDiv value at the requested k.
140
+ `upper_bound` bounds the optimum; `gap` is the absolute difference from `value`.
141
+ Neither repeating candidates nor relaxing the diversity threshold fills a result.
142
+
143
+ `opdiv(...)` raises `MetricUndefinedError` unless optimality is established;
144
+ `gpdiv(...)` raises it when its metric is undefined. The exception
145
+ has a `.result` containing the selection and available bounds. Use `select(...)`
146
+ when you want to inspect a time-limited result rather than require a scalar metric.
147
+
148
+ Optimal selection uses [OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver),
149
+ with binary variables, exactly k selections, and one exclusion constraint per
150
+ conflict edge. A complete greedy portfolio supplies a solver hint and an objective
151
+ lower bound. One search worker and a fixed seed make runs reproducible within a
152
+ solver version.
153
+
154
+ Scores are centered and rescaled before conversion to integer coefficients at
155
+ precision 1e-9 in normalized units. The largest k coefficient-rounding errors
156
+ are included in the returned upper bound. `optimal` means CP-SAT proved the
157
+ integer optimum and the original-score mean gap is at most 1e-8 times the
158
+ centered score scale (the largest absolute centered score, or 1 for constant
159
+ scores), subject to floating-point arithmetic. A small nonzero `gap` can remain
160
+ due to rounding; extremely close portfolios can be indistinguishable at this
161
+ precision. Returned `value` always uses the original scores.
162
+
163
+ There is no default time limit; `time_limit` limits the solver only. On a stopped
164
+ search, a complete greedy portfolio is retained if it beats the solver's candidate.
165
+ Greedy ties follow input order. Equally optimal selections can differ between
166
+ solver versions. Pairwise matrices require O(m²) memory; difficult conflict graphs
167
+ can be expensive to optimize.
168
+
169
+ This package uses the paper's CP-SAT backend and OPDiv/GPDiv definitions. It keeps
170
+ a direct full-graph model; the experiment runner's clique compression, score-prefix
171
+ relaxations, and benchmark pipeline are not included. Its normalized integer
172
+ scaling also differs from the experiment runner's fixed score multiplier.
173
+ For reproduction, use identical eligible candidates, row ordering, scores, and
174
+ conflict graphs: experiment-specific threshold tolerances must be encoded in
175
+ `conflicts` explicitly. The molecular helper matches the paper's non-chiral
176
+ fingerprint settings; compute fingerprints from the original archived graphs
177
+ when reproducing the archive experiments.
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ python -m pytest
183
+ ruff check .
184
+ python -m build
185
+ python -m twine check dist/*
186
+ ```
187
+
188
+ Tests compare optimal results and rounding-aware bounds with exhaustive enumeration,
189
+ exercise the paper's greedy failure, and cover threshold boundaries, negative
190
+ utilities, time-limit outcomes, input validation, and optional molecular support.
191
+
192
+ The source repository is [mireklzicar/opdiv](https://github.com/mireklzicar/opdiv);
193
+ [Deep-MedChem/opdiv](https://github.com/Deep-MedChem/opdiv) is its organization fork.
194
+
195
+ ## Attribution
196
+
197
+ The portfolio metrics and their molecular evaluation application are introduced
198
+ in the paper above. The underlying graph optimization and score-ordered greedy
199
+ algorithms are established methods; this package does not claim their invention.
200
+ See the paper for the scientific formulation and prior work. MIT licensed.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/opdiv/__init__.py
5
+ src/opdiv/_core.py
6
+ src/opdiv/_similarity.py
7
+ src/opdiv/py.typed
8
+ src/opdiv.egg-info/PKG-INFO
9
+ src/opdiv.egg-info/SOURCES.txt
10
+ src/opdiv.egg-info/dependency_links.txt
11
+ src/opdiv.egg-info/requires.txt
12
+ src/opdiv.egg-info/top_level.txt
13
+ tests/test_core.py
14
+ tests/test_similarity.py
@@ -0,0 +1,11 @@
1
+ numpy>=1.23
2
+ ortools<10,>=9.15
3
+
4
+ [chem]
5
+ rdkit>=2023.9
6
+
7
+ [dev]
8
+ pytest>=7
9
+ build>=1.2
10
+ twine>=5
11
+ ruff>=0.6
@@ -0,0 +1 @@
1
+ opdiv
@@ -0,0 +1,195 @@
1
+ from itertools import combinations
2
+ from types import SimpleNamespace
3
+
4
+ import numpy as np
5
+ import pytest
6
+ from ortools.sat.python import cp_model
7
+
8
+ from opdiv import MetricUndefinedError, gpdiv, opdiv, select
9
+
10
+
11
+ def graph():
12
+ return np.array([[0, 1, 1, 0], [1, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0]], bool)
13
+
14
+
15
+ def test_paper_counterexample():
16
+ scores = [16, 12, 11, 6]
17
+ result = select(scores, 2, conflicts=graph())
18
+ assert result.indices == (1, 2)
19
+ assert result.status == "optimal"
20
+ assert result.is_complete
21
+ assert result.gap == pytest.approx(0)
22
+ assert opdiv(scores, 2, conflicts=graph()) == 11.5
23
+ assert gpdiv(scores, 2, conflicts=graph()) == 11
24
+
25
+
26
+ def test_greedy_underfill_is_not_infeasibility():
27
+ a = graph()[:3, :3]
28
+ greedy = select([16, 12, 11], 2, conflicts=a, method="greedy")
29
+ assert greedy.status == "incomplete"
30
+ assert greedy.indices == (0,)
31
+ assert greedy.value is None and greedy.gap is None
32
+ assert not greedy.is_complete
33
+ with pytest.raises(MetricUndefinedError) as exc:
34
+ gpdiv([16, 12, 11], 2, conflicts=a)
35
+ assert exc.value.result == greedy
36
+ assert opdiv([16, 12, 11], 2, conflicts=a) == 11.5
37
+
38
+
39
+ @pytest.mark.parametrize("seed", range(12))
40
+ def test_optimum_and_bounds_against_exhaustive_enumeration(seed):
41
+ rng = np.random.default_rng(seed)
42
+ q = rng.normal(size=9)
43
+ a = np.triu(rng.random((9, 9)) < 0.45, 1)
44
+ a |= a.T
45
+ for k in (1, 3, 6, 9):
46
+ values = [
47
+ float(q[list(s)].mean()) for s in combinations(range(9), k) if not a[np.ix_(s, s)].any()
48
+ ]
49
+ result = select(q, k, conflicts=a)
50
+ if not values:
51
+ assert result.status == "infeasible"
52
+ assert result.value is None and result.upper_bound is None
53
+ else:
54
+ optimum = max(values)
55
+ assert result.status == "optimal"
56
+ assert result.value == pytest.approx(optimum)
57
+ assert result.upper_bound >= optimum - 1e-8
58
+ assert result.value <= optimum + 1e-8
59
+ assert len(set(result.indices)) == k
60
+ assert not a[np.ix_(result.indices, result.indices)].any()
61
+
62
+
63
+ @pytest.mark.parametrize("scores,k", [([], 1), ([1], 2)])
64
+ def test_not_enough_candidates(scores, k):
65
+ assert select(scores, k).status == "infeasible"
66
+ with pytest.raises(MetricUndefinedError):
67
+ opdiv(scores, k)
68
+
69
+
70
+ def test_negative_scores_and_stable_ties():
71
+ assert select([-4, -2, -2, -8], 3).indices == (1, 2, 0)
72
+ assert opdiv([-4, -2, -2, -8], 3) == pytest.approx(-8 / 3)
73
+
74
+
75
+ @pytest.mark.parametrize("scale,offset", [(1e-12, 0), (1e-3, 1e9), (1e6, -1e12)])
76
+ def test_positive_affine_score_transform_preserves_selection(scale, offset):
77
+ q = np.array([16, 12, 11, 6]) * scale + offset
78
+ result = select(q, 2, conflicts=graph())
79
+ assert result.indices == (1, 2)
80
+ assert result.value == pytest.approx(float(q[[1, 2]].mean()))
81
+
82
+
83
+ def mock_solver(monkeypatch, code, selected=(), bound=0):
84
+ class Solver:
85
+ parameters = SimpleNamespace()
86
+ best_objective_bound = bound
87
+
88
+ def solve(self, model):
89
+ return code
90
+
91
+ def value(self, var):
92
+ return var.index in selected
93
+
94
+ def solution_info(self):
95
+ return "mock model error"
96
+
97
+ monkeypatch.setattr("opdiv._core.cp_model.CpSolver", Solver)
98
+
99
+
100
+ def test_timeout_retains_greedy_and_refuses_scalar_metric(monkeypatch):
101
+ mock_solver(monkeypatch, cp_model.UNKNOWN)
102
+ result = select([16, 12, 11, 6], 2, conflicts=graph(), time_limit=0.001)
103
+ assert result.status == "feasible"
104
+ assert result.indices == (0, 3)
105
+ assert result.value == 11 and result.upper_bound == 14
106
+ with pytest.raises(MetricUndefinedError) as exc:
107
+ opdiv([16, 12, 11, 6], 2, conflicts=graph(), time_limit=0.001)
108
+ assert exc.value.result == result
109
+ unknown = select([16, 12, 11], 2, conflicts=graph()[:3, :3], time_limit=0.001)
110
+ assert unknown.status == "unknown"
111
+ assert unknown.value is None and unknown.upper_bound == 14
112
+
113
+
114
+ def test_timeout_with_solver_incumbent_and_bound(monkeypatch):
115
+ mock_solver(monkeypatch, cp_model.FEASIBLE, selected=(1, 2), bound=400_000_000)
116
+ result = select([16, 12, 11, 6], 2, conflicts=graph(), time_limit=0.001)
117
+ assert result.status == "feasible"
118
+ assert result.value == 11.5
119
+ assert result.upper_bound == pytest.approx(12)
120
+ assert result.gap == pytest.approx(0.5)
121
+
122
+
123
+ def test_rejects_invalid_solver_portfolio(monkeypatch):
124
+ mock_solver(monkeypatch, cp_model.OPTIMAL, selected=(0, 1))
125
+ with pytest.raises(RuntimeError, match="invalid portfolio"):
126
+ select([16, 12, 11, 6], 2, conflicts=graph())
127
+
128
+
129
+ @pytest.mark.parametrize(
130
+ "scores,k,kwargs",
131
+ [
132
+ ([1, np.nan], 1, {}),
133
+ ([[1, 2]], 1, {}),
134
+ ([1, 2], 0, {}),
135
+ ([1, 2], True, {}),
136
+ ([1, 2], 1.5, {}),
137
+ ([1, 2], 1, {"conflicts": [[0, 1], [0, 0]]}),
138
+ ([1, 2], 1, {"conflicts": [[1, 0], [0, 0]]}),
139
+ ([1, 2], 1, {"conflicts": [[0, 0.5], [0.5, 0]]}),
140
+ ([1, 2], 1, {"similarities": np.eye(3), "max_similarity": 0.5}),
141
+ ([1, 2], 1, {"similarities": np.eye(2)}),
142
+ ([1, 2], 1, {"max_similarity": 0.5}),
143
+ ([1, 2], 1, {"method": "typo"}),
144
+ ([1, 2], 1, {"time_limit": -1}),
145
+ ([1, 2], 1, {"time_limit": np.nan}),
146
+ ([1, 2], 1, {"time_limit": 1, "method": "greedy"}),
147
+ (
148
+ [1, 2],
149
+ 1,
150
+ {"similarities": np.eye(2), "max_similarity": 0.5, "conflicts": np.zeros((2, 2))},
151
+ ),
152
+ ],
153
+ )
154
+ def test_invalid_inputs(scores, k, kwargs):
155
+ with pytest.raises(ValueError):
156
+ select(scores, k, **kwargs)
157
+
158
+
159
+ @pytest.mark.parametrize("seed", range(12))
160
+ def test_rounding_bounds_cover_original_float_optimum(seed):
161
+ rng = np.random.default_rng(seed)
162
+ q = rng.uniform(-1, 1, 8)
163
+ a = np.triu(rng.random((8, 8)) < 0.3, 1)
164
+ a |= a.T
165
+ k = 3
166
+ feasible = [q[list(s)].mean() for s in combinations(range(8), k) if not a[np.ix_(s, s)].any()]
167
+ result = select(q, k, conflicts=a)
168
+ if feasible:
169
+ optimum = max(feasible)
170
+ assert result.value <= optimum + 1e-14
171
+ assert result.upper_bound >= optimum - 1e-14
172
+ assert result.gap <= 1e-8 * np.ptp(q)
173
+
174
+
175
+ def test_integer_optimum_does_not_hide_rounding_gap(monkeypatch):
176
+ # Both portfolios tie after integer rounding; B,C have better real utility.
177
+ q = [1, 0.75, 0.75000000001, 0.5]
178
+ mock_solver(monkeypatch, cp_model.OPTIMAL, selected=(0, 3), bound=0)
179
+ result = select(q, 2, conflicts=graph())
180
+ assert result.value == 0.75
181
+ assert result.upper_bound >= np.mean(q[1:3])
182
+ assert result.gap > 0
183
+
184
+
185
+ def test_model_errors_are_not_reported_as_infeasibility(monkeypatch):
186
+ mock_solver(monkeypatch, cp_model.MODEL_INVALID)
187
+ with pytest.raises(RuntimeError, match="mock model error"):
188
+ select([16, 12, 11, 6], 2, conflicts=graph())
189
+
190
+
191
+ def test_public_api_contains_only_pairwise_metrics():
192
+ import opdiv as package
193
+
194
+ assert not hasattr(package, "cpdiv")
195
+ assert not hasattr(package, "select_clusters")
@@ -0,0 +1,59 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from opdiv import conflicts_from_similarity, opdiv, select, tanimoto_similarity
5
+
6
+
7
+ def test_threshold_equality_and_adjacent_float():
8
+ s = np.array([[1, 0.7], [0.7, 1]])
9
+ original = s.copy()
10
+ assert not conflicts_from_similarity(s, 0.7).any()
11
+ assert opdiv([2, 1], 2, similarities=s, max_similarity=0.7) == 1.5
12
+ threshold = np.nextafter(0.7, -np.inf)
13
+ assert select([2, 1], 2, similarities=s, max_similarity=threshold).status == "infeasible"
14
+ np.testing.assert_array_equal(s, original)
15
+
16
+
17
+ @pytest.mark.parametrize(
18
+ "matrix,threshold",
19
+ [
20
+ ([[1, 0.2], [0.3, 1]], 0.5),
21
+ ([[1, np.nan], [np.nan, 1]], 0.5),
22
+ ([[1, 0]], 0.5),
23
+ (np.eye(2), np.inf),
24
+ ],
25
+ )
26
+ def test_invalid_similarity(matrix, threshold):
27
+ with pytest.raises(ValueError):
28
+ conflicts_from_similarity(matrix, threshold)
29
+
30
+
31
+ def test_morgan_tanimoto_matches_rdkit():
32
+ pytest.importorskip("rdkit")
33
+ from rdkit import Chem, DataStructs
34
+ from rdkit.Chem import rdFingerprintGenerator
35
+
36
+ smiles = ["CCO", "CCCO", "c1ccccc1", "CC(=O)O"]
37
+ matrix = tanimoto_similarity(smiles)
38
+ generator = rdFingerprintGenerator.GetMorganGenerator(
39
+ radius=2, fpSize=2048, includeChirality=False
40
+ )
41
+ fps = [generator.GetFingerprint(Chem.MolFromSmiles(s)) for s in smiles]
42
+ for i in range(len(smiles)):
43
+ for j in range(len(smiles)):
44
+ assert matrix[i, j] == DataStructs.TanimotoSimilarity(fps[i], fps[j])
45
+ assert select([0.9, 0.8, 0.7, 0.6], 2, similarities=matrix, max_similarity=0.4).is_complete
46
+ assert tanimoto_similarity([]).shape == (0, 0)
47
+
48
+
49
+ @pytest.mark.parametrize("smiles", [["CCO", "OCC"], [""], ["invalid"], "CCO"])
50
+ def test_invalid_molecules(smiles):
51
+ pytest.importorskip("rdkit")
52
+ with pytest.raises(ValueError):
53
+ tanimoto_similarity(smiles)
54
+
55
+
56
+ def test_morgan_ignores_chirality_as_in_paper():
57
+ pytest.importorskip("rdkit")
58
+ s = tanimoto_similarity(["C[C@H](O)F", "C[C@@H](O)F"])
59
+ assert s[0, 1] == 1.0