88
99
1010class 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