Skip to content

Commit 8911830

Browse files
committed
ensemble output is now set to [0,1] range, same for inconsistency resolution input. mv and wmv-conf are now separate models
1 parent 8ec5a1c commit 8911830

13 files changed

Lines changed: 178 additions & 104 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,15 @@ The two-sided scaling matters whenever a model's threshold is not 0.5: with $t_{
162162
negative prediction only has a range of $0.2$ to move in and a positive one a range of $0.8$, so
163163
without rescaling the positive side would systematically outweigh the negative side.
164164

165-
Confidence can be disabled by the `use_confidence` parameter of the predict method (default: True).
165+
Confidence is used by the weighted voting ensembles (`wmv-conf` and `wmv-f1`). If the `ensemble_type`
166+
is set to `mv`, all votes count the same (confidence is fixed to 1), which gives an unweighted
167+
majority-voting baseline.
166168

167169
The`model_weight` can be set for each model in the configuration file (default: 1). This is used to favor a certain
168170
model independently of a given class.
169171
`Trust` is based on the model's performance on a validation set. After training, we evaluate the Machine Learning models
170172
on a validation set for each class. If the `ensemble_type` is set to `wmv-f1`, the trust is calculated as F1-score $^{6.25}$.
171-
If the `ensemble_type` is set to `mv` (the default), the trust is set to 1 for all models.
173+
For `mv` and `wmv-conf`, the trust is set to 1 for all models.
172174

173175
#### Learned aggregation (`ltr` and `des`)
174176

chebifier/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
# even if multiple subpackages are imported later.
33

44
from ._custom_cache import PerSmilesPerModelLRUCache, modelwise_smiles_lru_cache
5-
from .ensemble.voting_ensemble import VotingEnsemble
5+
from .ensemble.voting_ensemble import (
6+
MajorityVotingEnsemble,
7+
VotingEnsemble,
8+
WMVwithConfidenceEnsemble,
9+
)
610

711
__all__ = [
812
"VotingEnsemble",
13+
"MajorityVotingEnsemble",
14+
"WMVwithConfidenceEnsemble",
915
"PerSmilesPerModelLRUCache",
1016
"modelwise_smiles_lru_cache",
1117
]

chebifier/ensemble/base_ensemble.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ def ensemble_name(self):
2727

