tostido's picture
Build MeshScale CPU worker template
96ef23c verified
Raw
History Blame Contribute Delete
12.6 kB
"""
Ensemble Voting Strategies
═══════════════════════════════════════════════════════════
Pure aggregation math - no models, no opinions.
STRATEGIES:
majority: Most common answer wins
confidence: Weighted by confidence scores
fitness_weighted: Weighted by fitness (for evolved ensembles)
unanimous: Only agree if all match
threshold: Agree if confidence exceeds threshold
USAGE:
>>> from cascade.ensemble import EnsembleVoting
>>>
>>> outputs = [
... {'answer': 'yes', 'score': 0.9},
... {'answer': 'yes', 'score': 0.7},
... {'answer': 'no', 'score': 0.8},
... ]
>>> confidences = [0.9, 0.7, 0.6]
>>>
>>> result = EnsembleVoting.confidence(outputs, confidences)
>>> # Result weighted towards 'yes' due to higher total confidence
"""
import numpy as np
from typing import Dict, Any, List, Optional, Union
from collections import Counter
class EnsembleVoting:
"""
Static voting strategies for ensemble aggregation.
These are pure functions - no state, no model dependencies.
Pass in outputs and weights, get aggregated result.
"""
@staticmethod
def majority(outputs: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Majority voting - most common output wins.
For discrete values: mode (most frequent)
For arrays: element-wise mean
Args:
outputs: List of output dictionaries from ensemble members
Returns:
Aggregated dictionary with majority values
"""
if not outputs:
return {}
result = {}
all_keys = set()
for out in outputs:
if out is not None:
all_keys.update(out.keys())
for key in all_keys:
values = [out.get(key) for out in outputs if out is not None and key in out]
if not values:
continue
first = values[0]
# Check if all values have the same type and shape
try:
if isinstance(first, np.ndarray):
# Verify all are arrays of same shape
if all(isinstance(v, np.ndarray) and v.shape == first.shape for v in values):
result[key] = np.mean(values, axis=0)
else:
# Heterogeneous shapes - just take first
result[key] = first
elif isinstance(first, list):
# Check if all lists have same length
if all(isinstance(v, list) and len(v) == len(first) for v in values):
result[key] = np.mean(values, axis=0).tolist()
else:
result[key] = first
elif isinstance(first, (int, float)):
# Mean for scalars (filter to only numeric)
numeric = [v for v in values if isinstance(v, (int, float))]
if numeric:
result[key] = np.mean(numeric)
else:
result[key] = first
else:
# Mode for discrete (strings, etc.)
hashable = [v if not isinstance(v, dict) else str(v) for v in values]
result[key] = Counter(hashable).most_common(1)[0][0]
except Exception:
# Any error - just take the first value
result[key] = first
return result
@staticmethod
def confidence(outputs: List[Dict[str, Any]],
confidences: List[float]) -> Dict[str, Any]:
"""
Confidence-weighted voting.
Higher confidence outputs have more weight in the final result.
Args:
outputs: List of output dictionaries
confidences: List of confidence scores (0-1)
Returns:
Confidence-weighted aggregated dictionary
"""
if not outputs or not confidences:
return {}
# Normalize confidences to sum to 1
conf_array = np.array(confidences, dtype=float)
total = conf_array.sum() + 1e-8
weights = conf_array / total
result = {}
all_keys = set()
for out in outputs:
if out is not None:
all_keys.update(out.keys())
for key in all_keys:
values = []
key_weights = []
for out, w in zip(outputs, weights):
if out is not None and key in out and out[key] is not None:
values.append(out[key])
key_weights.append(w)
if not values:
continue
first = values[0]
try:
if isinstance(first, np.ndarray):
# Verify all are arrays of same shape
if all(isinstance(v, np.ndarray) and v.shape == first.shape for v in values):
w_norm = np.array(key_weights) / (sum(key_weights) + 1e-8)
result[key] = np.average(values, weights=w_norm, axis=0)
else:
# Heterogeneous - use highest weighted
max_idx = np.argmax(key_weights)
result[key] = values[max_idx]
elif isinstance(first, list):
# Check if all lists have same length
if all(isinstance(v, list) and len(v) == len(first) for v in values):
w_norm = np.array(key_weights) / (sum(key_weights) + 1e-8)
result[key] = np.average(values, weights=w_norm, axis=0).tolist()
else:
max_idx = np.argmax(key_weights)
result[key] = values[max_idx]
elif isinstance(first, (int, float)):
# Weighted average for scalars (filter to numeric)
numeric_vals = []
numeric_weights = []
for v, w in zip(values, key_weights):
if isinstance(v, (int, float)):
numeric_vals.append(v)
numeric_weights.append(w)
if numeric_vals:
result[key] = np.average(numeric_vals, weights=numeric_weights)
else:
result[key] = first
else:
# Weighted mode for discrete
weighted = {}
for v, w in zip(values, key_weights):
v_key = v if not isinstance(v, dict) else str(v)
weighted[v_key] = weighted.get(v_key, 0) + w
result[key] = max(weighted, key=weighted.get)
except Exception:
# Fallback to highest weighted value
max_idx = np.argmax(key_weights) if key_weights else 0
result[key] = values[max_idx] if values else first
return result
@staticmethod
def fitness_weighted(outputs: List[Dict[str, Any]],
fitnesses: List[float]) -> Dict[str, Any]:
"""
Fitness-weighted voting (for evolved ensembles).
Higher fitness members have more influence.
Negative fitnesses are shifted to positive.
Args:
outputs: List of output dictionaries
fitnesses: List of fitness scores (can be negative)
Returns:
Fitness-weighted aggregated dictionary
"""
if not fitnesses:
return EnsembleVoting.majority(outputs)
# Shift to positive (handle negative fitness)
min_fit = min(fitnesses)
shifted = [f - min_fit + 1e-8 for f in fitnesses]
return EnsembleVoting.confidence(outputs, shifted)
@staticmethod
def unanimous(outputs: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""
Unanimous voting - only return result if all agree.
Returns None if there's any disagreement.
Args:
outputs: List of output dictionaries
Returns:
The unanimous output, or None if disagreement
"""
if not outputs:
return None
# Check if all outputs match
first = outputs[0]
for out in outputs[1:]:
if out != first:
return None
return first
@staticmethod
def threshold(outputs: List[Dict[str, Any]],
confidences: List[float],
threshold: float = 0.8) -> Optional[Dict[str, Any]]:
"""
Threshold voting - only use outputs above confidence threshold.
Filters to high-confidence outputs, then does majority vote.
Returns None if no outputs pass threshold.
Args:
outputs: List of output dictionaries
confidences: List of confidence scores (0-1)
threshold: Minimum confidence to include (default 0.8)
Returns:
Aggregated high-confidence outputs, or None
"""
if not outputs or not confidences:
return None
# Filter to above threshold
filtered_outputs = []
filtered_confidences = []
for out, conf in zip(outputs, confidences):
if conf >= threshold:
filtered_outputs.append(out)
filtered_confidences.append(conf)
if not filtered_outputs:
return None
return EnsembleVoting.confidence(filtered_outputs, filtered_confidences)
@staticmethod
def softmax_temperature(outputs: List[Dict[str, Any]],
confidences: List[float],
temperature: float = 1.0) -> Dict[str, Any]:
"""
Softmax temperature voting.
Applies temperature scaling to confidences before weighting:
- temperature < 1: Sharper (high confidence dominates)
- temperature = 1: Normal
- temperature > 1: Flatter (more equal weighting)
Args:
outputs: List of output dictionaries
confidences: List of confidence scores
temperature: Temperature parameter (default 1.0)
Returns:
Temperature-scaled weighted aggregation
"""
if not confidences or temperature <= 0:
return EnsembleVoting.majority(outputs)
# Apply softmax with temperature
conf_array = np.array(confidences, dtype=float)
scaled = conf_array / temperature
exp_scaled = np.exp(scaled - np.max(scaled)) # Numerically stable
softmax_weights = exp_scaled / (exp_scaled.sum() + 1e-8)
return EnsembleVoting.confidence(outputs, softmax_weights.tolist())
@staticmethod
def rank_based(outputs: List[Dict[str, Any]],
confidences: List[float]) -> Dict[str, Any]:
"""
Rank-based voting.
Weights by rank rather than raw confidence.
More robust to outlier confidence values.
Args:
outputs: List of output dictionaries
confidences: List of confidence scores
Returns:
Rank-weighted aggregation
"""
if not confidences:
return EnsembleVoting.majority(outputs)
# Convert to ranks (1 = highest confidence)
n = len(confidences)
sorted_indices = np.argsort(confidences)[::-1] # Descending
ranks = np.zeros(n)
for rank, idx in enumerate(sorted_indices):
ranks[idx] = n - rank # Higher rank = higher weight
return EnsembleVoting.confidence(outputs, ranks.tolist())