collablearn-int396 / src /group_eval.py
Cyril-36's picture
Deploy CollabLearn Streamlit demo via Docker
d81f51d verified
Raw
History Blame Contribute Delete
6.15 kB
"""Group-level evaluation metrics."""
from __future__ import annotations
import numpy as np
import pandas as pd
from scipy.spatial.distance import pdist
from .adapters.base import DatasetSchema
OUTCOME_SCORE = {
"Withdrawn": 0.0,
"Fail": 1.0,
"Pass": 2.0,
"Distinction": 3.0,
}
AT_RISK_OUTCOMES = {"Withdrawn", "Fail"}
def intra_group_distance(X_red: np.ndarray, groups: list[list[int]]) -> float:
distances = [pdist(X_red[group]).mean() for group in groups if len(group) >= 2]
return float(np.mean(distances)) if distances else 0.0
def inter_group_variance(X_red: np.ndarray, groups: list[list[int]]) -> float:
valid = [group for group in groups if group]
if not valid:
return 0.0
centroids = np.array([X_red[group].mean(axis=0) for group in valid])
return float(centroids.var(axis=0).sum())
def complementarity(labels: np.ndarray, groups: list[list[int]], G: int) -> float:
labels = np.asarray(labels)
values = []
for group in groups:
if not group:
continue
values.append(len(set(labels[group].tolist()) - {-1}) / max(1, min(G, len(group))))
return float(np.mean(values)) if values else 0.0
def engagement_balance(engage_col: np.ndarray, groups: list[list[int]]) -> float:
engage_col = np.asarray(engage_col, dtype=float)
sd = float(np.nanstd(engage_col))
if sd == 0 or np.isnan(sd):
return 0.0
mu = float(np.nanmean(engage_col))
devs = [abs(np.nanmean(engage_col[group]) - mu) / sd for group in groups if group]
return float(np.mean(devs)) if devs else 0.0
def demographic_fairness(attr_col: pd.Series | np.ndarray, groups: list[list[int]]) -> float:
attr = pd.Series(attr_col).reset_index(drop=True)
class_dist = attr.value_counts(normalize=True)
tvs = []
for group in groups:
if not group:
continue
group_dist = attr.iloc[group].value_counts(normalize=True)
keys = set(class_dist.index) | set(group_dist.index)
tv = 0.5 * sum(abs(class_dist.get(key, 0.0) - group_dist.get(key, 0.0)) for key in keys)
tvs.append(tv)
return float(np.mean(tvs)) if tvs else 0.0
def cluster_coverage(labels: np.ndarray, groups: list[list[int]]) -> float:
labels = np.asarray(labels)
n_clusters = len(set(labels.tolist()) - {-1})
if n_clusters == 0:
return 0.0
values = [len(set(labels[group].tolist()) - {-1}) / n_clusters for group in groups if group]
return float(np.mean(values)) if values else 0.0
def outcome_diversity(outcomes: pd.Series, groups: list[list[int]], G: int) -> float:
outcomes = outcomes.reset_index(drop=True).fillna("Unknown")
n_outcomes = max(1, outcomes.nunique())
denom = max(1, min(G, n_outcomes))
values = [outcomes.iloc[group].nunique() / denom for group in groups if group]
return float(np.mean(values)) if values else 0.0
def at_risk_concentration(outcomes: pd.Series, groups: list[list[int]]) -> float:
outcomes = outcomes.reset_index(drop=True).fillna("Unknown")
values = []
for group in groups:
if not group:
continue
group_outcomes = outcomes.iloc[group]
values.append(float(group_outcomes.isin(AT_RISK_OUTCOMES).mean()))
return float(np.mean(values)) if values else 0.0
def high_risk_group_rate(outcomes: pd.Series, groups: list[list[int]], threshold: float = 0.5) -> float:
outcomes = outcomes.reset_index(drop=True).fillna("Unknown")
rates = []
for group in groups:
if not group:
continue
rates.append(float(outcomes.iloc[group].isin(AT_RISK_OUTCOMES).mean() > threshold))
return float(np.mean(rates)) if rates else 0.0
def outcome_balance(outcomes: pd.Series, groups: list[list[int]]) -> float:
outcomes = outcomes.reset_index(drop=True)
if pd.api.types.is_numeric_dtype(outcomes):
scores = pd.to_numeric(outcomes, errors="coerce").astype(float)
else:
scores = outcomes.map(OUTCOME_SCORE).astype(float)
if scores.isna().all():
return 0.0
scores = scores.fillna(scores.median())
sd = float(scores.std(ddof=0))
if sd == 0 or np.isnan(sd):
return 0.0
mu = float(scores.mean())
devs = [abs(float(scores.iloc[group].mean()) - mu) / sd for group in groups if group]
return float(np.mean(devs)) if devs else 0.0
def evaluate_all(
X_red: np.ndarray,
labels: np.ndarray,
groups: list[list[int]],
feature_df: pd.DataFrame,
G: int,
schema: DatasetSchema | None = None,
attr: str | None = None,
) -> dict[str, float]:
metrics = {
"intra_group_distance": intra_group_distance(X_red, groups),
"inter_group_variance": inter_group_variance(X_red, groups),
"complementarity": complementarity(labels, groups, G),
"cluster_coverage": cluster_coverage(labels, groups),
}
engagement_col = schema.engagement_col if schema else None
if engagement_col and engagement_col in feature_df.columns:
metrics["engagement_balance"] = engagement_balance(feature_df[engagement_col].to_numpy(), groups)
fairness_cols = list(schema.fairness_cols) if schema else ([attr] if attr else [])
fairness_values = []
for fairness_col in fairness_cols:
if fairness_col in feature_df.columns:
value = demographic_fairness(feature_df[fairness_col], groups)
metrics[f"demographic_fairness_{fairness_col}"] = value
fairness_values.append(value)
if fairness_values:
metrics["demographic_fairness"] = float(np.mean(fairness_values))
outcome_col = schema.outcome_col if schema else None
if outcome_col and outcome_col in feature_df.columns:
outcomes = feature_df[outcome_col]
metrics.update(
{
"outcome_diversity": outcome_diversity(outcomes, groups, G),
"at_risk_concentration": at_risk_concentration(outcomes, groups),
"high_risk_group_rate": high_risk_group_rate(outcomes, groups),
"outcome_balance": outcome_balance(outcomes, groups),
}
)
return metrics