2828
@property
2929
def decision_threshold(self):
30-
"""Value a net score has to exceed for the class to be predicted.
30+
"""Probability a class has to exceed to be predicted.
3131
32-
Zero for every ensemble whose scores are already centred on their decision boundary; only
33-
ensembles that emit a calibrated, unshifted scale need to override this.
32+
Ensembles report probabilities, so the default is the neutral point: predict a class when
33+
the ensemble believes it more likely than not. Ensembles that tune their own operating
34+
point (to maximise macro-F1, say) override this.
3435
"""
35-
return 0.0
36+
return 0.5
3637

3738
def calibrate(
3839
self,

chebifier/ensemble/dynamic_selection_ensemble.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
cv_folds,
1515
dense_from_pairs,
1616
holdout_split,
17+
noncandidate_probability,
1718
pair_scorer,
1819
rescale_to_threshold,
1920
save_hyperparameter_results,
@@ -289,9 +290,8 @@ def aggregate(delta, chunk, competence_threshold, vote):
289290
if vote == "confidence":
290291
direction = direction * 2.0 * np.abs(chunk.profiles - POSITIVE_THRESHOLD)
291292
total = weight.sum(axis=1)
292-
return ((weight * direction).sum(axis=1) / np.maximum(total, 1e-6)).astype(
293-
np.float32
294-
)
293+
agreement = (weight * direction).sum(axis=1) / np.maximum(total, 1e-6)
294+
return np.clip(0.5 + agreement / 2, 0.0, 1.0).astype(np.float32)
295295

296296

297297
class DynamicSelectionEnsemble(VotingEnsemble):
@@ -407,6 +407,11 @@ def calibrate(self, validation_predictions, validation_data, validation_labels):
407407
labels, candidates.molecule, candidates.class_index, dev_mask
408408
)
409409
tau, dev_macro_f1 = scorer.tune(net[keep])
410+
floor = noncandidate_probability(
411+
scores.shape[0] * scores.shape[1] - candidates.molecule.size,
412+
int(labels.sum())
413+
- int(labels[candidates.molecule, candidates.class_index].sum()),
414+
)
410415

411416
# the meta-classifier and tau are fitted with the dev molecules held out of the reference
412417
# set, but nothing stops prediction from looking neighbours up in all of them
@@ -433,10 +438,10 @@ def calibrate(self, validation_predictions, validation_data, validation_labels):
433438
"consensus_threshold": float(self.consensus_threshold),
434439
"competence_threshold": float(self.competence_threshold),
435440
"tau": float(tau),
441+
"floor": floor,
436442
"n_classes": int(scores.shape[1]),
437443
"n_dsel": int(reference_mask.sum()),
438444
"dev_macro_f1": float(dev_macro_f1),
439-
"normalized": True,
440445
},
441446
)
442447
print(
@@ -732,12 +737,11 @@ def _load(self):
732737
)
733738
with open(self._metadata_path, "r", encoding="utf-8") as f:
734739
self._metadata = json.load(f)
735-
if not self._metadata.get("normalized"):
740+
if "floor" not in self._metadata:
736741
raise ValueError(
737-
f"The ensemble in {self.ensemble_dir} was calibrated before net scores were "
738-
"normalised by the selection weight. Its stored tau is on the old (unnormalised) "
739-
"scale and would reject every prediction. Please re-run `chebifier build` for this "
740-
"ensemble."
742+
f"The ensemble in {self.ensemble_dir} was calibrated before scores became "
743+
"probabilities. Its stored tau is on the old scale and would reject every "
744+
"prediction. Please re-run `chebifier build` for this ensemble."
741745
)
742746
with open(self._classifier_path, "rb") as f:
743747
self._classifier = pickle.load(f)
@@ -809,10 +813,17 @@ def predict(self, test_predictions, molecules=None):
809813
dense = dense_from_pairs(
810814
candidates.molecule,
811815
candidates.class_index,
812-
net - self._metadata["tau"],
816+
net,
813817
scores.shape[:2],
818+
floor=self._metadata["floor"],
814819
)
815820
return {
816821
"net_score": torch.from_numpy(dense),
817822
"has_valid_predictions": torch.from_numpy((~np.isnan(scores)).any(axis=2)),
818823
}
824+
825+
@property
826+
def decision_threshold(self):
827+
"""Probability a candidate has to exceed, the operating point macro-F1 peaks at."""
828+
self._load()
829+
return float(self._metadata["tau"])

chebifier/ensemble/learning_to_rank_ensemble.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
dense_from_pairs,
1414
fit_platt,
1515
holdout_split,
16-
noncandidate_log_odds,
16+
noncandidate_probability,
1717
pair_scorer,
1818
save_hyperparameter_results,
1919
select_candidates,
@@ -75,6 +75,10 @@ def build_rows(scores, k, labels=None):
7575
return rows
7676

7777

78+
def _sigmoid(x):
79+
return float(1.0 / (1.0 + np.exp(-x)))
80+
81+
7882
def design_matrix(rows, class_stats=None):
7983
if class_stats is None:
8084
return rows["X"]
@@ -142,7 +146,7 @@ def calibrate(self, validation_predictions, validation_data, validation_labels):
142146
rows, labels, train_mask, dev_mask, scores, thresholds
143147
)
144148
platt_a, platt_b = fit_platt(dev_scores, rows["y"][dev_mask[rows["molecule"]]])
145-
floor_logodds = noncandidate_log_odds(
149+
floor = noncandidate_probability(
146150
scores.shape[0] * scores.shape[1] - rows["molecule"].size,
147151
int(labels.sum()) - int(rows["y"].sum()),
148152
)
@@ -161,8 +165,8 @@ def calibrate(self, validation_predictions, validation_data, validation_labels):
161165
"tau": float(tau),
162166
"platt_a": platt_a,
163167
"platt_b": platt_b,
164-
"tau_logodds": platt_a * float(tau) + platt_b,
165-
"floor_logodds": floor_logodds,
168+
"tau_probability": _sigmoid(platt_a * float(tau) + platt_b),
169+
"floor": floor,
166170
"n_classes": int(scores.shape[1]),
167171
"best_iteration": int(booster.best_iteration),
168172
"dev_macro_f1": float(dev_macro_f1),
@@ -175,9 +179,9 @@ def calibrate(self, validation_predictions, validation_data, validation_labels):
175179
self._booster, self._metadata = booster, metadata
176180
print(
177181
f"Saved ranker to {self._model_path} (candidate_k={candidate_k}, tau={tau:.4f}, "
178-
f"held-out macro-f1: {dev_macro_f1:.4f}). Calibrated to log-odds with "
179-
f"a={platt_a:.4f}, b={platt_b:.4f} (decision threshold {metadata['tau_logodds']:.4f}); "
180-
f"non-candidate pairs scored at {floor_logodds:.2f}."
182+
f"held-out macro-f1: {dev_macro_f1:.4f}). Calibrated to probabilities with "
183+
f"a={platt_a:.4f}, b={platt_b:.4f} (decision threshold "
184+
f"{metadata['tau_probability']:.4f}); non-candidate pairs scored at {floor:.2e}."
181185
)
182186

183187
def _fit(self, rows, labels, train_mask, dev_mask, scores=None, thresholds=None):
@@ -299,13 +303,9 @@ def _load(self):
299303

300304
@property
301305
def decision_threshold(self):
302-
"""Decision threshold on the scale `predict` returns.
303-
304-
Zero for rankers calibrated before Platt scaling was introduced - those still subtract tau
305-
from the score themselves.
306-
"""
306+
"""Probability a candidate has to exceed, the operating point macro-F1 peaks at."""
307307
self._load()
308-
return float(self._metadata.get("tau_logodds", 0.0))
308+
return float(self._metadata["tau_probability"])
309309

310310
def predict(self, test_predictions, molecules=None):
311311
self._load()
@@ -314,15 +314,18 @@ def predict(self, test_predictions, molecules=None):
314314
raw = self._booster.predict(design_matrix(rows, self._class_stats)).astype(
315315
np.float32
316316
)
317-
if "platt_a" in self._metadata:
318-
# calibrated log-odds, with the decision threshold left to the caller so that the
319-
# inconsistency resolution sees an unshifted scale
320-
net = self._metadata["platt_a"] * raw + self._metadata["platt_b"]
321-
floor = self._metadata["floor_logodds"]
322-
else:
323-
net, floor = raw - self._metadata["tau"], None
317+
# the ranker score is not a probability, so it goes through the calibration fitted during
318+
# `calibrate`; the decision threshold is left to the caller so that inconsistency
319+
# resolution sees the probabilities themselves
320+
net = 1.0 / (
321+
1.0 + np.exp(-(self._metadata["platt_a"] * raw + self._metadata["platt_b"]))
322+
)
324323
dense = dense_from_pairs(
325-
rows["molecule"], rows["class_index"], net, scores.shape[:2], floor=floor
324+
rows["molecule"],
325+
rows["class_index"],
326+
net.astype(np.float32),
327+
scores.shape[:2],
328+
floor=self._metadata["floor"],
326329
)
327330
return {
328331
"net_score": torch.from_numpy(dense),

chebifier/ensemble/level1.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,15 @@ def dense_from_pairs(molecule, class_index, net, shape, floor=None):
165165

166166

167167
def fit_platt(scores, labels):
168-
"""Fit `P(positive) = sigmoid(a * score + b)` so that `a * score + b` is calibrated log-odds.
168+
"""Fit `P(positive) = sigmoid(a * score + b)`, turning a raw score into a probability.
169169
170170
A lambdarank score is only trained to order pairs within a group, so its scale carries no
171-
probabilistic meaning. Inconsistency resolution needs one: it compares scores across classes and
172-
maps them through `sigmoid(k * score)`. Being a monotone map, this leaves the ensemble's own
173-
thresholded decisions untouched - what it buys is a scale on which those downstream comparisons
174-
are meaningful.
171+
probabilistic meaning. Inconsistency resolution needs one: it compares scores across classes.
172+
Being a monotone map, this leaves the ensemble's own thresholded decisions untouched - what it
173+
buys is a scale on which those downstream comparisons are meaningful.
174+
175+
The voting ensembles need no equivalent: their weighted agreement fraction is already a well
176+
calibrated probability, and a logistic fit measurably degrades it.
175177
"""
176178
from sklearn.linear_model import LogisticRegression
177179

