Inference
Averaging logits can reverse a soft-voting decision
A three-model binary fixture distinguishes averaging probabilities from averaging log-odds, with a threshold reversal and an odds-based identity check.
Evidence and testing
Review record: Tensor Almanac automated evidence gate (no independent human review). Example versions, recorded outputs and limitations appear below.
Specify the space in which models are combined
Soft voting combines predicted class probabilities and selects the class with the highest combined probability. With equal model weights, this is an arithmetic average. Scikit-learn documents this rule for its soft VotingClassifier. Averaging binary logits before applying a sigmoid is a different combination rule. [1]
Here a binary logit means the natural log-odds log(p / (1 - p)) for the same class in every model. It is not a raw score with an arbitrary scale. The example starts from explicit probabilities so both combination rules receive exactly the same model opinions. All model rows refer to the same sample columns.
Use a fixture that changes the final decision
Run python examples/probability_vs_logit_pool.py. For the first sample, three models assign class-one probabilities of 0.99, 0.2 and 0.2. Their arithmetic mean is about 0.463333. Applying the sigmoid to their mean log-odds instead gives about 0.647373. At this fixture's threshold, which assigns class one only above 0.5, the first rule chooses zero and the second chooses one.
The second sample gives every model probability 0.8. Both rules retain 0.8, providing an agreement case alongside the reversal. Assertions check the output shape, both decision vectors and an independent odds-product identity. The standalone example uses fixed NumPy arrays rather than fitting an ensemble.
A separate local execution fitted a scikit-learn soft VotingClassifier whose three constituent probabilities matched the first sample. Its probability and predicted label agreed with the arithmetic average, not the logit-pool decision. This checks the documented API behavior on an artificial fitted ensemble; it is not an accuracy comparison.
Executed with Python 3.12.14 and NumPy 2.3.5. Supplemental scikit-learn 1.8.0 VotingClassifier execution is recorded in the private review. Exact stdout and source-byte SHA-256 are preserved.
"""Equal-weight binary probability pooling versus log-odds pooling."""
import numpy as np
def pool_binary(probabilities):
p = np.asarray(probabilities, dtype=float)
if p.ndim != 2 or 0 in p.shape:
raise ValueError("nonempty model-by-sample matrix required")
if not np.isfinite(p).all() or ((p <= 0) | (p >= 1)).any():
raise ValueError("finite probabilities strictly inside zero and one required")
mean_probability = p.mean(axis=0)
mean_log_odds = (np.log(p) - np.log1p(-p)).mean(axis=0)
# A sign split avoids exponentiating a large positive number.
magnitude = np.exp(-np.abs(mean_log_odds))
from_mean_logit = np.where(
mean_log_odds >= 0, 1 / (1 + magnitude), magnitude / (1 + magnitude)
)
return mean_probability, from_mean_logit
def main():
# Rows are models; columns are the same two sample IDs for every model.
p = np.array([[0.99, 0.8], [0.2, 0.8], [0.2, 0.8]])
arithmetic, logit_pool = pool_binary(p)
arithmetic_labels = (arithmetic > 0.5).astype(int)
logit_labels = (logit_pool > 0.5).astype(int)
assert arithmetic.shape == logit_pool.shape == (2,)
assert np.allclose(arithmetic, [1.39 / 3, 0.8])
assert arithmetic_labels.tolist() == [0, 1]
assert logit_labels.tolist() == [1, 1]
# Moderate fixture only: an independent odds-product identity check.
geometric_odds = np.prod(p / (1 - p), axis=0) ** (1 / p.shape[0])
assert np.allclose(logit_pool / (1 - logit_pool), geometric_odds)
assert np.isclose(arithmetic[1], logit_pool[1])
invalid = [[], [[0.0]], [[1.0]], [[float("nan")]], [0.2, 0.8]]
rejected = 0
for fixture in invalid:
try:
pool_binary(fixture)
except ValueError:
rejected += 1
assert rejected == len(invalid)
print("mean probabilities:", [round(float(v), 6) for v in arithmetic])
print("sigmoid of mean log-odds:", [round(float(v), 6) for v in logit_pool])
print("probability-pool labels:", arithmetic_labels.tolist())
print("logit-pool labels:", logit_labels.tolist())
print("pooled odds match geometric mean:", bool(np.allclose(logit_pool / (1 - logit_pool), geometric_odds)))
print("unanimous 0.8 predictions stay 0.8:", bool(np.isclose(logit_pool[1], 0.8)))
print("invalid fixtures rejected:", rejected)
if __name__ == "__main__":
main()
Recorded output
mean probabilities: [0.463333, 0.8] sigmoid of mean log-odds: [0.647373, 0.8] probability-pool labels: [0, 1] logit-pool labels: [1, 1] pooled odds match geometric mean: True unanimous 0.8 predictions stay 0.8: True invalid fixtures rejected: 5
References: [1]
Understand the odds identity before choosing a rule
Natural logarithms turn a product into a sum. Exponentiating the mean log-odds therefore gives the geometric mean of the individual odds. Converting those pooled odds back to a probability explains what the second rule computes. The example verifies that identity numerically; it does not claim the two averages generally commute. NumPy documents log as the natural logarithm, inverse to the exponential. [2]
The helper accepts a nonempty model-by-sample matrix of finite probabilities strictly between zero and one. Endpoints are rejected because their log-odds are not finite. Five invalid fixtures exercise these limits. A sign-based sigmoid avoids exponentiating a large positive value; the direct odds-product check is restricted to this moderate fixture.
Choose and record the combination rule as part of the ensemble contract. This counterexample establishes a semantic difference, not which method generalizes better. It assumes aligned samples, one shared binary class meaning and equal weights. Calibration, learned ensemble weights, correlated model errors and application-specific thresholds require separate evidence.
References: [2]