opdiv 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opdiv/__init__.py +21 -0
- opdiv/_core.py +273 -0
- opdiv/_similarity.py +65 -0
- opdiv/py.typed +0 -0
- opdiv-0.1.0.dist-info/METADATA +200 -0
- opdiv-0.1.0.dist-info/RECORD +9 -0
- opdiv-0.1.0.dist-info/WHEEL +5 -0
- opdiv-0.1.0.dist-info/licenses/LICENSE +21 -0
- opdiv-0.1.0.dist-info/top_level.txt +1 -0
opdiv/__init__.py
ADDED
|
@@ -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
|
+
]
|
opdiv/_core.py
ADDED
|
@@ -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
|
opdiv/_similarity.py
ADDED
|
@@ -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
|
opdiv/py.typed
ADDED
|
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
|
+
[](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,9 @@
|
|
|
1
|
+
opdiv/__init__.py,sha256=OYvAocEm9jUDqfAa2unHK_ZN1OGQKWgriNkuXNhHAOc,415
|
|
2
|
+
opdiv/_core.py,sha256=FIswYtHSjQOrdggJDeXTU5hNV9Z-8DGYDzoZo87W8Q8,10618
|
|
3
|
+
opdiv/_similarity.py,sha256=0zbCToNTxjB8rBjPrXciBOZKaqdaANT3JyXOqCUXdSQ,3042
|
|
4
|
+
opdiv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
opdiv-0.1.0.dist-info/licenses/LICENSE,sha256=7fdoLNKBWOccTfhQpUWuKLk7SmWgY5J2854_FAAYg98,1072
|
|
6
|
+
opdiv-0.1.0.dist-info/METADATA,sha256=Zc01Ss4tWz9KsJ-zH2UxWFMqs7aydzY6PwtiUWzd9TM,8334
|
|
7
|
+
opdiv-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
opdiv-0.1.0.dist-info/top_level.txt,sha256=9RGXvX84CgQni5tP3y2fxHNWawdJ08OllQVYHEaGpOI,6
|
|
9
|
+
opdiv-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opdiv
|