whassan's picture
Deploy Sensorflow Studio
fb23187 verified
Raw History Blame Contribute Delete
16.5 kB
"""Grader disagreement subsystem.
Grader sources (spec §11): auto label, vendor GT, historical model, VLM label,
human reviewer (when present), independent detector. Computes classification,
spatial, temporal and statistical agreement, and a canonical
grader_consensus_score while preserving the components.
Statistical agreement — used correctly, not interchangeably:
- Cohen's Kappa: exactly 2 graders, categorical labels.
- Fleiss' Kappa: >2 graders, every grader rates every (fixed) item.
- Krippendorff's Alpha: any number of graders, tolerates missing ratings.
"""
from __future__ import annotations
import math
from collections import Counter
from typing import Dict, List, Optional, Sequence, Tuple
import numpy as np
from sensorflow.evaluation.records import (
Annotation,
EvalStore,
Frame,
GraderComparison,
)
from sensorflow.metrics.perception_3d import bev_iou
GRADER_SOURCES = ["auto_label", "vendor_gt", "historical_model", "vlm_label", "independent_detector"]
CLASS_LIST = ["pedestrian", "cyclist", "vehicle", "motorcycle", "truck"]
# ------------------------------------------------------------------ statistics
def cohens_kappa(a: Sequence[str], b: Sequence[str]) -> float:
"""Cohen's kappa for exactly two graders over the same items."""
assert len(a) == len(b) and len(a) > 0
cats = sorted(set(a) | set(b))
n = len(a)
po = sum(1 for x, y in zip(a, b) if x == y) / n
pa = Counter(a)
pb = Counter(b)
pe = sum((pa[c] / n) * (pb[c] / n) for c in cats)
if math.isclose(pe, 1.0):
return 1.0
return (po - pe) / (1 - pe)
def fleiss_kappa(ratings: List[Dict[str, int]]) -> float:
"""Fleiss' kappa: ratings[i] = {category: count} with a fixed rater count
per item (every grader rates every item)."""
if not ratings:
return 0.0
n_raters = sum(ratings[0].values())
cats = sorted({c for r in ratings for c in r})
N = len(ratings)
p_j = {c: sum(r.get(c, 0) for r in ratings) / (N * n_raters) for c in cats}
P_i = []
for r in ratings:
s = sum(cnt * (cnt - 1) for cnt in r.values())
P_i.append(s / (n_raters * (n_raters - 1)) if n_raters > 1 else 1.0)
P_bar = float(np.mean(P_i))
P_e = sum(p ** 2 for p in p_j.values())
if math.isclose(P_e, 1.0):
return 1.0
return (P_bar - P_e) / (1 - P_e)
def krippendorff_alpha(data: List[List[Optional[str]]]) -> float:
"""Krippendorff's alpha (nominal) tolerating missing ratings.
data[rater][item] = category or None.
"""
# Collect pairable values per item (unit).
units: List[List[str]] = []
n_items = max((len(r) for r in data), default=0)
for i in range(n_items):
vals = [r[i] for r in data if i < len(r) and r[i] is not None]
if len(vals) >= 2:
units.append(vals)
if not units:
return 0.0
# Observed disagreement.
Do_num, Do_den = 0.0, 0.0
all_vals: List[str] = []
for vals in units:
m = len(vals)
all_vals.extend(vals)
cnt = Counter(vals)
pairs_disagree = 0
for c1 in cnt:
for c2 in cnt:
if c1 != c2:
pairs_disagree += cnt[c1] * cnt[c2]
Do_num += pairs_disagree / (m - 1)
Do_den += m
Do = Do_num / Do_den if Do_den else 0.0
# Expected disagreement.
total = len(all_vals)
cnt_all = Counter(all_vals)
De_num = 0.0
for c1 in cnt_all:
for c2 in cnt_all:
if c1 != c2:
De_num += cnt_all[c1] * cnt_all[c2]
De = De_num / (total * (total - 1)) if total > 1 else 0.0
if math.isclose(De, 0.0):
return 1.0
return 1 - Do / De
# ------------------------------------------------------------------ simulated graders
def _simulate_grader_votes(ann: Annotation, gt_class: Optional[str], rng: np.random.Generator) -> Dict[str, str]:
"""Deterministic per-annotation grader simulation.
Each grader source produces a class vote. Injected GRADER_DISAGREEMENT
forces the VLM + independent detector to disagree.
"""
base = gt_class or ann.class_name
votes: Dict[str, str] = {"auto_label": ann.class_name}
votes["vendor_gt"] = gt_class if gt_class else ann.class_name
def maybe_flip(cls: str, p: float) -> str:
if rng.random() < p:
alts = [c for c in CLASS_LIST if c != cls]
return alts[int(rng.integers(0, len(alts)))]
return cls
disagree = "GRADER_DISAGREEMENT" in ann.injected_errors
votes["historical_model"] = maybe_flip(base, 0.02)
votes["vlm_label"] = maybe_flip(base, 1.0 if disagree else 0.04)
votes["independent_detector"] = maybe_flip(base, 1.0 if disagree else 0.03)
return votes
def _spatial_agreement(ann: Annotation, gt_box: Optional[List[float]], rng: np.random.Generator) -> Tuple[float, Dict[str, float]]:
"""Spatial agreement between graders' boxes (auto vs reference vs an
independent perturbation): IoU, center distance, dims, orientation."""
if not ann.bbox_3d:
return 0.0, {}
ref = gt_box if gt_box else ann.bbox_3d
iou = bev_iou(ann.bbox_3d, ref)
center_dist = math.hypot(ann.bbox_3d[0] - ref[0], ann.bbox_3d[1] - ref[1])
dim_diff = float(np.mean([abs(ann.bbox_3d[i] - ref[i]) / max(ref[i], 0.1) for i in (3, 4, 5)]))
yaw_diff = abs(ann.bbox_3d[6] - ref[6]) % (2 * math.pi)
yaw_diff = min(yaw_diff, 2 * math.pi - yaw_diff)
detail = {
"iou": round(iou, 4),
"center_distance": round(center_dist, 3),
"dimension_diff": round(dim_diff, 4),
"orientation_diff_deg": round(math.degrees(yaw_diff), 2),
}
score = float(np.clip(
0.5 * iou + 0.25 * max(0, 1 - center_dist / 2.0) + 0.15 * max(0, 1 - dim_diff) + 0.10 * max(0, 1 - yaw_diff / math.pi),
0, 1))
return score, detail
def grade_annotation(
store: EvalStore,
ann: Annotation,
frame: Frame,
temporal_quality: Optional[float] = None,
seed: int = 7,
) -> GraderComparison:
rng = np.random.default_rng(abs(hash((ann.annotation_id, seed))) % (2 ** 32))
gt = next((g for g in frame.gt_boxes if g.gt_id == ann.matched_gt_id), None)
votes = _simulate_grader_votes(ann, gt.class_name if gt else None, rng)
classes = list(votes.values())
# Reliability-weighted class agreement: authoritative sources count more.
weights = {"auto_label": 1.0, "vendor_gt": 2.0, "historical_model": 0.75,
"vlm_label": 0.5, "independent_detector": 0.75}
weight_by_class: Dict[str, float] = {}
for source, cls in votes.items():
weight_by_class[cls] = weight_by_class.get(cls, 0.0) + weights.get(source, 1.0)
total_weight = sum(weights.get(s, 1.0) for s in votes)
class_agreement = max(weight_by_class.values()) / total_weight
spatial, spatial_detail = _spatial_agreement(ann, gt.bbox_3d if gt else None, rng)
temporal = temporal_quality if temporal_quality is not None else (1.0 if ann.track_id else 0.5)
# Per-item agreement indicators; proper kappa/alpha statistics are computed
# at dataset level in dataset_grader_statistics (kappa is undefined on a
# single item).
kappa_stats: Dict[str, float] = {
"auto_vs_vendor_agree": 1.0 if votes["auto_label"] == votes["vendor_gt"] else 0.0,
"vote_agreement": round(class_agreement, 4),
}
disagreements: List[str] = []
if class_agreement < 1.0:
disagreements.append("classification")
if spatial < 0.6:
disagreements.append("spatial")
if temporal < 0.6:
disagreements.append("temporal")
consensus = float(np.clip(0.5 * class_agreement + 0.35 * spatial + 0.15 * temporal, 0, 1))
# --- additive consensus evidence (canonical consensus above is unchanged)
csv = consensus_score_vector(votes, spatial_detail, float(temporal), weights)
candidates = _grader_box_candidates(ann, gt, votes, rng)
mbr = mbr_consensus_select(candidates)
extra_stats: Dict[str, float] = {f"csv_{k}": v for k, v in csv.items()}
for c in mbr["candidates"]:
extra_stats[f"mbr_risk_{c['source']}"] = c["expected_risk"]
extra_stats["mbr_selected_idx"] = float(mbr["selected_index"])
extra_stats["mbr_selected_risk"] = mbr["selected"]["expected_risk"]
cmp = GraderComparison(
annotation_id=ann.annotation_id,
grader_count=len(votes),
graders=list(votes.keys()),
class_votes=votes,
class_agreement=round(class_agreement, 4),
spatial_agreement=round(spatial, 4),
temporal_agreement=round(float(temporal), 4),
consensus=round(consensus, 4),
disagreement_types=disagreements,
kappa_stats={**kappa_stats,
**{f"spatial_{k}": v for k, v in spatial_detail.items()},
**extra_stats},
)
store.put("grader_comparisons", cmp)
return cmp
def dataset_grader_statistics(store: EvalStore, dataset_id: str) -> Dict[str, float]:
"""Dataset-level statistical agreement across all graded items."""
anns = store.where("annotations", dataset_id=dataset_id)
comparisons = [store.get("grader_comparisons", a.annotation_id) for a in anns]
comparisons = [c for c in comparisons if c is not None]
if not comparisons:
return {}
# Two-grader Cohen's kappa: auto label vs vendor GT over all items.
auto = [c.class_votes.get("auto_label", "") for c in comparisons]
vendor = [c.class_votes.get("vendor_gt", "") for c in comparisons]
cohen = cohens_kappa(auto, vendor)
# Fleiss' kappa: all graders rate all items (fixed panel).
ratings = [dict(Counter(c.class_votes.values())) for c in comparisons]
fleiss = fleiss_kappa(ratings)
# Krippendorff's alpha: tolerate a missing vendor rating (simulated missing
# data on unmatched labels).
raters: List[List[Optional[str]]] = [
[c.class_votes.get(source) for c in comparisons] for source in GRADER_SOURCES
]
# Mark vendor rating missing where the label had no matched GT.
ann_by_id = {a.annotation_id: a for a in anns}
for j, c in enumerate(comparisons):
a = ann_by_id.get(c.annotation_id)
if a is not None and a.matched_gt_id is None:
raters[GRADER_SOURCES.index("vendor_gt")][j] = None
kripp = krippendorff_alpha(raters)
stats = {
"cohen_kappa": round(cohen, 4),
"fleiss_kappa": round(fleiss, 4),
"krippendorff_alpha": round(kripp, 4),
"mean_consensus": round(float(np.mean([c.consensus for c in comparisons if c.consensus is not None])), 4),
}
# Additive: Kendall's tau between the model-confidence ranking of items and
# the grader-consensus ranking (do graders order item quality like the
# model's own confidence does?). Needs >= 2 items.
paired = [(ann_by_id[c.annotation_id].confidence, c.consensus or 0.0)
for c in comparisons if c.annotation_id in ann_by_id]
conf = [p[0] for p in paired]
cons = [p[1] for p in paired]
if len(conf) >= 2:
tau, p = kendalls_tau(conf, cons)
stats["kendall_tau_confidence_vs_consensus"] = tau
stats["kendall_tau_p_value"] = p
return stats
# ------------------------------------------------------------------ additive consensus extensions
#
# The canonical scalar consensus above stays authoritative. The functions
# below add richer evidence (spec: consensus score vector, Kendall's tau
# ranking agreement, Minimum Bayes Risk selection) surfaced through
# GraderComparison.kappa_stats with csv_* / mbr_* prefixes.
def consensus_score_vector(votes: Dict[str, str], spatial_detail: Dict[str, float],
temporal: float,
weights: Optional[Dict[str, float]] = None) -> Dict[str, float]:
"""Per-element consensus scores instead of one scalar.
Elements: majority class share, reliability-weighted class agreement,
spatial sub-scores (IoU, center, dimensions, orientation) and temporal
consistency — each individually in [0, 1].
"""
n = max(len(votes), 1)
counts = Counter(votes.values())
majority_share = max(counts.values()) / n if counts else 0.0
weights = weights or {}
weight_by_class: Dict[str, float] = {}
for source, cls in votes.items():
weight_by_class[cls] = weight_by_class.get(cls, 0.0) + weights.get(source, 1.0)
total_w = sum(weights.get(s, 1.0) for s in votes) or 1.0
weighted = max(weight_by_class.values()) / total_w if weight_by_class else 0.0
center = spatial_detail.get("center_distance")
dims = spatial_detail.get("dimension_diff")
orient = spatial_detail.get("orientation_diff_deg")
return {
"class_majority_share": round(majority_share, 4),
"class_weighted_agreement": round(weighted, 4),
"spatial_iou": round(float(spatial_detail.get("iou", 0.0)), 4),
"spatial_center": round(max(0.0, 1 - (center or 2.0) / 2.0), 4),
"spatial_dims": round(max(0.0, 1 - (dims if dims is not None else 1.0)), 4),
"spatial_orientation": round(max(0.0, 1 - (orient if orient is not None else 180.0) / 180.0), 4),
"temporal": round(float(temporal), 4),
}
def kendalls_tau(scores_a: Sequence[float], scores_b: Sequence[float]) -> Tuple[float, float]:
"""Kendall's tau-b ranking agreement between two score lists over the same
items (scipy.stats.kendalltau). Returns (tau, p_value)."""
from scipy.stats import kendalltau
tau, p = kendalltau(list(scores_a), list(scores_b))
if math.isnan(tau):
return 0.0, 1.0
return round(float(tau), 4), round(float(p), 6)
def mbr_utility(cand_a: Dict, cand_b: Dict, iou_weight: float = 0.5) -> float:
"""Pairwise utility for MBR: class match + BEV IoU (each in [0, 1])."""
class_match = 1.0 if cand_a.get("class_name") == cand_b.get("class_name") else 0.0
box_a, box_b = cand_a.get("bbox_3d"), cand_b.get("bbox_3d")
iou = bev_iou(box_a, box_b) if box_a and box_b else 0.0
return (1 - iou_weight) * class_match + iou_weight * iou
def mbr_consensus_select(candidates: List[Dict], iou_weight: float = 0.5) -> Dict:
"""Minimum Bayes Risk consensus selection.
Among candidate labels (each {source, class_name, bbox_3d}), select the one
minimizing expected disagreement risk against the others treated as
pseudo-references: risk(c) = 1 - mean_utility(c, others).
"""
if not candidates:
raise ValueError("mbr_consensus_select requires at least one candidate")
scored = []
for i, cand in enumerate(candidates):
others = [c for j, c in enumerate(candidates) if j != i]
util = (float(np.mean([mbr_utility(cand, o, iou_weight) for o in others]))
if others else 1.0)
scored.append({"source": cand.get("source", f"candidate_{i}"),
"class_name": cand.get("class_name"),
"expected_utility": round(util, 4),
"expected_risk": round(1 - util, 4)})
best = min(range(len(scored)),
key=lambda i: (scored[i]["expected_risk"], i))
return {"selected_index": best, "selected": scored[best], "candidates": scored,
"utility": f"{1 - iou_weight:.2f}*class_match + {iou_weight:.2f}*bev_iou"}
def _grader_box_candidates(ann: Annotation, gt, votes: Dict[str, str],
rng: np.random.Generator) -> List[Dict]:
"""Candidate labels for MBR selection. auto_label and vendor_gt use their
real boxes; the remaining graders' boxes are SIMULATED as deterministic
small perturbations of their vote's base box (consistent with the simulated
grader panel above)."""
base_box = list(gt.bbox_3d) if gt is not None else (list(ann.bbox_3d) if ann.bbox_3d else None)
out: List[Dict] = []
for source in GRADER_SOURCES:
if source not in votes:
continue
if source == "auto_label":
box = list(ann.bbox_3d) if ann.bbox_3d else None
elif source == "vendor_gt":
box = list(gt.bbox_3d) if gt is not None else (list(ann.bbox_3d) if ann.bbox_3d else None)
elif base_box is not None:
jitter = rng.normal(0.0, 0.12, size=3)
box = list(base_box)
box[0] += float(jitter[0])
box[1] += float(jitter[1])
box[6] += float(jitter[2] * 0.2)
else:
box = None
out.append({"source": source, "class_name": votes[source], "bbox_3d": box})
return out