Skip to content

Commit c70d92f

Browse files
committed
inconsistency resolution fixes and remove legacy methods
1 parent a371097 commit c70d92f

8 files changed

Lines changed: 134 additions & 299 deletions

File tree

README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,12 @@ After a decision has been made for each class independently, the consistency of
246246
and disjointness axioms is checked. This is
247247
done in 3 steps:
248248
- (1) First, the hierarchy is corrected. For each pair of classes $A$ and $B$ where $A$ is a subclass of $B$ (following
249-
the is-a relation in ChEBI), we set the ensemble prediction of $A$ to $0$ if the _absolute value_ of $B$'s score is large than that of $A$. For example, if $A$ has a net score of $3$ and $B$ has a net score of $-4$, the ensemble will set $A$ to $0$ (i.e., predict neither $A$ nor $B$).
249+
the is-a relation in ChEBI), we set the ensemble prediction of $A$ to that of $B$ if $B$ is the more
250+
_confident_ of the two, and $B$ to that of $A$ otherwise. Confidence is the distance from the decision
251+
threshold, scaled separately on each side of it so that a maximally confident negative and a maximally
252+
confident positive both count $1$ — the same measure `wmv-conf` weights its votes by. For example, if
253+
$A$ scores $0.6$ and $B$ scores $0.1$ at a threshold of $0.5$, $B$ is the more confident one
254+
($0.8$ against $0.2$), so $A$ is lowered to $0.1$ and neither class is predicted.
250255
- (2) Next, we check for disjointness. This is not specified directly in ChEBI, but in an additional ChEBI module ([chebi-disjoints.owl](https://ftp.ebi.ac.uk/pub/databases/chebi/ontology/)).
251256
We have extracted these disjointness axioms into a CSV file and added some more disjointness axioms ourselves (see
252257
`data>disjoint_chebi.csv` and `data>disjoint_additional.csv`). If two classes $A$ and $B$ are disjoint and we predict
@@ -273,12 +278,11 @@ operating point the ensemble reports as `decision_threshold`, which is not alway
273278
Gödel is winner-take-all (it raises the parent to the child, and zeroes the weaker side of a
274279
disjoint pair), whereas Łukasiewicz shares the correction — a disjointness violation with scores
275280
$0.8$ and $0.7$ becomes $0.55$ and $0.45$ rather than $0.8$ and $0$.
276-
- `hex`, `hex-legacy` — **HEX graphs**
281+
- `hex` — **HEX graphs**
277282
([Deng et al. 2014](https://doi.org/10.1007/978-3-319-10590-1_4)). A CRF over binary label
278283
vectors in which hierarchy edges forbid $(B, A) = (0, 1)$ and exclusion edges forbid
279284
$(1, 1)$. Illegal states have probability zero, so the marginals satisfy
280285
$P(A) \le P(B)$ for $A \subseteq B$ and $P(A) + P(B) \le 1$ for disjoint $A, B$ by construction.
281-
The two variants differ only in how they cope with the intractability described below.
282286

283287
`ilr-godel` and `ilr-lukasiewicz` are tuned with `alpha`, `max_iter` and `tol`, passed as
284288
`-irp alpha=0.5`. `scripts/calibrate_resolution.py` grid-searches resolution parameters against a
@@ -293,8 +297,8 @@ $O(\min(|V|2^w, |V|2^{\Omega}))$, and on a 2117-class ChEBI label set the maximu
293297
$\Omega = 2115$ and the junction tree width is $\le 62$, with over 5 million legal states in the
294298
largest cliques — the paper's efficiency argument assumes labels are mostly mutually exclusive,
295299
whereas ChEBI labels overwhelmingly overlap (~25 classes hold per molecule). Exact junction-tree
296-
inference is therefore not an option at this scale, and both variants deviate from the published
297-
method; this should be reported as such.
300+
inference is therefore not an option at this scale, so `hex` deviates from the published method;
301+
this should be reported as such.
298302

299303
`hex` (`chebifier/hex_bounded.py`) replaces exact inference with a **branch-and-bound over partial
300304
assignments**. Each search node fixes some labels on and some off, leaving the rest free, and
@@ -311,10 +315,3 @@ therefore decided negative — ties go against predicting the class — and the
311315
labels is accumulated in `n_uncertified`. Pass `budget` and `processes` (molecules are bounded in
312316
parallel across a worker pool) with `-irp budget=4000`. `threshold` defaults to the ensemble's
313317
operating point and only needs to be set explicitly to override it.
314-
315-
`hex-legacy` (`chebifier/hex_graph.py`) instead *clamps*: classes whose score is further than
316-
`delta` from the boundary, and which are not involved in a violation, are fixed to their sign; that
317-
assignment is propagated to a fixpoint; and exact inference runs only on the connected components
318-
of what remains (typically fewer than 30 classes). Components above `max_component_size` fall back
319-
to `score-based`, counted in `n_fallbacks`. Unlike `hex`, it gives no guarantee about how far the
320-
result is from the true marginals.

chebifier/ensemble/level1.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ def rescale_to_threshold(scores, thresholds):
3535
)
3636

3737

38+
def rescale_from_threshold(scores, thresholds):
39+
scores = np.asarray(scores, dtype=np.float32)
40+
thresholds = np.asarray(thresholds, dtype=np.float32)
41+
return np.where(
42+
scores < POSITIVE_THRESHOLD,
43+
thresholds * scores / POSITIVE_THRESHOLD,
44+
thresholds
45+
+ (1 - thresholds) * (scores - POSITIVE_THRESHOLD) / POSITIVE_THRESHOLD,
46+
)
47+
48+
3849
def threshold_array(thresholds, model_names):
3950
missing = [name for name in model_names if name not in thresholds]
4051
if missing:

chebifier/ensemble/voting_ensemble.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from torchmetrics import F1Score
66

77
from chebifier.ensemble.base_ensemble import BaseEnsemble
8+
from chebifier.inconsistency_resolution import confidence
89

910

1011
class VotingEnsemble(BaseEnsemble):
@@ -179,9 +180,4 @@ class WMVwithConfidenceEnsemble(VotingEnsemble):
179180
def calculate_confidence(
180181
self, predictions_tensor: torch.Tensor, thresholds: torch.Tensor
181182
) -> torch.Tensor:
182-
scores = predictions_tensor.nan_to_num()
183-
return torch.where(
184-
scores < thresholds,
185-
(thresholds - scores) / thresholds,
186-
(scores - thresholds) / (1 - thresholds),
187-
)
183+
return confidence(predictions_tensor.nan_to_num(), thresholds)

chebifier/hex_bounded.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,15 @@ def aggregate(items, chunk=512):
294294
return out
295295

296296

297+
def has_violation(g, y):
298+
pos = np.nonzero(y)[0]
299+
if pos.size == 0:
300+
return False
301+
if (g.strict[pos].any(0) & ~y).any():
302+
return True
303+
return bool(pos.size > 1 and g.excl[np.ix_(pos, pos)].any())
304+
305+
297306
_WORKER_GRAPH = None
298307

299308

@@ -329,7 +338,7 @@ def bounded_marginals_many(
329338
F,
330339
budget=400,
331340
threshold=None,
332-
check_every=200,
341+
check_every=1000,
333342
processes=None,
334343
chunksize=8,
335344
pool=None,
@@ -367,7 +376,7 @@ def __init__(
367376
threshold=0.5,
368377
processes=None,
369378
):
370-
from chebifier.inconsistency_resolution import PredictionSmoother
379+
from chebifier.inconsistency_resolution import ScoreBasedPredictionSmoother
371380

372381
self.verbose = verbose
373382
self.budget = budget
@@ -376,7 +385,9 @@ def __init__(
376385
self.n_uncertified = 0
377386
self._pool = None
378387
self.graph = None
379-
self._base = PredictionSmoother(chebi_graph, None, disjoint_files, verbose)
388+
self._base = ScoreBasedPredictionSmoother(
389+
chebi_graph, None, disjoint_files, verbose
390+
)
380391
self.set_label_names(label_names)
381392

382393
def set_label_names(self, label_names):
@@ -425,22 +436,42 @@ def __del__(self):
425436
def __call__(self, preds, valid_mask=None):
426437
import torch
427438

439+
from chebifier.ensemble.level1 import (
440+
POSITIVE_THRESHOLD,
441+
rescale_from_threshold,
442+
rescale_to_threshold,
443+
)
428444
from chebifier.inconsistency_resolution import seed_uncovered
429445

430-
preds = seed_uncovered(preds, valid_mask)
431-
p = np.clip(preds.detach().cpu().numpy().astype(np.float64), 1e-6, 1 - 1e-6)
446+
# the marginals are taken over legal states only, so they already satisfy the constraints
447+
# as inequalities: disjoint classes cannot both exceed half the mass, and a subclass never
448+
# carries more than its superclass. Thresholding turns those inequalities into a consistent
449+
# assignment only at the neutral point, so the ensemble's operating point is rescaled onto
450+
# it on the way in and the marginals are rescaled back on the way out.
451+
preds = seed_uncovered(preds, valid_mask, self.threshold)
452+
p = rescale_to_threshold(preds.detach().cpu().numpy(), self.threshold)
453+
p = np.clip(p.astype(np.float64), 1e-6, 1 - 1e-6)
432454
f = np.log(p) - np.log1p(-p)
433455
res = bounded_marginals_many(
434456
self.graph,
435457
f,
436458
budget=self.budget,
437-
threshold=self.threshold,
459+
threshold=POSITIVE_THRESHOLD,
438460
processes=self.processes,
439461
pool=self._worker_pool(),
440462
)
441463
lb, ub = res["lb"], res["ub"]
442-
certified = (lb > self.threshold) | (ub <= self.threshold)
464+
certified = (lb > POSITIVE_THRESHOLD) | (ub <= POSITIVE_THRESHOLD)
443465
self.n_uncertified += int((~certified).sum())
466+
# reporting the lower bound sends every uncertified label negative, which keeps the
467+
# assignment consistent: lb > 1/2 for two disjoint classes would put their true marginals
468+
# over the total mass
469+
out = rescale_from_threshold(lb, self.threshold)
470+
out = np.where(
471+
lb > POSITIVE_THRESHOLD,
472+
np.maximum(out, np.nextafter(np.float32(self.threshold), np.float32(1))),
473+
np.minimum(out, np.float32(self.threshold)),
474+
)
444475
if self.verbose:
445476
print(f"BoundedHex: {int((~certified).sum())} uncertified label decisions")
446-
return torch.tensor(lb, dtype=preds.dtype, device=preds.device)
477+
return torch.tensor(out, dtype=preds.dtype, device=preds.device)

chebifier/hex_graph.py

Lines changed: 0 additions & 211 deletions
This file was deleted.

0 commit comments

Comments
 (0)