@@ -187,17 +189,16 @@ def fit_platt(scores, labels):
187189
return slope, intercept
188190

189191

190-
def noncandidate_log_odds(n_noncandidate, n_missed_positives):
191-
"""Log-odds that a pair outside the candidate set is nevertheless a positive.
192+
def noncandidate_probability(n_noncandidate, n_missed_positives):
193+
"""Probability that a pair outside the candidate set is nevertheless a positive.
192194
193195
Candidate selection keeps only the top-k classes per model, so the pairs it drops are not
194196
"unknown" - they are overwhelmingly true negatives, and how overwhelmingly is measurable on the
195197
validation set. This turns the fill value for those pairs from an arbitrary floor into a
196-
calibrated score that can be compared against the candidates. The Jeffreys-style pseudo-count
197-
keeps the result finite when no positive is missed at all.
198+
calibrated probability that can be compared against the candidates. The Jeffreys-style
199+
pseudo-count keeps the result non-zero when no positive is missed at all.
198200
"""
199-
p = (n_missed_positives + 0.5) / (n_noncandidate + 1.0)
200-
return float(np.log(p / (1.0 - p)))
201+
return float((n_missed_positives + 0.5) / (n_noncandidate + 1.0))
201202

202203

203204
def save_hyperparameter_results(ensemble_dir, results, best):

chebifier/ensemble/voting_ensemble.py

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88

99

1010
class VotingEnsemble(BaseEnsemble):
11+
"""Base class for the voting ensembles. Subclasses define the vote weights by overriding
12+
calculate_confidence (self-reported confidence of a model) and calculate_trust (measured
13+
reliability of a model)."""
14+
1115
def __init__(
1216
self,
1317
ensemble_dir: str,
14-
use_confidence: bool = True,
1518
):
1619
super().__init__(ensemble_dir)
17-
self.use_confidence = use_confidence
1820
self.classwise_f1 = None
1921
self.prediction_thresholds = None
2022

@@ -66,19 +68,26 @@ def _load_prediction_thresholds(self) -> dict[str, float]:
6668
f"Prediction thresholds file not found in ensemble directory: {self.ensemble_dir}. Please calibrate the ensemble first."
6769
)
6870

71+
def calculate_confidence(
72+
self, predictions_tensor: torch.Tensor, thresholds: torch.Tensor
73+
) -> torch.Tensor:
74+
raise NotImplementedError
75+
6976
def calculate_trust(self, predictions: dict[str, torch.Tensor]) -> torch.Tensor:
70-
# No trust for MV, only used in WMV
77+
# No trust unless a subclass measures it (e.g. WMVwithF1Ensemble)
7178
return 1
7279

7380
def predict(self, test_predictions: dict[str, torch.Tensor], molecules=None):
7481
"""
75-
Aggregates predictions from multiple models using weighted majority voting.
76-
weights are only the self-reported confidence (=difference between prediction and threshold). If set to false, all models are weighted equally.
77-
78-
The net score is normalised by the weight mass that was cast, so it is a signed agreement
79-
fraction in [-1, 1] rather than a sum over models. This keeps classes covered by different
80-
numbers of base learners comparable, which matters downstream: inconsistency resolution
81-
compares scores across classes. The sign is unaffected, so class decisions do not change.
82+
Aggregates predictions from multiple models by voting, each vote weighted by
83+
calculate_confidence * calculate_trust.
84+
85+
The net score is the weighted agreement among the models that voted, mapped onto [0, 1]:
86+
1 if they unanimously predict the class, 0 if they unanimously reject it, 0.5 if they are
87+
evenly split. Normalising by the weight mass that was cast (rather than summing over models)
88+
keeps classes covered by different numbers of base learners comparable, which is what
89+
inconsistency resolution needs - it compares scores across classes. The agreement fraction
90+
is a well calibrated probability on its own, so no further calibration is applied.
8291
"""
8392
predictions_tensor = torch.stack(
8493
list(test_predictions.values()), dim=2
@@ -114,16 +123,9 @@ def predict(self, test_predictions: dict[str, torch.Tensor], molecules=None):
114123
predictions_tensor < threshold_mask.unsqueeze(0).unsqueeze(0)
115124
) & valid_predictions
116125

117-
if self.use_confidence:
118-
threshold = threshold_mask.unsqueeze(0).unsqueeze(0)
119-
scores = predictions_tensor.nan_to_num()
120-
confidence = torch.where(
121-
scores < threshold,
122-
(threshold - scores) / threshold,
123-
(scores - threshold) / (1 - threshold),
124-
)
125-
else:
126-
confidence = torch.ones_like(predictions_tensor)
126+
confidence = self.calculate_confidence(
127+
predictions_tensor, threshold_mask.unsqueeze(0).unsqueeze(0)
128+
)
127129

128130
trust = self.calculate_trust(test_predictions)
129131
# Calculate weighted predictions using broadcasting
@@ -141,8 +143,13 @@ def predict(self, test_predictions: dict[str, torch.Tensor], molecules=None):
141143
) # Shape: (num_molecules, num_classes)
142144

143145
# Determine which classes to include for each molecule
144-
net_score = (positive_sum - negative_sum) / (positive_sum + negative_sum).clamp(
145-
min=1e-6
146+
net_score = (
147+
0.5
148+
+ (positive_sum - negative_sum)
149+
/ (positive_sum + negative_sum).clamp(min=1e-6)
150+
/ 2
151+
).clamp(
152+
0.0, 1.0
146153
) # Shape: (num_molecules, num_classes)
147154
return {
148155
"net_score": net_score,
@@ -153,3 +160,28 @@ def predict(self, test_predictions: dict[str, torch.Tensor], molecules=None):
153160
"positive_mask": positive_mask,
154161
"negative_mask": negative_mask,
155162
}
163+
164+
165+
class MajorityVotingEnsemble(VotingEnsemble):
166+
"""Plain majority voting: every model that votes counts the same, no weights at all."""
167+
168+
def calculate_confidence(
169+
self, predictions_tensor: torch.Tensor, thresholds: torch.Tensor
170+
) -> torch.Tensor:
171+
return torch.ones_like(predictions_tensor)
172+
173+
174+
class WMVwithConfidenceEnsemble(VotingEnsemble):
175+
"""WMV ensemble that weights each vote by the model's self-reported confidence, i.e. how far
176+
its prediction sits from its decision threshold, scaled separately on each side so that a
177+
maximally confident negative and a maximally confident positive both count 1."""
178+
179+
def calculate_confidence(
180+
self, predictions_tensor: torch.Tensor, thresholds: torch.Tensor
181+
) -> 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+
)

0 commit comments

Comments
 (0)