feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Evaluation metrics for Intelligence Pipeline v3."""
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Entity and ticker precision, recall, F1, and ambiguity accuracy metrics.
|
||||
|
||||
Implements evaluation metrics for entity extraction quality against a gold
|
||||
standard corpus. Supports both strict matching (exact span) and relaxed
|
||||
matching (overlapping span with same type), with per-type breakdowns.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MatchMode(str, Enum):
|
||||
"""Entity matching strategy."""
|
||||
|
||||
strict = "strict"
|
||||
relaxed = "relaxed"
|
||||
|
||||
|
||||
class EntitySpan(BaseModel):
|
||||
"""A single entity mention with character offsets and type."""
|
||||
|
||||
text: str
|
||||
entity_type: str
|
||||
start_char: int
|
||||
end_char: int
|
||||
document_id: str = ""
|
||||
canonical_id: str | None = None
|
||||
is_ambiguous: bool = False
|
||||
|
||||
@property
|
||||
def span(self) -> tuple[int, int]:
|
||||
return (self.start_char, self.end_char)
|
||||
|
||||
|
||||
class TickerMention(BaseModel):
|
||||
"""A resolved ticker/company mention."""
|
||||
|
||||
text: str
|
||||
ticker: str
|
||||
start_char: int
|
||||
end_char: int
|
||||
document_id: str = ""
|
||||
canonical_company_id: str | None = None
|
||||
is_ambiguous: bool = False
|
||||
|
||||
@property
|
||||
def span(self) -> tuple[int, int]:
|
||||
return (self.start_char, self.end_char)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PRF1(BaseModel):
|
||||
"""Precision, recall, F1 triple."""
|
||||
|
||||
precision: float = Field(ge=0.0, le=1.0)
|
||||
recall: float = Field(ge=0.0, le=1.0)
|
||||
f1: float = Field(ge=0.0, le=1.0)
|
||||
support_predicted: int = Field(ge=0)
|
||||
support_gold: int = Field(ge=0)
|
||||
|
||||
|
||||
class EntityMetricsResult(BaseModel):
|
||||
"""Full entity evaluation result with per-type breakdowns."""
|
||||
|
||||
match_mode: Literal["strict", "relaxed"]
|
||||
overall: PRF1
|
||||
per_type: dict[str, PRF1]
|
||||
|
||||
|
||||
class TickerMetricsResult(BaseModel):
|
||||
"""Ticker/company resolution evaluation result."""
|
||||
|
||||
match_mode: Literal["strict", "relaxed"]
|
||||
overall: PRF1
|
||||
per_type: dict[str, PRF1] = Field(
|
||||
default_factory=dict,
|
||||
description="Breakdown by canonical company or sector if available",
|
||||
)
|
||||
|
||||
|
||||
class AmbiguityResult(BaseModel):
|
||||
"""Ambiguity detection accuracy."""
|
||||
|
||||
accuracy: float = Field(ge=0.0, le=1.0)
|
||||
true_positives: int = Field(ge=0)
|
||||
true_negatives: int = Field(ge=0)
|
||||
false_positives: int = Field(ge=0)
|
||||
false_negatives: int = Field(ge=0)
|
||||
support: int = Field(ge=0)
|
||||
|
||||
|
||||
class EntityEvaluationReport(BaseModel):
|
||||
"""Complete entity evaluation report."""
|
||||
|
||||
entity_metrics: EntityMetricsResult
|
||||
ticker_metrics: TickerMetricsResult
|
||||
ambiguity_accuracy: AmbiguityResult
|
||||
document_count: int = Field(ge=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matching Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _spans_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
|
||||
"""Return True if two character spans overlap."""
|
||||
return a[0] < b[1] and b[0] < a[1]
|
||||
|
||||
|
||||
def _entity_matches_strict(pred: EntitySpan, gold: EntitySpan) -> bool:
|
||||
"""Strict match: exact span boundaries and same entity type."""
|
||||
return (
|
||||
pred.entity_type == gold.entity_type
|
||||
and pred.start_char == gold.start_char
|
||||
and pred.end_char == gold.end_char
|
||||
)
|
||||
|
||||
|
||||
def _entity_matches_relaxed(pred: EntitySpan, gold: EntitySpan) -> bool:
|
||||
"""Relaxed match: overlapping span with same entity type."""
|
||||
return pred.entity_type == gold.entity_type and _spans_overlap(
|
||||
pred.span, gold.span
|
||||
)
|
||||
|
||||
|
||||
def _ticker_matches_strict(pred: TickerMention, gold: TickerMention) -> bool:
|
||||
"""Strict match: exact span and same resolved ticker."""
|
||||
return (
|
||||
pred.ticker == gold.ticker
|
||||
and pred.start_char == gold.start_char
|
||||
and pred.end_char == gold.end_char
|
||||
)
|
||||
|
||||
|
||||
def _ticker_matches_relaxed(pred: TickerMention, gold: TickerMention) -> bool:
|
||||
"""Relaxed match: overlapping span with same resolved ticker."""
|
||||
return pred.ticker == gold.ticker and _spans_overlap(pred.span, gold.span)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core Metric Computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_prf1(
|
||||
predicted: list[EntitySpan] | list[TickerMention],
|
||||
gold: list[EntitySpan] | list[TickerMention],
|
||||
match_fn: object,
|
||||
) -> PRF1:
|
||||
"""Compute precision, recall, F1 using greedy bipartite matching.
|
||||
|
||||
Each predicted item can match at most one gold item and vice versa.
|
||||
"""
|
||||
n_pred = len(predicted)
|
||||
n_gold = len(gold)
|
||||
|
||||
if n_pred == 0 and n_gold == 0:
|
||||
return PRF1(
|
||||
precision=1.0,
|
||||
recall=1.0,
|
||||
f1=1.0,
|
||||
support_predicted=0,
|
||||
support_gold=0,
|
||||
)
|
||||
|
||||
if n_pred == 0:
|
||||
return PRF1(
|
||||
precision=1.0,
|
||||
recall=0.0,
|
||||
f1=0.0,
|
||||
support_predicted=0,
|
||||
support_gold=n_gold,
|
||||
)
|
||||
|
||||
if n_gold == 0:
|
||||
return PRF1(
|
||||
precision=0.0,
|
||||
recall=1.0,
|
||||
f1=0.0,
|
||||
support_predicted=n_pred,
|
||||
support_gold=0,
|
||||
)
|
||||
|
||||
# Greedy matching: for each predicted, find first unmatched gold
|
||||
matched_gold: set[int] = set()
|
||||
true_positives = 0
|
||||
|
||||
for p in predicted:
|
||||
for g_idx, g in enumerate(gold):
|
||||
if g_idx in matched_gold:
|
||||
continue
|
||||
if match_fn(p, g): # type: ignore[operator]
|
||||
true_positives += 1
|
||||
matched_gold.add(g_idx)
|
||||
break
|
||||
|
||||
precision = true_positives / n_pred if n_pred > 0 else 0.0
|
||||
recall = true_positives / n_gold if n_gold > 0 else 0.0
|
||||
|
||||
if precision + recall > 0:
|
||||
f1 = 2 * precision * recall / (precision + recall)
|
||||
else:
|
||||
f1 = 0.0
|
||||
|
||||
return PRF1(
|
||||
precision=precision,
|
||||
recall=recall,
|
||||
f1=f1,
|
||||
support_predicted=n_pred,
|
||||
support_gold=n_gold,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_entity_metrics(
|
||||
predicted: list[EntitySpan],
|
||||
gold: list[EntitySpan],
|
||||
mode: MatchMode = MatchMode.strict,
|
||||
) -> EntityMetricsResult:
|
||||
"""Compute entity precision, recall, F1 with per-type breakdowns.
|
||||
|
||||
Args:
|
||||
predicted: Predicted entity spans.
|
||||
gold: Gold standard entity spans.
|
||||
mode: Matching strategy (strict or relaxed).
|
||||
|
||||
Returns:
|
||||
EntityMetricsResult with overall and per-type PRF1.
|
||||
"""
|
||||
match_fn = _entity_matches_strict if mode == MatchMode.strict else _entity_matches_relaxed
|
||||
|
||||
# Overall
|
||||
overall = _compute_prf1(predicted, gold, match_fn)
|
||||
|
||||
# Per-type breakdown
|
||||
all_types = {e.entity_type for e in predicted} | {e.entity_type for e in gold}
|
||||
per_type: dict[str, PRF1] = {}
|
||||
|
||||
for entity_type in sorted(all_types):
|
||||
type_predicted = [e for e in predicted if e.entity_type == entity_type]
|
||||
type_gold = [e for e in gold if e.entity_type == entity_type]
|
||||
per_type[entity_type] = _compute_prf1(type_predicted, type_gold, match_fn)
|
||||
|
||||
return EntityMetricsResult(
|
||||
match_mode=mode.value,
|
||||
overall=overall,
|
||||
per_type=per_type,
|
||||
)
|
||||
|
||||
|
||||
def compute_ticker_metrics(
|
||||
predicted: list[TickerMention],
|
||||
gold: list[TickerMention],
|
||||
mode: MatchMode = MatchMode.strict,
|
||||
) -> TickerMetricsResult:
|
||||
"""Compute ticker/company resolution precision, recall, F1.
|
||||
|
||||
Args:
|
||||
predicted: Predicted ticker mentions with resolved tickers.
|
||||
gold: Gold standard ticker mentions.
|
||||
mode: Matching strategy (strict or relaxed).
|
||||
|
||||
Returns:
|
||||
TickerMetricsResult with overall and optional per-ticker PRF1.
|
||||
"""
|
||||
match_fn = _ticker_matches_strict if mode == MatchMode.strict else _ticker_matches_relaxed
|
||||
|
||||
overall = _compute_prf1(predicted, gold, match_fn)
|
||||
|
||||
# Per-ticker breakdown
|
||||
all_tickers = {t.ticker for t in predicted} | {t.ticker for t in gold}
|
||||
per_type: dict[str, PRF1] = {}
|
||||
|
||||
for ticker in sorted(all_tickers):
|
||||
ticker_predicted = [t for t in predicted if t.ticker == ticker]
|
||||
ticker_gold = [t for t in gold if t.ticker == ticker]
|
||||
per_type[ticker] = _compute_prf1(ticker_predicted, ticker_gold, match_fn)
|
||||
|
||||
return TickerMetricsResult(
|
||||
match_mode=mode.value,
|
||||
overall=overall,
|
||||
per_type=per_type,
|
||||
)
|
||||
|
||||
|
||||
def compute_ambiguity_accuracy(
|
||||
predicted: list[EntitySpan] | list[TickerMention],
|
||||
gold: list[EntitySpan] | list[TickerMention],
|
||||
) -> AmbiguityResult:
|
||||
"""Compute ambiguity detection accuracy.
|
||||
|
||||
Measures how well the system identifies entities that require
|
||||
adjudication (ambiguous entities). Uses the `is_ambiguous` flag
|
||||
on each span/mention.
|
||||
|
||||
Entities are aligned by position (exact start_char, end_char match)
|
||||
to compare ambiguity labels.
|
||||
|
||||
Args:
|
||||
predicted: Predicted entities/tickers with ambiguity flags.
|
||||
gold: Gold standard entities/tickers with ambiguity flags.
|
||||
|
||||
Returns:
|
||||
AmbiguityResult with accuracy and confusion counts.
|
||||
"""
|
||||
# Build a lookup from gold spans to ambiguity flag
|
||||
gold_lookup: dict[tuple[int, int], bool] = {}
|
||||
for g in gold:
|
||||
gold_lookup[(g.start_char, g.end_char)] = g.is_ambiguous
|
||||
|
||||
tp = 0 # predicted ambiguous, gold ambiguous
|
||||
tn = 0 # predicted not ambiguous, gold not ambiguous
|
||||
fp = 0 # predicted ambiguous, gold not ambiguous
|
||||
fn = 0 # predicted not ambiguous, gold ambiguous
|
||||
|
||||
matched_count = 0
|
||||
|
||||
for p in predicted:
|
||||
key = (p.start_char, p.end_char)
|
||||
if key in gold_lookup:
|
||||
matched_count += 1
|
||||
gold_ambiguous = gold_lookup[key]
|
||||
pred_ambiguous = p.is_ambiguous
|
||||
|
||||
if pred_ambiguous and gold_ambiguous:
|
||||
tp += 1
|
||||
elif not pred_ambiguous and not gold_ambiguous:
|
||||
tn += 1
|
||||
elif pred_ambiguous and not gold_ambiguous:
|
||||
fp += 1
|
||||
else:
|
||||
fn += 1
|
||||
|
||||
support = tp + tn + fp + fn
|
||||
accuracy = (tp + tn) / support if support > 0 else 1.0
|
||||
|
||||
return AmbiguityResult(
|
||||
accuracy=accuracy,
|
||||
true_positives=tp,
|
||||
true_negatives=tn,
|
||||
false_positives=fp,
|
||||
false_negatives=fn,
|
||||
support=support,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_entities(
|
||||
predicted_entities: list[EntitySpan],
|
||||
gold_entities: list[EntitySpan],
|
||||
predicted_tickers: list[TickerMention],
|
||||
gold_tickers: list[TickerMention],
|
||||
mode: MatchMode = MatchMode.strict,
|
||||
document_count: int = 1,
|
||||
) -> EntityEvaluationReport:
|
||||
"""Run full entity evaluation producing a complete report.
|
||||
|
||||
Args:
|
||||
predicted_entities: All predicted entity spans.
|
||||
gold_entities: All gold standard entity spans.
|
||||
predicted_tickers: All predicted ticker mentions.
|
||||
gold_tickers: All gold standard ticker mentions.
|
||||
mode: Matching strategy.
|
||||
document_count: Number of documents evaluated.
|
||||
|
||||
Returns:
|
||||
EntityEvaluationReport with entity metrics, ticker metrics,
|
||||
and ambiguity accuracy.
|
||||
"""
|
||||
entity_metrics = compute_entity_metrics(predicted_entities, gold_entities, mode)
|
||||
ticker_metrics = compute_ticker_metrics(predicted_tickers, gold_tickers, mode)
|
||||
ambiguity_accuracy = compute_ambiguity_accuracy(predicted_entities, gold_entities)
|
||||
|
||||
return EntityEvaluationReport(
|
||||
entity_metrics=entity_metrics,
|
||||
ticker_metrics=ticker_metrics,
|
||||
ambiguity_accuracy=ambiguity_accuracy,
|
||||
document_count=document_count,
|
||||
)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Event and relation macro/micro F1 evaluation metrics.
|
||||
|
||||
Implements evaluation metrics for event classification and relation extraction
|
||||
quality against a gold standard corpus. Supports both macro-F1 (average across
|
||||
classes) and micro-F1 (global TP/FP/FN) with per-class breakdowns.
|
||||
|
||||
Matching logic:
|
||||
- Events match if they share the same event_class AND have overlapping evidence
|
||||
spans OR the same primary company.
|
||||
- Relations match if they share the same relation_type, source_id, and target_id.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.intelligence_pipeline_v3.schemas.annotations import (
|
||||
EventClass,
|
||||
RelationType,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PredictedEvent(BaseModel):
|
||||
"""A predicted event for evaluation."""
|
||||
|
||||
event_class: EventClass
|
||||
evidence_ids: list[str] = Field(default_factory=list)
|
||||
primary_company_ids: list[str] = Field(default_factory=list)
|
||||
confidence: float = Field(ge=0.0, le=1.0, default=1.0)
|
||||
|
||||
|
||||
class GoldEvent(BaseModel):
|
||||
"""A gold standard event for evaluation."""
|
||||
|
||||
event_class: EventClass
|
||||
evidence_ids: list[str] = Field(default_factory=list)
|
||||
primary_company_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PredictedRelation(BaseModel):
|
||||
"""A predicted relation for evaluation."""
|
||||
|
||||
relation_type: RelationType
|
||||
source_id: str
|
||||
target_id: str
|
||||
confidence: float = Field(ge=0.0, le=1.0, default=1.0)
|
||||
|
||||
|
||||
class GoldRelation(BaseModel):
|
||||
"""A gold standard relation for evaluation."""
|
||||
|
||||
relation_type: RelationType
|
||||
source_id: str
|
||||
target_id: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PRF1(BaseModel):
|
||||
"""Precision, recall, F1 triple."""
|
||||
|
||||
precision: float = Field(ge=0.0, le=1.0)
|
||||
recall: float = Field(ge=0.0, le=1.0)
|
||||
f1: float = Field(ge=0.0, le=1.0)
|
||||
support_predicted: int = Field(ge=0)
|
||||
support_gold: int = Field(ge=0)
|
||||
|
||||
|
||||
class EventMetricsResult(BaseModel):
|
||||
"""Full event evaluation result with macro/micro F1 and per-class breakdown."""
|
||||
|
||||
macro_f1: float = Field(ge=0.0, le=1.0)
|
||||
micro: PRF1
|
||||
per_class: dict[str, PRF1]
|
||||
|
||||
|
||||
class RelationMetricsResult(BaseModel):
|
||||
"""Full relation evaluation result with macro/micro F1 and per-type breakdown."""
|
||||
|
||||
macro_f1: float = Field(ge=0.0, le=1.0)
|
||||
micro: PRF1
|
||||
per_type: dict[str, PRF1]
|
||||
|
||||
|
||||
class EventRelationEvaluationReport(BaseModel):
|
||||
"""Complete event and relation evaluation report."""
|
||||
|
||||
event_metrics: EventMetricsResult
|
||||
relation_metrics: RelationMetricsResult
|
||||
document_count: int = Field(ge=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matching Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _events_match(pred: PredictedEvent, gold: GoldEvent) -> bool:
|
||||
"""Events match if same event_class AND overlapping evidence OR same primary company.
|
||||
|
||||
Overlap means at least one evidence_id in common, OR at least one
|
||||
primary_company_id in common.
|
||||
"""
|
||||
if pred.event_class != gold.event_class:
|
||||
return False
|
||||
|
||||
# Check overlapping evidence spans
|
||||
if pred.evidence_ids and gold.evidence_ids:
|
||||
if set(pred.evidence_ids) & set(gold.evidence_ids):
|
||||
return True
|
||||
|
||||
# Check same primary company
|
||||
if pred.primary_company_ids and gold.primary_company_ids:
|
||||
if set(pred.primary_company_ids) & set(gold.primary_company_ids):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _relations_match(pred: PredictedRelation, gold: GoldRelation) -> bool:
|
||||
"""Relations match if same type, source, and target."""
|
||||
return (
|
||||
pred.relation_type == gold.relation_type
|
||||
and pred.source_id == gold.source_id
|
||||
and pred.target_id == gold.target_id
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core Metric Computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_prf1_greedy(
|
||||
predicted: list,
|
||||
gold: list,
|
||||
match_fn: object,
|
||||
) -> PRF1:
|
||||
"""Compute precision, recall, F1 using greedy bipartite matching.
|
||||
|
||||
Each predicted item can match at most one gold item and vice versa.
|
||||
"""
|
||||
n_pred = len(predicted)
|
||||
n_gold = len(gold)
|
||||
|
||||
if n_pred == 0 and n_gold == 0:
|
||||
return PRF1(
|
||||
precision=1.0, recall=1.0, f1=1.0,
|
||||
support_predicted=0, support_gold=0,
|
||||
)
|
||||
|
||||
if n_pred == 0:
|
||||
return PRF1(
|
||||
precision=1.0, recall=0.0, f1=0.0,
|
||||
support_predicted=0, support_gold=n_gold,
|
||||
)
|
||||
|
||||
if n_gold == 0:
|
||||
return PRF1(
|
||||
precision=0.0, recall=1.0, f1=0.0,
|
||||
support_predicted=n_pred, support_gold=0,
|
||||
)
|
||||
|
||||
matched_gold: set[int] = set()
|
||||
true_positives = 0
|
||||
|
||||
for p in predicted:
|
||||
for g_idx, g in enumerate(gold):
|
||||
if g_idx in matched_gold:
|
||||
continue
|
||||
if match_fn(p, g): # type: ignore[operator]
|
||||
true_positives += 1
|
||||
matched_gold.add(g_idx)
|
||||
break
|
||||
|
||||
precision = true_positives / n_pred if n_pred > 0 else 0.0
|
||||
recall = true_positives / n_gold if n_gold > 0 else 0.0
|
||||
|
||||
if precision + recall > 0:
|
||||
f1 = 2 * precision * recall / (precision + recall)
|
||||
else:
|
||||
f1 = 0.0
|
||||
|
||||
return PRF1(
|
||||
precision=precision,
|
||||
recall=recall,
|
||||
f1=f1,
|
||||
support_predicted=n_pred,
|
||||
support_gold=n_gold,
|
||||
)
|
||||
|
||||
|
||||
def _compute_micro_prf1(
|
||||
predicted: list,
|
||||
gold: list,
|
||||
match_fn: object,
|
||||
class_key_pred: object,
|
||||
class_key_gold: object,
|
||||
all_classes: set[str],
|
||||
) -> PRF1:
|
||||
"""Compute micro-averaged PRF1 by summing TP/FP/FN across all classes."""
|
||||
total_tp = 0
|
||||
total_pred = 0
|
||||
total_gold = 0
|
||||
|
||||
for cls in all_classes:
|
||||
cls_predicted = [p for p in predicted if class_key_pred(p) == cls]
|
||||
cls_gold = [g for g in gold if class_key_gold(g) == cls]
|
||||
|
||||
total_pred += len(cls_predicted)
|
||||
total_gold += len(cls_gold)
|
||||
|
||||
# Greedy match within this class
|
||||
matched_gold: set[int] = set()
|
||||
for p in cls_predicted:
|
||||
for g_idx, g in enumerate(cls_gold):
|
||||
if g_idx in matched_gold:
|
||||
continue
|
||||
if match_fn(p, g): # type: ignore[operator]
|
||||
total_tp += 1
|
||||
matched_gold.add(g_idx)
|
||||
break
|
||||
|
||||
if total_pred == 0 and total_gold == 0:
|
||||
return PRF1(
|
||||
precision=1.0, recall=1.0, f1=1.0,
|
||||
support_predicted=0, support_gold=0,
|
||||
)
|
||||
|
||||
precision = total_tp / total_pred if total_pred > 0 else 0.0
|
||||
recall = total_tp / total_gold if total_gold > 0 else 0.0
|
||||
|
||||
if precision + recall > 0:
|
||||
f1 = 2 * precision * recall / (precision + recall)
|
||||
else:
|
||||
f1 = 0.0
|
||||
|
||||
return PRF1(
|
||||
precision=precision,
|
||||
recall=recall,
|
||||
f1=f1,
|
||||
support_predicted=total_pred,
|
||||
support_gold=total_gold,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_event_metrics(
|
||||
predicted: list[PredictedEvent],
|
||||
gold: list[GoldEvent],
|
||||
) -> EventMetricsResult:
|
||||
"""Compute event macro-F1, micro-F1, and per-class F1.
|
||||
|
||||
Args:
|
||||
predicted: Predicted events.
|
||||
gold: Gold standard events.
|
||||
|
||||
Returns:
|
||||
EventMetricsResult with macro, micro, and per-class breakdowns.
|
||||
"""
|
||||
all_classes = {e.value for e in EventClass}
|
||||
|
||||
# Per-class breakdown
|
||||
per_class: dict[str, PRF1] = {}
|
||||
f1_scores: list[float] = []
|
||||
|
||||
for cls in sorted(all_classes):
|
||||
cls_predicted = [p for p in predicted if p.event_class.value == cls]
|
||||
cls_gold = [g for g in gold if g.event_class.value == cls]
|
||||
prf1 = _compute_prf1_greedy(cls_predicted, cls_gold, _events_match)
|
||||
per_class[cls] = prf1
|
||||
f1_scores.append(prf1.f1)
|
||||
|
||||
# Macro-F1: average F1 across all event classes
|
||||
macro_f1 = sum(f1_scores) / len(f1_scores) if f1_scores else 0.0
|
||||
|
||||
# Micro-F1: global TP/FP/FN
|
||||
micro = _compute_micro_prf1(
|
||||
predicted, gold, _events_match,
|
||||
lambda p: p.event_class.value,
|
||||
lambda g: g.event_class.value,
|
||||
all_classes,
|
||||
)
|
||||
|
||||
return EventMetricsResult(
|
||||
macro_f1=macro_f1,
|
||||
micro=micro,
|
||||
per_class=per_class,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Relations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_relation_metrics(
|
||||
predicted: list[PredictedRelation],
|
||||
gold: list[GoldRelation],
|
||||
) -> RelationMetricsResult:
|
||||
"""Compute relation macro-F1, micro-F1, and per-type F1.
|
||||
|
||||
Args:
|
||||
predicted: Predicted relations.
|
||||
gold: Gold standard relations.
|
||||
|
||||
Returns:
|
||||
RelationMetricsResult with macro, micro, and per-type breakdowns.
|
||||
"""
|
||||
all_types = {r.value for r in RelationType}
|
||||
|
||||
# Per-type breakdown
|
||||
per_type: dict[str, PRF1] = {}
|
||||
f1_scores: list[float] = []
|
||||
|
||||
for rtype in sorted(all_types):
|
||||
type_predicted = [p for p in predicted if p.relation_type.value == rtype]
|
||||
type_gold = [g for g in gold if g.relation_type.value == rtype]
|
||||
prf1 = _compute_prf1_greedy(type_predicted, type_gold, _relations_match)
|
||||
per_type[rtype] = prf1
|
||||
f1_scores.append(prf1.f1)
|
||||
|
||||
# Macro-F1: average F1 across all relation types
|
||||
macro_f1 = sum(f1_scores) / len(f1_scores) if f1_scores else 0.0
|
||||
|
||||
# Micro-F1: global TP/FP/FN
|
||||
micro = _compute_micro_prf1(
|
||||
predicted, gold, _relations_match,
|
||||
lambda p: p.relation_type.value,
|
||||
lambda g: g.relation_type.value,
|
||||
all_types,
|
||||
)
|
||||
|
||||
return RelationMetricsResult(
|
||||
macro_f1=macro_f1,
|
||||
micro=micro,
|
||||
per_type=per_type,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Combined Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def evaluate_events_and_relations(
|
||||
predicted_events: list[PredictedEvent],
|
||||
gold_events: list[GoldEvent],
|
||||
predicted_relations: list[PredictedRelation],
|
||||
gold_relations: list[GoldRelation],
|
||||
document_count: int = 1,
|
||||
) -> EventRelationEvaluationReport:
|
||||
"""Run full event and relation evaluation producing a complete report.
|
||||
|
||||
Args:
|
||||
predicted_events: All predicted events.
|
||||
gold_events: All gold standard events.
|
||||
predicted_relations: All predicted relations.
|
||||
gold_relations: All gold standard relations.
|
||||
document_count: Number of documents evaluated.
|
||||
|
||||
Returns:
|
||||
EventRelationEvaluationReport with event metrics, relation metrics.
|
||||
"""
|
||||
event_metrics = compute_event_metrics(predicted_events, gold_events)
|
||||
relation_metrics = compute_relation_metrics(predicted_relations, gold_relations)
|
||||
|
||||
return EventRelationEvaluationReport(
|
||||
event_metrics=event_metrics,
|
||||
relation_metrics=relation_metrics,
|
||||
document_count=document_count,
|
||||
)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Evidence offset validity, support rate, coverage, and orphan metrics.
|
||||
|
||||
Implements evaluation metrics for evidence grounding quality:
|
||||
- Offset validity rate: proportion of spans where text matches source at offsets
|
||||
- Support rate: proportion of extracted items with at least one valid evidence span
|
||||
- Coverage score: average proportion of required fields supported by evidence
|
||||
- Orphan rate: proportion of evidence spans not referenced by any extracted item
|
||||
- Per-field support: breakdown of support rate by field type
|
||||
- Unsupported claim rate: proportion of extracted items with no valid evidence
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FieldType(str, Enum):
|
||||
"""Types of extracted fields that can be evidence-supported."""
|
||||
|
||||
entity = "entity"
|
||||
event = "event"
|
||||
fact = "fact"
|
||||
sentiment = "sentiment"
|
||||
|
||||
|
||||
class EvidenceSpan(BaseModel):
|
||||
"""An evidence span with text and character offsets into source."""
|
||||
|
||||
span_id: str
|
||||
text: str
|
||||
start_char: int
|
||||
end_char: int
|
||||
document_id: str = ""
|
||||
|
||||
|
||||
class ExtractionResult(BaseModel):
|
||||
"""An extracted item referencing evidence spans by ID."""
|
||||
|
||||
item_id: str
|
||||
field_type: FieldType
|
||||
evidence_ids: list[str] = Field(default_factory=list)
|
||||
required_fields: list[str] = Field(default_factory=list)
|
||||
supported_fields: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EvidenceMetricsResult(BaseModel):
|
||||
"""Complete evidence evaluation result."""
|
||||
|
||||
validity_rate: float = Field(ge=0.0, le=1.0)
|
||||
support_rate: float = Field(ge=0.0, le=1.0)
|
||||
coverage_score: float = Field(ge=0.0, le=1.0)
|
||||
orphan_rate: float = Field(ge=0.0, le=1.0)
|
||||
per_field_support: dict[str, float]
|
||||
unsupported_claim_rate: float = Field(ge=0.0, le=1.0)
|
||||
total_spans: int = Field(ge=0)
|
||||
valid_spans: int = Field(ge=0)
|
||||
total_items: int = Field(ge=0)
|
||||
supported_items: int = Field(ge=0)
|
||||
orphan_spans: int = Field(ge=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core Metric Computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_offset_validity(
|
||||
spans: list[EvidenceSpan],
|
||||
source_text: str,
|
||||
) -> tuple[float, int, int]:
|
||||
"""Compute the proportion of spans whose text matches source at offsets.
|
||||
|
||||
Args:
|
||||
spans: Evidence spans with text and character offsets.
|
||||
source_text: The original source document text.
|
||||
|
||||
Returns:
|
||||
Tuple of (validity_rate, valid_count, total_count).
|
||||
"""
|
||||
if not spans:
|
||||
return (1.0, 0, 0)
|
||||
|
||||
valid = 0
|
||||
for span in spans:
|
||||
start = span.start_char
|
||||
end = span.end_char
|
||||
|
||||
# Basic bounds check
|
||||
if start < 0 or end < 0 or start > end:
|
||||
continue
|
||||
if end > len(source_text):
|
||||
continue
|
||||
|
||||
source_slice = source_text[start:end]
|
||||
if source_slice == span.text:
|
||||
valid += 1
|
||||
|
||||
total = len(spans)
|
||||
rate = valid / total
|
||||
return (rate, valid, total)
|
||||
|
||||
|
||||
def compute_support_rate(
|
||||
items: list[ExtractionResult],
|
||||
valid_span_ids: set[str],
|
||||
) -> tuple[float, int, int]:
|
||||
"""Compute proportion of items with at least one valid evidence span.
|
||||
|
||||
Args:
|
||||
items: Extraction results referencing evidence span IDs.
|
||||
valid_span_ids: Set of span IDs that passed offset validity.
|
||||
|
||||
Returns:
|
||||
Tuple of (support_rate, supported_count, total_count).
|
||||
"""
|
||||
if not items:
|
||||
return (1.0, 0, 0)
|
||||
|
||||
supported = 0
|
||||
for item in items:
|
||||
if any(eid in valid_span_ids for eid in item.evidence_ids):
|
||||
supported += 1
|
||||
|
||||
total = len(items)
|
||||
rate = supported / total
|
||||
return (rate, supported, total)
|
||||
|
||||
|
||||
def compute_coverage_score(
|
||||
items: list[ExtractionResult],
|
||||
) -> float:
|
||||
"""Compute average proportion of required fields supported by evidence.
|
||||
|
||||
For each item, coverage = len(supported_fields ∩ required_fields) / len(required_fields).
|
||||
Items with no required fields are treated as fully covered.
|
||||
|
||||
Args:
|
||||
items: Extraction results with required and supported field lists.
|
||||
|
||||
Returns:
|
||||
Average coverage score across all items.
|
||||
"""
|
||||
if not items:
|
||||
return 1.0
|
||||
|
||||
total_coverage = 0.0
|
||||
for item in items:
|
||||
if not item.required_fields:
|
||||
total_coverage += 1.0
|
||||
continue
|
||||
|
||||
required = set(item.required_fields)
|
||||
supported = set(item.supported_fields)
|
||||
covered = required & supported
|
||||
total_coverage += len(covered) / len(required)
|
||||
|
||||
return total_coverage / len(items)
|
||||
|
||||
|
||||
def compute_orphan_rate(
|
||||
spans: list[EvidenceSpan],
|
||||
items: list[ExtractionResult],
|
||||
) -> tuple[float, int]:
|
||||
"""Compute proportion of evidence spans not referenced by any item.
|
||||
|
||||
Args:
|
||||
spans: All evidence spans.
|
||||
items: Extraction results referencing evidence span IDs.
|
||||
|
||||
Returns:
|
||||
Tuple of (orphan_rate, orphan_count).
|
||||
"""
|
||||
if not spans:
|
||||
return (0.0, 0)
|
||||
|
||||
referenced_ids: set[str] = set()
|
||||
for item in items:
|
||||
referenced_ids.update(item.evidence_ids)
|
||||
|
||||
orphan_count = sum(1 for span in spans if span.span_id not in referenced_ids)
|
||||
rate = orphan_count / len(spans)
|
||||
return (rate, orphan_count)
|
||||
|
||||
|
||||
def compute_per_field_support(
|
||||
items: list[ExtractionResult],
|
||||
valid_span_ids: set[str],
|
||||
) -> dict[str, float]:
|
||||
"""Compute support rate broken down by field type.
|
||||
|
||||
Args:
|
||||
items: Extraction results with field types and evidence IDs.
|
||||
valid_span_ids: Set of span IDs that passed offset validity.
|
||||
|
||||
Returns:
|
||||
Dict mapping field type name to support rate.
|
||||
"""
|
||||
by_type: dict[str, list[ExtractionResult]] = {}
|
||||
for item in items:
|
||||
key = item.field_type.value
|
||||
by_type.setdefault(key, []).append(item)
|
||||
|
||||
result: dict[str, float] = {}
|
||||
for field_type, type_items in sorted(by_type.items()):
|
||||
rate, _, _ = compute_support_rate(type_items, valid_span_ids)
|
||||
result[field_type] = rate
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def compute_unsupported_claim_rate(
|
||||
items: list[ExtractionResult],
|
||||
valid_span_ids: set[str],
|
||||
) -> float:
|
||||
"""Compute proportion of items with no valid evidence at all.
|
||||
|
||||
An item is unsupported if it has no evidence_ids OR none of its
|
||||
evidence_ids are in the valid set.
|
||||
|
||||
Args:
|
||||
items: Extraction results referencing evidence span IDs.
|
||||
valid_span_ids: Set of span IDs that passed offset validity.
|
||||
|
||||
Returns:
|
||||
Unsupported claim rate (0.0 to 1.0).
|
||||
"""
|
||||
if not items:
|
||||
return 0.0
|
||||
|
||||
unsupported = 0
|
||||
for item in items:
|
||||
if not item.evidence_ids:
|
||||
unsupported += 1
|
||||
elif not any(eid in valid_span_ids for eid in item.evidence_ids):
|
||||
unsupported += 1
|
||||
|
||||
return unsupported / len(items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def evaluate_evidence(
|
||||
spans: list[EvidenceSpan],
|
||||
source_text: str,
|
||||
items: list[ExtractionResult],
|
||||
) -> EvidenceMetricsResult:
|
||||
"""Run full evidence evaluation producing a complete metrics report.
|
||||
|
||||
Args:
|
||||
spans: All evidence spans with text and offsets.
|
||||
source_text: The original source document text.
|
||||
items: Extraction results referencing evidence spans.
|
||||
|
||||
Returns:
|
||||
EvidenceMetricsResult with all computed metrics.
|
||||
"""
|
||||
# Step 1: Offset validity
|
||||
validity_rate, valid_count, total_spans = compute_offset_validity(spans, source_text)
|
||||
|
||||
# Step 2: Build valid span ID set
|
||||
valid_span_ids: set[str] = set()
|
||||
for span in spans:
|
||||
start = span.start_char
|
||||
end = span.end_char
|
||||
if start < 0 or end < 0 or start > end:
|
||||
continue
|
||||
if end > len(source_text):
|
||||
continue
|
||||
if source_text[start:end] == span.text:
|
||||
valid_span_ids.add(span.span_id)
|
||||
|
||||
# Step 3: Support rate
|
||||
support_rate, supported_count, total_items = compute_support_rate(items, valid_span_ids)
|
||||
|
||||
# Step 4: Coverage score
|
||||
coverage_score = compute_coverage_score(items)
|
||||
|
||||
# Step 5: Orphan rate
|
||||
orphan_rate, orphan_count = compute_orphan_rate(spans, items)
|
||||
|
||||
# Step 6: Per-field support
|
||||
per_field_support = compute_per_field_support(items, valid_span_ids)
|
||||
|
||||
# Step 7: Unsupported claim rate
|
||||
unsupported_claim_rate = compute_unsupported_claim_rate(items, valid_span_ids)
|
||||
|
||||
return EvidenceMetricsResult(
|
||||
validity_rate=validity_rate,
|
||||
support_rate=support_rate,
|
||||
coverage_score=coverage_score,
|
||||
orphan_rate=orphan_rate,
|
||||
per_field_support=per_field_support,
|
||||
unsupported_claim_rate=unsupported_claim_rate,
|
||||
total_spans=total_spans,
|
||||
valid_spans=valid_count,
|
||||
total_items=total_items,
|
||||
supported_items=supported_count,
|
||||
orphan_spans=orphan_count,
|
||||
)
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Numeric exact/tolerance-aware matching metrics for extracted financial facts.
|
||||
|
||||
Implements evaluation metrics for numeric extraction quality against a gold
|
||||
standard corpus. Supports exact match, default 5% tolerance, and configurable
|
||||
tolerance matching. Provides per-fact-type breakdowns, unit consistency
|
||||
checks, and period matching.
|
||||
|
||||
Input model fields: fact_type, predicate, literal_value, normalized_value,
|
||||
unit, period, evidence_ids.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FactType(str, Enum):
|
||||
"""Known financial fact types for per-type breakdown."""
|
||||
|
||||
eps = "eps"
|
||||
revenue = "revenue"
|
||||
percentage_change = "percentage_change"
|
||||
price_target = "price_target"
|
||||
guidance = "guidance"
|
||||
dividend = "dividend"
|
||||
margin = "margin"
|
||||
growth_rate = "growth_rate"
|
||||
other = "other"
|
||||
|
||||
|
||||
class NumericFact(BaseModel):
|
||||
"""A single extracted numeric fact with normalization and context."""
|
||||
|
||||
fact_type: str
|
||||
predicate: str
|
||||
literal_value: str
|
||||
normalized_value: float | None = None
|
||||
unit: str | None = None
|
||||
period: str | None = None
|
||||
evidence_ids: list[str] = Field(default_factory=list)
|
||||
document_id: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NumericMatchResult(BaseModel):
|
||||
"""Result of matching a single predicted fact against gold."""
|
||||
|
||||
exact_match: bool = False
|
||||
within_tolerance: bool = False
|
||||
tolerance_pct: float = 0.0
|
||||
unit_consistent: bool = True
|
||||
period_match: bool = True
|
||||
absolute_error: float | None = None
|
||||
relative_error_pct: float | None = None
|
||||
|
||||
|
||||
class AccuracyMetric(BaseModel):
|
||||
"""Simple accuracy metric with support count."""
|
||||
|
||||
accuracy: float = Field(ge=0.0, le=1.0)
|
||||
matches: int = Field(ge=0)
|
||||
total: int = Field(ge=0)
|
||||
|
||||
|
||||
class ToleranceDistribution(BaseModel):
|
||||
"""Distribution of relative errors across tolerance buckets."""
|
||||
|
||||
exact: int = Field(ge=0, default=0)
|
||||
within_1pct: int = Field(ge=0, default=0)
|
||||
within_5pct: int = Field(ge=0, default=0)
|
||||
within_10pct: int = Field(ge=0, default=0)
|
||||
beyond_10pct: int = Field(ge=0, default=0)
|
||||
not_comparable: int = Field(ge=0, default=0)
|
||||
|
||||
|
||||
class ErrorCategory(str, Enum):
|
||||
"""Common numeric extraction error categories."""
|
||||
|
||||
unit_mismatch = "unit_mismatch"
|
||||
period_mismatch = "period_mismatch"
|
||||
magnitude_error = "magnitude_error"
|
||||
sign_error = "sign_error"
|
||||
parsing_failure = "parsing_failure"
|
||||
missing_value = "missing_value"
|
||||
|
||||
|
||||
class ErrorBreakdown(BaseModel):
|
||||
"""Counts of errors by category."""
|
||||
|
||||
counts: dict[str, int] = Field(default_factory=dict)
|
||||
total_errors: int = Field(ge=0, default=0)
|
||||
|
||||
|
||||
class NumericEvaluationReport(BaseModel):
|
||||
"""Complete numeric extraction evaluation report."""
|
||||
|
||||
exact_match_accuracy: AccuracyMetric
|
||||
tolerance_accuracy: AccuracyMetric
|
||||
tolerance_pct_used: float = Field(ge=0.0)
|
||||
per_type_exact: dict[str, AccuracyMetric] = Field(default_factory=dict)
|
||||
per_type_tolerance: dict[str, AccuracyMetric] = Field(default_factory=dict)
|
||||
unit_consistency: AccuracyMetric
|
||||
period_match: AccuracyMetric
|
||||
tolerance_distribution: ToleranceDistribution
|
||||
error_breakdown: ErrorBreakdown
|
||||
document_count: int = Field(ge=0, default=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matching Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_TOLERANCE_PCT = 5.0
|
||||
|
||||
|
||||
def _is_exact_match(pred_value: float, gold_value: float) -> bool:
|
||||
"""Check if predicted value exactly equals gold value (within float epsilon)."""
|
||||
return abs(pred_value - gold_value) < 1e-9
|
||||
|
||||
|
||||
def _is_within_tolerance(
|
||||
pred_value: float, gold_value: float, tolerance_pct: float
|
||||
) -> bool:
|
||||
"""Check if predicted value is within ±tolerance_pct of gold value.
|
||||
|
||||
For zero gold values, uses absolute comparison with a small epsilon
|
||||
derived from the tolerance percentage.
|
||||
"""
|
||||
if abs(gold_value) < 1e-12:
|
||||
# For zero gold, allow small absolute tolerance
|
||||
return abs(pred_value) < tolerance_pct / 100.0
|
||||
threshold = abs(gold_value) * (tolerance_pct / 100.0)
|
||||
return abs(pred_value - gold_value) <= threshold
|
||||
|
||||
|
||||
def _compute_relative_error_pct(pred_value: float, gold_value: float) -> float | None:
|
||||
"""Compute relative error as a percentage of gold value.
|
||||
|
||||
Returns None if gold value is zero (relative error undefined).
|
||||
"""
|
||||
if abs(gold_value) < 1e-12:
|
||||
return None
|
||||
return abs(pred_value - gold_value) / abs(gold_value) * 100.0
|
||||
|
||||
|
||||
def _classify_error(
|
||||
pred: NumericFact, gold: NumericFact, pred_value: float | None, gold_value: float
|
||||
) -> str | None:
|
||||
"""Classify the type of error for a mismatched prediction."""
|
||||
if pred_value is None:
|
||||
if pred.normalized_value is None:
|
||||
return ErrorCategory.parsing_failure.value
|
||||
return ErrorCategory.missing_value.value
|
||||
|
||||
# Check sign error (opposite signs, both non-zero)
|
||||
if pred_value * gold_value < 0 and abs(pred_value) > 1e-9 and abs(gold_value) > 1e-9:
|
||||
return ErrorCategory.sign_error.value
|
||||
|
||||
# Check magnitude error (off by factor of 10+)
|
||||
if abs(gold_value) > 1e-9:
|
||||
ratio = abs(pred_value / gold_value)
|
||||
if ratio >= 10.0 or ratio <= 0.1:
|
||||
return ErrorCategory.magnitude_error.value
|
||||
|
||||
# Unit mismatch (if units don't match)
|
||||
if pred.unit and gold.unit and pred.unit != gold.unit:
|
||||
return ErrorCategory.unit_mismatch.value
|
||||
|
||||
# Period mismatch
|
||||
if pred.period and gold.period and pred.period != gold.period:
|
||||
return ErrorCategory.period_mismatch.value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _bucket_relative_error(relative_error_pct: float | None) -> str:
|
||||
"""Assign a relative error to a tolerance bucket name."""
|
||||
if relative_error_pct is None:
|
||||
return "not_comparable"
|
||||
if relative_error_pct < 1e-7:
|
||||
return "exact"
|
||||
if relative_error_pct <= 1.0:
|
||||
return "within_1pct"
|
||||
if relative_error_pct <= 5.0:
|
||||
return "within_5pct"
|
||||
if relative_error_pct <= 10.0:
|
||||
return "within_10pct"
|
||||
return "beyond_10pct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single Fact Matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def match_numeric_fact(
|
||||
pred: NumericFact,
|
||||
gold: NumericFact,
|
||||
tolerance_pct: float = DEFAULT_TOLERANCE_PCT,
|
||||
) -> NumericMatchResult:
|
||||
"""Match a predicted numeric fact against a gold standard fact.
|
||||
|
||||
Compares normalized values, checks unit consistency and period match.
|
||||
|
||||
Args:
|
||||
pred: Predicted numeric fact.
|
||||
gold: Gold standard numeric fact.
|
||||
tolerance_pct: Tolerance percentage for approximate matching.
|
||||
|
||||
Returns:
|
||||
NumericMatchResult with match details.
|
||||
"""
|
||||
# Unit consistency check
|
||||
unit_consistent = True
|
||||
if pred.unit is not None and gold.unit is not None:
|
||||
unit_consistent = pred.unit == gold.unit
|
||||
elif pred.unit is None and gold.unit is not None:
|
||||
unit_consistent = False
|
||||
# If gold has no unit, we consider it consistent regardless
|
||||
|
||||
# Period match check
|
||||
period_match = True
|
||||
if pred.period is not None and gold.period is not None:
|
||||
period_match = pred.period == gold.period
|
||||
elif pred.period is None and gold.period is not None:
|
||||
period_match = False
|
||||
|
||||
# Value comparison
|
||||
pred_value = pred.normalized_value
|
||||
gold_value = gold.normalized_value
|
||||
|
||||
if pred_value is None or gold_value is None:
|
||||
return NumericMatchResult(
|
||||
exact_match=False,
|
||||
within_tolerance=False,
|
||||
tolerance_pct=tolerance_pct,
|
||||
unit_consistent=unit_consistent,
|
||||
period_match=period_match,
|
||||
absolute_error=None,
|
||||
relative_error_pct=None,
|
||||
)
|
||||
|
||||
absolute_error = abs(pred_value - gold_value)
|
||||
relative_error_pct = _compute_relative_error_pct(pred_value, gold_value)
|
||||
exact = _is_exact_match(pred_value, gold_value)
|
||||
within_tol = _is_within_tolerance(pred_value, gold_value, tolerance_pct)
|
||||
|
||||
return NumericMatchResult(
|
||||
exact_match=exact,
|
||||
within_tolerance=within_tol,
|
||||
tolerance_pct=tolerance_pct,
|
||||
unit_consistent=unit_consistent,
|
||||
period_match=period_match,
|
||||
absolute_error=absolute_error,
|
||||
relative_error_pct=relative_error_pct,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch Evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _align_facts(
|
||||
predicted: list[NumericFact],
|
||||
gold: list[NumericFact],
|
||||
) -> list[tuple[NumericFact, NumericFact]]:
|
||||
"""Align predicted facts to gold facts using greedy matching.
|
||||
|
||||
Matches on fact_type and predicate. Each gold fact can match at most
|
||||
one predicted fact.
|
||||
"""
|
||||
pairs: list[tuple[NumericFact, NumericFact]] = []
|
||||
matched_gold: set[int] = set()
|
||||
|
||||
for pred in predicted:
|
||||
for g_idx, g in enumerate(gold):
|
||||
if g_idx in matched_gold:
|
||||
continue
|
||||
if pred.fact_type == g.fact_type and pred.predicate == g.predicate:
|
||||
pairs.append((pred, g))
|
||||
matched_gold.add(g_idx)
|
||||
break
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def evaluate_numeric_facts(
|
||||
predicted: list[NumericFact],
|
||||
gold: list[NumericFact],
|
||||
tolerance_pct: float = DEFAULT_TOLERANCE_PCT,
|
||||
document_count: int = 1,
|
||||
) -> NumericEvaluationReport:
|
||||
"""Run full numeric extraction evaluation.
|
||||
|
||||
Aligns predicted facts to gold facts by fact_type and predicate,
|
||||
then computes exact match accuracy, tolerance-based accuracy,
|
||||
per-type breakdowns, unit consistency, period match accuracy,
|
||||
tolerance distribution histogram, and error categories.
|
||||
|
||||
Args:
|
||||
predicted: All predicted numeric facts.
|
||||
gold: All gold standard numeric facts.
|
||||
tolerance_pct: Tolerance percentage for approximate matching.
|
||||
document_count: Number of documents evaluated.
|
||||
|
||||
Returns:
|
||||
NumericEvaluationReport with complete evaluation results.
|
||||
"""
|
||||
pairs = _align_facts(predicted, gold)
|
||||
total_aligned = len(pairs)
|
||||
|
||||
# Track results
|
||||
exact_matches = 0
|
||||
tolerance_matches = 0
|
||||
unit_matches = 0
|
||||
period_matches = 0
|
||||
comparable_count = 0
|
||||
|
||||
# Per-type tracking
|
||||
per_type_exact_counts: dict[str, tuple[int, int]] = {} # type -> (matches, total)
|
||||
per_type_tol_counts: dict[str, tuple[int, int]] = {}
|
||||
|
||||
# Tolerance distribution
|
||||
dist = ToleranceDistribution()
|
||||
|
||||
# Error tracking
|
||||
error_counts: dict[str, int] = {}
|
||||
|
||||
for pred, g in pairs:
|
||||
result = match_numeric_fact(pred, g, tolerance_pct)
|
||||
|
||||
# Unit consistency
|
||||
if result.unit_consistent:
|
||||
unit_matches += 1
|
||||
|
||||
# Period match
|
||||
if result.period_match:
|
||||
period_matches += 1
|
||||
|
||||
# Only count value comparisons when both values exist
|
||||
if pred.normalized_value is not None and g.normalized_value is not None:
|
||||
comparable_count += 1
|
||||
|
||||
if result.exact_match:
|
||||
exact_matches += 1
|
||||
if result.within_tolerance:
|
||||
tolerance_matches += 1
|
||||
|
||||
# Per-type tracking
|
||||
ft = pred.fact_type
|
||||
ex_m, ex_t = per_type_exact_counts.get(ft, (0, 0))
|
||||
tol_m, tol_t = per_type_tol_counts.get(ft, (0, 0))
|
||||
per_type_exact_counts[ft] = (
|
||||
ex_m + (1 if result.exact_match else 0),
|
||||
ex_t + 1,
|
||||
)
|
||||
per_type_tol_counts[ft] = (
|
||||
tol_m + (1 if result.within_tolerance else 0),
|
||||
tol_t + 1,
|
||||
)
|
||||
|
||||
# Tolerance distribution
|
||||
bucket = _bucket_relative_error(result.relative_error_pct)
|
||||
if bucket == "exact":
|
||||
dist.exact += 1
|
||||
elif bucket == "within_1pct":
|
||||
dist.within_1pct += 1
|
||||
elif bucket == "within_5pct":
|
||||
dist.within_5pct += 1
|
||||
elif bucket == "within_10pct":
|
||||
dist.within_10pct += 1
|
||||
elif bucket == "beyond_10pct":
|
||||
dist.beyond_10pct += 1
|
||||
else:
|
||||
dist.not_comparable += 1
|
||||
|
||||
# Error classification for non-exact matches
|
||||
if not result.exact_match:
|
||||
error_cat = _classify_error(pred, g, pred.normalized_value, g.normalized_value)
|
||||
if error_cat:
|
||||
error_counts[error_cat] = error_counts.get(error_cat, 0) + 1
|
||||
else:
|
||||
dist.not_comparable += 1
|
||||
# Classify missing value error
|
||||
if pred.normalized_value is None:
|
||||
error_cat = ErrorCategory.parsing_failure.value
|
||||
elif g.normalized_value is None:
|
||||
error_cat = ErrorCategory.missing_value.value
|
||||
else:
|
||||
error_cat = None
|
||||
if error_cat:
|
||||
error_counts[error_cat] = error_counts.get(error_cat, 0) + 1
|
||||
|
||||
# Build accuracy metrics
|
||||
exact_accuracy = AccuracyMetric(
|
||||
accuracy=exact_matches / comparable_count if comparable_count > 0 else 1.0,
|
||||
matches=exact_matches,
|
||||
total=comparable_count,
|
||||
)
|
||||
tolerance_accuracy = AccuracyMetric(
|
||||
accuracy=tolerance_matches / comparable_count if comparable_count > 0 else 1.0,
|
||||
matches=tolerance_matches,
|
||||
total=comparable_count,
|
||||
)
|
||||
unit_consistency = AccuracyMetric(
|
||||
accuracy=unit_matches / total_aligned if total_aligned > 0 else 1.0,
|
||||
matches=unit_matches,
|
||||
total=total_aligned,
|
||||
)
|
||||
period_match_metric = AccuracyMetric(
|
||||
accuracy=period_matches / total_aligned if total_aligned > 0 else 1.0,
|
||||
matches=period_matches,
|
||||
total=total_aligned,
|
||||
)
|
||||
|
||||
# Per-type exact accuracy
|
||||
per_type_exact: dict[str, AccuracyMetric] = {}
|
||||
for ft, (m, t) in sorted(per_type_exact_counts.items()):
|
||||
per_type_exact[ft] = AccuracyMetric(
|
||||
accuracy=m / t if t > 0 else 1.0,
|
||||
matches=m,
|
||||
total=t,
|
||||
)
|
||||
|
||||
# Per-type tolerance accuracy
|
||||
per_type_tolerance: dict[str, AccuracyMetric] = {}
|
||||
for ft, (m, t) in sorted(per_type_tol_counts.items()):
|
||||
per_type_tolerance[ft] = AccuracyMetric(
|
||||
accuracy=m / t if t > 0 else 1.0,
|
||||
matches=m,
|
||||
total=t,
|
||||
)
|
||||
|
||||
total_errors = sum(error_counts.values())
|
||||
|
||||
return NumericEvaluationReport(
|
||||
exact_match_accuracy=exact_accuracy,
|
||||
tolerance_accuracy=tolerance_accuracy,
|
||||
tolerance_pct_used=tolerance_pct,
|
||||
per_type_exact=per_type_exact,
|
||||
per_type_tolerance=per_type_tolerance,
|
||||
unit_consistency=unit_consistency,
|
||||
period_match=period_match_metric,
|
||||
tolerance_distribution=dist,
|
||||
error_breakdown=ErrorBreakdown(counts=error_counts, total_errors=total_errors),
|
||||
document_count=document_count,
|
||||
)
|
||||
@@ -0,0 +1,599 @@
|
||||
"""Per-document-type and per-difficulty evaluation report generator.
|
||||
|
||||
Groups evaluation results by document type and difficulty bucket,
|
||||
runs all individual metric computations per group, and produces a
|
||||
FullEvaluationReport with overall + per-type + per-difficulty breakdowns.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.entity_metrics import (
|
||||
EntityEvaluationReport,
|
||||
EntitySpan,
|
||||
MatchMode,
|
||||
TickerMention,
|
||||
evaluate_entities,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.event_metrics import (
|
||||
EventRelationEvaluationReport,
|
||||
GoldEvent,
|
||||
GoldRelation,
|
||||
PredictedEvent,
|
||||
PredictedRelation,
|
||||
evaluate_events_and_relations,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.evidence_metrics import (
|
||||
EvidenceMetricsResult,
|
||||
EvidenceSpan,
|
||||
ExtractionResult,
|
||||
evaluate_evidence,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.numeric_metrics import (
|
||||
NumericEvaluationReport,
|
||||
NumericFact,
|
||||
evaluate_numeric_facts,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.resource_metrics import (
|
||||
ResourceEvaluationReport,
|
||||
StageTimingRecord,
|
||||
evaluate_resources,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.sentiment_metrics import (
|
||||
SentimentEvaluationReport,
|
||||
SentimentPrediction,
|
||||
evaluate_sentiment,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DocumentType(str, Enum):
|
||||
"""Document types in the evaluation corpus."""
|
||||
|
||||
article = "article"
|
||||
filing = "filing"
|
||||
transcript = "transcript"
|
||||
press_release = "press_release"
|
||||
macro_event = "macro_event"
|
||||
|
||||
|
||||
class Difficulty(str, Enum):
|
||||
"""Difficulty buckets for evaluation stratification."""
|
||||
|
||||
easy = "easy"
|
||||
medium = "medium"
|
||||
hard = "hard"
|
||||
|
||||
|
||||
class DocumentResult(BaseModel):
|
||||
"""Holds all metric inputs for a single evaluated document."""
|
||||
|
||||
document_id: str
|
||||
document_type: DocumentType
|
||||
difficulty: Difficulty
|
||||
|
||||
# Entity metric inputs
|
||||
predicted_entities: list[EntitySpan] = Field(default_factory=list)
|
||||
gold_entities: list[EntitySpan] = Field(default_factory=list)
|
||||
predicted_tickers: list[TickerMention] = Field(default_factory=list)
|
||||
gold_tickers: list[TickerMention] = Field(default_factory=list)
|
||||
|
||||
# Event/relation metric inputs
|
||||
predicted_events: list[PredictedEvent] = Field(default_factory=list)
|
||||
gold_events: list[GoldEvent] = Field(default_factory=list)
|
||||
predicted_relations: list[PredictedRelation] = Field(default_factory=list)
|
||||
gold_relations: list[GoldRelation] = Field(default_factory=list)
|
||||
|
||||
# Numeric metric inputs
|
||||
predicted_numeric_facts: list[NumericFact] = Field(default_factory=list)
|
||||
gold_numeric_facts: list[NumericFact] = Field(default_factory=list)
|
||||
|
||||
# Evidence metric inputs
|
||||
evidence_spans: list[EvidenceSpan] = Field(default_factory=list)
|
||||
source_text: str = ""
|
||||
extraction_results: list[ExtractionResult] = Field(default_factory=list)
|
||||
|
||||
# Sentiment metric inputs
|
||||
predicted_sentiments: list[SentimentPrediction] = Field(default_factory=list)
|
||||
gold_sentiments: list[SentimentPrediction] = Field(default_factory=list)
|
||||
|
||||
# Resource metric inputs
|
||||
stage_timings: list[StageTimingRecord] = Field(default_factory=list)
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safety Gate Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SafetyGateThresholds(BaseModel):
|
||||
"""Configurable thresholds for safety gate pass/fail."""
|
||||
|
||||
min_entity_f1: float = Field(default=0.7, ge=0.0, le=1.0)
|
||||
min_event_macro_f1: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
min_evidence_support_rate: float = Field(default=0.8, ge=0.0, le=1.0)
|
||||
max_unsupported_claim_rate: float = Field(default=0.2, ge=0.0, le=1.0)
|
||||
min_sentiment_macro_f1: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
max_calibration_ece: float = Field(default=0.15, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class SafetyGateResult(BaseModel):
|
||||
"""Result of safety gate evaluation."""
|
||||
|
||||
passed: bool
|
||||
checks: dict[str, bool] = Field(default_factory=dict)
|
||||
details: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GroupMetrics(BaseModel):
|
||||
"""Metrics for a single group (document type or difficulty bucket)."""
|
||||
|
||||
group_name: str
|
||||
document_count: int = Field(ge=0)
|
||||
entity_metrics: EntityEvaluationReport | None = None
|
||||
event_metrics: EventRelationEvaluationReport | None = None
|
||||
numeric_metrics: NumericEvaluationReport | None = None
|
||||
evidence_metrics: EvidenceMetricsResult | None = None
|
||||
sentiment_metrics: SentimentEvaluationReport | None = None
|
||||
resource_metrics: ResourceEvaluationReport | None = None
|
||||
|
||||
|
||||
class FullEvaluationReport(BaseModel):
|
||||
"""Complete evaluation report with overall + per-type + per-difficulty breakdowns."""
|
||||
|
||||
overall: GroupMetrics
|
||||
per_document_type: dict[str, GroupMetrics] = Field(default_factory=dict)
|
||||
per_difficulty: dict[str, GroupMetrics] = Field(default_factory=dict)
|
||||
safety_gate: SafetyGateResult
|
||||
total_documents: int = Field(ge=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core Computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_group_metrics(
|
||||
group_name: str,
|
||||
documents: list[DocumentResult],
|
||||
entity_match_mode: MatchMode = MatchMode.strict,
|
||||
) -> GroupMetrics:
|
||||
"""Compute all metrics for a group of documents.
|
||||
|
||||
Aggregates all individual document inputs into combined lists and
|
||||
runs each metric computation once for the group.
|
||||
"""
|
||||
if not documents:
|
||||
return GroupMetrics(group_name=group_name, document_count=0)
|
||||
|
||||
doc_count = len(documents)
|
||||
|
||||
# Aggregate entity inputs
|
||||
all_pred_entities: list[EntitySpan] = []
|
||||
all_gold_entities: list[EntitySpan] = []
|
||||
all_pred_tickers: list[TickerMention] = []
|
||||
all_gold_tickers: list[TickerMention] = []
|
||||
|
||||
for doc in documents:
|
||||
all_pred_entities.extend(doc.predicted_entities)
|
||||
all_gold_entities.extend(doc.gold_entities)
|
||||
all_pred_tickers.extend(doc.predicted_tickers)
|
||||
all_gold_tickers.extend(doc.gold_tickers)
|
||||
|
||||
entity_report = evaluate_entities(
|
||||
predicted_entities=all_pred_entities,
|
||||
gold_entities=all_gold_entities,
|
||||
predicted_tickers=all_pred_tickers,
|
||||
gold_tickers=all_gold_tickers,
|
||||
mode=entity_match_mode,
|
||||
document_count=doc_count,
|
||||
)
|
||||
|
||||
# Aggregate event/relation inputs
|
||||
all_pred_events: list[PredictedEvent] = []
|
||||
all_gold_events: list[GoldEvent] = []
|
||||
all_pred_relations: list[PredictedRelation] = []
|
||||
all_gold_relations: list[GoldRelation] = []
|
||||
|
||||
for doc in documents:
|
||||
all_pred_events.extend(doc.predicted_events)
|
||||
all_gold_events.extend(doc.gold_events)
|
||||
all_pred_relations.extend(doc.predicted_relations)
|
||||
all_gold_relations.extend(doc.gold_relations)
|
||||
|
||||
event_report = evaluate_events_and_relations(
|
||||
predicted_events=all_pred_events,
|
||||
gold_events=all_gold_events,
|
||||
predicted_relations=all_pred_relations,
|
||||
gold_relations=all_gold_relations,
|
||||
document_count=doc_count,
|
||||
)
|
||||
|
||||
# Aggregate numeric inputs
|
||||
all_pred_numeric: list[NumericFact] = []
|
||||
all_gold_numeric: list[NumericFact] = []
|
||||
|
||||
for doc in documents:
|
||||
all_pred_numeric.extend(doc.predicted_numeric_facts)
|
||||
all_gold_numeric.extend(doc.gold_numeric_facts)
|
||||
|
||||
numeric_report = evaluate_numeric_facts(
|
||||
predicted=all_pred_numeric,
|
||||
gold=all_gold_numeric,
|
||||
document_count=doc_count,
|
||||
)
|
||||
|
||||
# Aggregate evidence inputs — concatenate source texts with separator
|
||||
all_spans: list[EvidenceSpan] = []
|
||||
all_items: list[ExtractionResult] = []
|
||||
combined_source = ""
|
||||
|
||||
for doc in documents:
|
||||
offset = len(combined_source)
|
||||
# Adjust span offsets for combined source
|
||||
for span in doc.evidence_spans:
|
||||
all_spans.append(
|
||||
EvidenceSpan(
|
||||
span_id=span.span_id,
|
||||
text=span.text,
|
||||
start_char=span.start_char + offset,
|
||||
end_char=span.end_char + offset,
|
||||
document_id=span.document_id or doc.document_id,
|
||||
)
|
||||
)
|
||||
all_items.extend(doc.extraction_results)
|
||||
combined_source += doc.source_text
|
||||
|
||||
evidence_report = evaluate_evidence(
|
||||
spans=all_spans,
|
||||
source_text=combined_source,
|
||||
items=all_items,
|
||||
)
|
||||
|
||||
# Aggregate sentiment inputs
|
||||
all_pred_sentiments: list[SentimentPrediction] = []
|
||||
all_gold_sentiments: list[SentimentPrediction] = []
|
||||
|
||||
for doc in documents:
|
||||
all_pred_sentiments.extend(doc.predicted_sentiments)
|
||||
all_gold_sentiments.extend(doc.gold_sentiments)
|
||||
|
||||
sentiment_report = evaluate_sentiment(
|
||||
predicted=all_pred_sentiments,
|
||||
gold=all_gold_sentiments,
|
||||
document_count=doc_count,
|
||||
)
|
||||
|
||||
# Aggregate resource inputs
|
||||
all_timings: list[StageTimingRecord] = []
|
||||
for doc in documents:
|
||||
all_timings.extend(doc.stage_timings)
|
||||
|
||||
resource_report = evaluate_resources(records=all_timings)
|
||||
|
||||
return GroupMetrics(
|
||||
group_name=group_name,
|
||||
document_count=doc_count,
|
||||
entity_metrics=entity_report,
|
||||
event_metrics=event_report,
|
||||
numeric_metrics=numeric_report,
|
||||
evidence_metrics=evidence_report,
|
||||
sentiment_metrics=sentiment_report,
|
||||
resource_metrics=resource_report,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_safety_gate(
|
||||
overall: GroupMetrics,
|
||||
thresholds: SafetyGateThresholds,
|
||||
) -> SafetyGateResult:
|
||||
"""Evaluate safety gate thresholds against overall metrics."""
|
||||
checks: dict[str, bool] = {}
|
||||
details: dict[str, str] = {}
|
||||
|
||||
# Entity F1
|
||||
if overall.entity_metrics:
|
||||
entity_f1 = overall.entity_metrics.entity_metrics.overall.f1
|
||||
passed_entity = entity_f1 >= thresholds.min_entity_f1
|
||||
checks["entity_f1"] = passed_entity
|
||||
details["entity_f1"] = (
|
||||
f"{entity_f1:.3f} {'≥' if passed_entity else '<'} {thresholds.min_entity_f1:.3f}"
|
||||
)
|
||||
else:
|
||||
checks["entity_f1"] = True
|
||||
details["entity_f1"] = "No entity data"
|
||||
|
||||
# Event macro-F1
|
||||
if overall.event_metrics:
|
||||
event_f1 = overall.event_metrics.event_metrics.macro_f1
|
||||
passed_event = event_f1 >= thresholds.min_event_macro_f1
|
||||
checks["event_macro_f1"] = passed_event
|
||||
details["event_macro_f1"] = (
|
||||
f"{event_f1:.3f} {'≥' if passed_event else '<'} {thresholds.min_event_macro_f1:.3f}"
|
||||
)
|
||||
else:
|
||||
checks["event_macro_f1"] = True
|
||||
details["event_macro_f1"] = "No event data"
|
||||
|
||||
# Evidence support rate
|
||||
if overall.evidence_metrics:
|
||||
support_rate = overall.evidence_metrics.support_rate
|
||||
passed_support = support_rate >= thresholds.min_evidence_support_rate
|
||||
checks["evidence_support_rate"] = passed_support
|
||||
details["evidence_support_rate"] = (
|
||||
f"{support_rate:.3f} {'≥' if passed_support else '<'} "
|
||||
f"{thresholds.min_evidence_support_rate:.3f}"
|
||||
)
|
||||
|
||||
unsupported = overall.evidence_metrics.unsupported_claim_rate
|
||||
passed_unsupported = unsupported <= thresholds.max_unsupported_claim_rate
|
||||
checks["unsupported_claim_rate"] = passed_unsupported
|
||||
details["unsupported_claim_rate"] = (
|
||||
f"{unsupported:.3f} {'≤' if passed_unsupported else '>'} "
|
||||
f"{thresholds.max_unsupported_claim_rate:.3f}"
|
||||
)
|
||||
else:
|
||||
checks["evidence_support_rate"] = True
|
||||
checks["unsupported_claim_rate"] = True
|
||||
details["evidence_support_rate"] = "No evidence data"
|
||||
details["unsupported_claim_rate"] = "No evidence data"
|
||||
|
||||
# Sentiment macro-F1
|
||||
if overall.sentiment_metrics:
|
||||
sent_f1 = overall.sentiment_metrics.f1_metrics.macro_f1
|
||||
passed_sent = sent_f1 >= thresholds.min_sentiment_macro_f1
|
||||
checks["sentiment_macro_f1"] = passed_sent
|
||||
details["sentiment_macro_f1"] = (
|
||||
f"{sent_f1:.3f} {'≥' if passed_sent else '<'} "
|
||||
f"{thresholds.min_sentiment_macro_f1:.3f}"
|
||||
)
|
||||
|
||||
ece = overall.sentiment_metrics.calibration.ece
|
||||
passed_ece = ece <= thresholds.max_calibration_ece
|
||||
checks["calibration_ece"] = passed_ece
|
||||
details["calibration_ece"] = (
|
||||
f"{ece:.3f} {'≤' if passed_ece else '>'} {thresholds.max_calibration_ece:.3f}"
|
||||
)
|
||||
else:
|
||||
checks["sentiment_macro_f1"] = True
|
||||
checks["calibration_ece"] = True
|
||||
details["sentiment_macro_f1"] = "No sentiment data"
|
||||
details["calibration_ece"] = "No sentiment data"
|
||||
|
||||
all_passed = all(checks.values())
|
||||
|
||||
return SafetyGateResult(
|
||||
passed=all_passed,
|
||||
checks=checks,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_evaluation_report(
|
||||
documents: list[DocumentResult],
|
||||
entity_match_mode: MatchMode = MatchMode.strict,
|
||||
safety_thresholds: SafetyGateThresholds | None = None,
|
||||
) -> FullEvaluationReport:
|
||||
"""Generate a full evaluation report with per-type and per-difficulty breakdowns.
|
||||
|
||||
Groups documents by document_type and difficulty, runs all metrics per group,
|
||||
and evaluates safety gate thresholds against overall results.
|
||||
|
||||
Args:
|
||||
documents: List of DocumentResult objects with all metric inputs.
|
||||
entity_match_mode: Matching strategy for entity metrics.
|
||||
safety_thresholds: Configurable safety gate thresholds (uses defaults if None).
|
||||
|
||||
Returns:
|
||||
FullEvaluationReport with overall, per-type, per-difficulty, and safety gate.
|
||||
"""
|
||||
if safety_thresholds is None:
|
||||
safety_thresholds = SafetyGateThresholds()
|
||||
|
||||
# Overall metrics
|
||||
overall = _compute_group_metrics("overall", documents, entity_match_mode)
|
||||
|
||||
# Group by document type
|
||||
by_type: dict[str, list[DocumentResult]] = defaultdict(list)
|
||||
for doc in documents:
|
||||
by_type[doc.document_type.value].append(doc)
|
||||
|
||||
per_document_type: dict[str, GroupMetrics] = {}
|
||||
for doc_type in DocumentType:
|
||||
type_docs = by_type.get(doc_type.value, [])
|
||||
if type_docs:
|
||||
per_document_type[doc_type.value] = _compute_group_metrics(
|
||||
doc_type.value, type_docs, entity_match_mode
|
||||
)
|
||||
|
||||
# Group by difficulty
|
||||
by_difficulty: dict[str, list[DocumentResult]] = defaultdict(list)
|
||||
for doc in documents:
|
||||
by_difficulty[doc.difficulty.value].append(doc)
|
||||
|
||||
per_difficulty: dict[str, GroupMetrics] = {}
|
||||
for diff in Difficulty:
|
||||
diff_docs = by_difficulty.get(diff.value, [])
|
||||
if diff_docs:
|
||||
per_difficulty[diff.value] = _compute_group_metrics(
|
||||
diff.value, diff_docs, entity_match_mode
|
||||
)
|
||||
|
||||
# Safety gate evaluation
|
||||
safety_gate = _evaluate_safety_gate(overall, safety_thresholds)
|
||||
|
||||
return FullEvaluationReport(
|
||||
overall=overall,
|
||||
per_document_type=per_document_type,
|
||||
per_difficulty=per_difficulty,
|
||||
safety_gate=safety_gate,
|
||||
total_documents=len(documents),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown Formatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_prf1_row(label: str, p: float, r: float, f1: float, support: int) -> str:
|
||||
"""Format a single PRF1 row for a markdown table."""
|
||||
return f"| {label} | {p:.3f} | {r:.3f} | {f1:.3f} | {support} |"
|
||||
|
||||
|
||||
def _format_group_section(group: GroupMetrics, heading_level: int = 3) -> str:
|
||||
"""Format a single group's metrics as markdown."""
|
||||
prefix = "#" * heading_level
|
||||
lines: list[str] = []
|
||||
lines.append(f"{prefix} {group.group_name} ({group.document_count} documents)")
|
||||
lines.append("")
|
||||
|
||||
# Entity metrics
|
||||
if group.entity_metrics:
|
||||
em = group.entity_metrics
|
||||
lines.append(f"{prefix}# Entity Metrics")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Precision | Recall | F1 | Support |")
|
||||
lines.append("|--------|-----------|--------|-----|---------|")
|
||||
o = em.entity_metrics.overall
|
||||
lines.append(_format_prf1_row("Entities (overall)", o.precision, o.recall, o.f1, o.support_gold))
|
||||
t = em.ticker_metrics.overall
|
||||
lines.append(_format_prf1_row("Tickers (overall)", t.precision, t.recall, t.f1, t.support_gold))
|
||||
lines.append("")
|
||||
lines.append(f"Ambiguity accuracy: {em.ambiguity_accuracy.accuracy:.3f}")
|
||||
lines.append("")
|
||||
|
||||
# Event metrics
|
||||
if group.event_metrics:
|
||||
ev = group.event_metrics
|
||||
lines.append(f"{prefix}# Event & Relation Metrics")
|
||||
lines.append("")
|
||||
lines.append(f"- Event macro-F1: {ev.event_metrics.macro_f1:.3f}")
|
||||
micro = ev.event_metrics.micro
|
||||
lines.append(f"- Event micro-F1: {micro.f1:.3f} (P={micro.precision:.3f}, R={micro.recall:.3f})")
|
||||
lines.append(f"- Relation macro-F1: {ev.relation_metrics.macro_f1:.3f}")
|
||||
r_micro = ev.relation_metrics.micro
|
||||
lines.append(f"- Relation micro-F1: {r_micro.f1:.3f} (P={r_micro.precision:.3f}, R={r_micro.recall:.3f})")
|
||||
lines.append("")
|
||||
|
||||
# Numeric metrics
|
||||
if group.numeric_metrics:
|
||||
nm = group.numeric_metrics
|
||||
lines.append(f"{prefix}# Numeric Metrics")
|
||||
lines.append("")
|
||||
lines.append(f"- Exact match accuracy: {nm.exact_match_accuracy.accuracy:.3f} ({nm.exact_match_accuracy.matches}/{nm.exact_match_accuracy.total})")
|
||||
lines.append(f"- Tolerance accuracy ({nm.tolerance_pct_used}%): {nm.tolerance_accuracy.accuracy:.3f} ({nm.tolerance_accuracy.matches}/{nm.tolerance_accuracy.total})")
|
||||
lines.append(f"- Unit consistency: {nm.unit_consistency.accuracy:.3f}")
|
||||
lines.append(f"- Period match: {nm.period_match.accuracy:.3f}")
|
||||
lines.append("")
|
||||
|
||||
# Evidence metrics
|
||||
if group.evidence_metrics:
|
||||
ev = group.evidence_metrics
|
||||
lines.append(f"{prefix}# Evidence Metrics")
|
||||
lines.append("")
|
||||
lines.append(f"- Offset validity rate: {ev.validity_rate:.3f} ({ev.valid_spans}/{ev.total_spans})")
|
||||
lines.append(f"- Support rate: {ev.support_rate:.3f} ({ev.supported_items}/{ev.total_items})")
|
||||
lines.append(f"- Coverage score: {ev.coverage_score:.3f}")
|
||||
lines.append(f"- Orphan rate: {ev.orphan_rate:.3f} ({ev.orphan_spans} orphans)")
|
||||
lines.append(f"- Unsupported claim rate: {ev.unsupported_claim_rate:.3f}")
|
||||
lines.append("")
|
||||
|
||||
# Sentiment metrics
|
||||
if group.sentiment_metrics:
|
||||
sm = group.sentiment_metrics
|
||||
lines.append(f"{prefix}# Sentiment Metrics")
|
||||
lines.append("")
|
||||
lines.append(f"- Macro-F1: {sm.f1_metrics.macro_f1:.3f}")
|
||||
lines.append(f"- Micro-F1: {sm.f1_metrics.micro_f1:.3f}")
|
||||
lines.append(f"- Direction accuracy: {sm.direction_accuracy.accuracy:.3f}")
|
||||
lines.append(f"- Calibration ECE: {sm.calibration.ece:.3f}")
|
||||
lines.append(f"- Brier score: {sm.calibration.brier_score:.3f}")
|
||||
lines.append("")
|
||||
|
||||
# Resource metrics
|
||||
if group.resource_metrics:
|
||||
rm = group.resource_metrics
|
||||
lines.append(f"{prefix}# Resource Metrics")
|
||||
lines.append("")
|
||||
lines.append(f"- Latency p50: {rm.latency.p50:.2f}s, p95: {rm.latency.p95:.2f}s, p99: {rm.latency.p99:.2f}s")
|
||||
lines.append(f"- Throughput: {rm.throughput.documents_per_minute:.1f} docs/min")
|
||||
lines.append(f"- Total tokens: {rm.token_usage.total_tokens}")
|
||||
lines.append(f"- CPU: {rm.cpu.total_cpu_seconds:.1f}s total, {rm.cpu.mean_cpu_seconds_per_document:.2f}s/doc")
|
||||
lines.append(f"- GPU: {rm.gpu.total_gpu_seconds:.1f}s total, peak {rm.gpu.peak_gpu_memory_mb:.0f} MB")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_report_markdown(report: FullEvaluationReport) -> str:
|
||||
"""Produce a readable markdown summary of the full evaluation report.
|
||||
|
||||
Args:
|
||||
report: The complete evaluation report.
|
||||
|
||||
Returns:
|
||||
Markdown-formatted string with all sections.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("# Intelligence Pipeline v3 — Evaluation Report")
|
||||
lines.append("")
|
||||
lines.append(f"**Total documents evaluated:** {report.total_documents}")
|
||||
lines.append("")
|
||||
|
||||
# Safety gate summary
|
||||
lines.append("## Safety Gate")
|
||||
lines.append("")
|
||||
gate = report.safety_gate
|
||||
status = "✅ PASSED" if gate.passed else "❌ FAILED"
|
||||
lines.append(f"**Status:** {status}")
|
||||
lines.append("")
|
||||
lines.append("| Check | Result | Details |")
|
||||
lines.append("|-------|--------|---------|")
|
||||
for check_name, passed in gate.checks.items():
|
||||
icon = "✅" if passed else "❌"
|
||||
detail = gate.details.get(check_name, "")
|
||||
lines.append(f"| {check_name} | {icon} | {detail} |")
|
||||
lines.append("")
|
||||
|
||||
# Overall metrics
|
||||
lines.append("## Overall Metrics")
|
||||
lines.append("")
|
||||
lines.append(_format_group_section(report.overall, heading_level=3))
|
||||
|
||||
# Per document type
|
||||
if report.per_document_type:
|
||||
lines.append("## Per Document Type")
|
||||
lines.append("")
|
||||
for doc_type, group in sorted(report.per_document_type.items()):
|
||||
lines.append(_format_group_section(group, heading_level=3))
|
||||
|
||||
# Per difficulty
|
||||
if report.per_difficulty:
|
||||
lines.append("## Per Difficulty")
|
||||
lines.append("")
|
||||
for diff, group in sorted(report.per_difficulty.items()):
|
||||
lines.append(_format_group_section(group, heading_level=3))
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,660 @@
|
||||
"""Latency, throughput, token, CPU, GPU, and memory resource metrics.
|
||||
|
||||
Implements evaluation metrics for pipeline resource consumption and efficiency.
|
||||
Supports per-document and per-stage breakdowns with percentile calculations.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageTimingRecord:
|
||||
"""A single stage execution record with resource measurements.
|
||||
|
||||
Captures timing, token usage, and hardware resource consumption
|
||||
for one processing stage of one document.
|
||||
"""
|
||||
|
||||
document_id: str
|
||||
stage_name: str
|
||||
start_time: float # Unix timestamp (seconds)
|
||||
end_time: float # Unix timestamp (seconds)
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
gpu_memory_mb: float = 0.0
|
||||
cpu_seconds: float = 0.0
|
||||
gpu_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
"""Wall-clock duration of this stage in seconds."""
|
||||
return self.end_time - self.start_time
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
"""Sum of input and output tokens."""
|
||||
return self.input_tokens + self.output_tokens
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Percentile Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_percentile(values: list[float], percentile: float) -> float:
|
||||
"""Compute a percentile from a sorted list without numpy.
|
||||
|
||||
Uses linear interpolation between nearest ranks.
|
||||
|
||||
Args:
|
||||
values: List of numeric values (need not be pre-sorted).
|
||||
percentile: Percentile to compute (0-100).
|
||||
|
||||
Returns:
|
||||
The interpolated percentile value.
|
||||
|
||||
Raises:
|
||||
ValueError: If values is empty or percentile is out of range.
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("Cannot compute percentile of empty list")
|
||||
if not (0.0 <= percentile <= 100.0):
|
||||
raise ValueError(f"Percentile must be between 0 and 100, got {percentile}")
|
||||
|
||||
sorted_values = sorted(values)
|
||||
n = len(sorted_values)
|
||||
|
||||
if n == 1:
|
||||
return sorted_values[0]
|
||||
|
||||
# Compute the rank (0-indexed fractional position)
|
||||
rank = (percentile / 100.0) * (n - 1)
|
||||
lower_idx = int(rank)
|
||||
upper_idx = lower_idx + 1
|
||||
fraction = rank - lower_idx
|
||||
|
||||
if upper_idx >= n:
|
||||
return sorted_values[-1]
|
||||
|
||||
return sorted_values[lower_idx] + fraction * (
|
||||
sorted_values[upper_idx] - sorted_values[lower_idx]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LatencyPercentiles(BaseModel):
|
||||
"""Latency percentile distribution in seconds."""
|
||||
|
||||
p50: float = Field(ge=0.0)
|
||||
p90: float = Field(ge=0.0)
|
||||
p95: float = Field(ge=0.0)
|
||||
p99: float = Field(ge=0.0)
|
||||
mean: float = Field(ge=0.0)
|
||||
max: float = Field(ge=0.0)
|
||||
min: float = Field(ge=0.0)
|
||||
count: int = Field(ge=0)
|
||||
|
||||
|
||||
class ThroughputMetrics(BaseModel):
|
||||
"""Document throughput measurements."""
|
||||
|
||||
documents_per_minute: float = Field(ge=0.0)
|
||||
documents_per_hour: float = Field(ge=0.0)
|
||||
total_documents: int = Field(ge=0)
|
||||
total_wall_seconds: float = Field(ge=0.0)
|
||||
|
||||
|
||||
class TokenUsageMetrics(BaseModel):
|
||||
"""Token consumption statistics."""
|
||||
|
||||
total_input_tokens: int = Field(ge=0)
|
||||
total_output_tokens: int = Field(ge=0)
|
||||
total_tokens: int = Field(ge=0)
|
||||
mean_input_tokens_per_document: float = Field(ge=0.0)
|
||||
mean_output_tokens_per_document: float = Field(ge=0.0)
|
||||
mean_total_tokens_per_document: float = Field(ge=0.0)
|
||||
per_stage: dict[str, "StageTokenUsage"] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StageTokenUsage(BaseModel):
|
||||
"""Token usage breakdown for a single stage."""
|
||||
|
||||
total_input_tokens: int = Field(ge=0)
|
||||
total_output_tokens: int = Field(ge=0)
|
||||
total_tokens: int = Field(ge=0)
|
||||
mean_input_tokens: float = Field(ge=0.0)
|
||||
mean_output_tokens: float = Field(ge=0.0)
|
||||
mean_total_tokens: float = Field(ge=0.0)
|
||||
count: int = Field(ge=0)
|
||||
|
||||
|
||||
class CpuMetrics(BaseModel):
|
||||
"""CPU resource consumption metrics."""
|
||||
|
||||
total_cpu_seconds: float = Field(ge=0.0)
|
||||
mean_cpu_seconds_per_document: float = Field(ge=0.0)
|
||||
peak_cpu_seconds: float = Field(ge=0.0, description="Max CPU-seconds for a single document")
|
||||
|
||||
|
||||
class GpuMetrics(BaseModel):
|
||||
"""GPU resource consumption metrics."""
|
||||
|
||||
total_gpu_seconds: float = Field(ge=0.0)
|
||||
mean_gpu_seconds_per_document: float = Field(ge=0.0)
|
||||
peak_gpu_memory_mb: float = Field(ge=0.0)
|
||||
mean_gpu_memory_mb: float = Field(ge=0.0)
|
||||
gpu_utilization_percent: float = Field(
|
||||
ge=0.0, le=100.0,
|
||||
description="Percentage of total wall time spent on GPU",
|
||||
)
|
||||
|
||||
|
||||
class MemoryMetrics(BaseModel):
|
||||
"""Memory consumption metrics."""
|
||||
|
||||
peak_rss_memory_mb: float = Field(ge=0.0)
|
||||
mean_working_set_mb: float = Field(ge=0.0)
|
||||
|
||||
|
||||
class EfficiencyMetrics(BaseModel):
|
||||
"""Efficiency ratio metrics."""
|
||||
|
||||
tokens_per_second: float = Field(ge=0.0)
|
||||
documents_per_gpu_second: float = Field(ge=0.0)
|
||||
fast_path_cpu_seconds: float = Field(ge=0.0)
|
||||
adjudication_cpu_seconds: float = Field(ge=0.0)
|
||||
fast_path_gpu_seconds: float = Field(ge=0.0)
|
||||
adjudication_gpu_seconds: float = Field(ge=0.0)
|
||||
fast_path_fraction: float = Field(
|
||||
ge=0.0, le=1.0,
|
||||
description="Fraction of total resource usage from fast-path stages",
|
||||
)
|
||||
adjudication_fraction: float = Field(
|
||||
ge=0.0, le=1.0,
|
||||
description="Fraction of total resource usage from adjudication stages",
|
||||
)
|
||||
|
||||
|
||||
class StageLatencyBreakdown(BaseModel):
|
||||
"""Per-stage latency statistics."""
|
||||
|
||||
stage_name: str
|
||||
latency: LatencyPercentiles
|
||||
invocation_count: int = Field(ge=0)
|
||||
|
||||
|
||||
class ResourceEvaluationReport(BaseModel):
|
||||
"""Complete resource evaluation report."""
|
||||
|
||||
latency: LatencyPercentiles
|
||||
per_stage_latency: list[StageLatencyBreakdown] = Field(default_factory=list)
|
||||
throughput: ThroughputMetrics
|
||||
token_usage: TokenUsageMetrics
|
||||
cpu: CpuMetrics
|
||||
gpu: GpuMetrics
|
||||
memory: MemoryMetrics
|
||||
efficiency: EfficiencyMetrics
|
||||
document_count: int = Field(ge=0)
|
||||
|
||||
|
||||
# Rebuild model to resolve forward references
|
||||
TokenUsageMetrics.model_rebuild()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Computation Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Stages considered as "adjudication" for resource split calculations
|
||||
ADJUDICATION_STAGES: frozenset[str] = frozenset({
|
||||
"adjudication",
|
||||
"adjudicator",
|
||||
"9b_adjudication",
|
||||
"semantic_adjudication",
|
||||
})
|
||||
|
||||
|
||||
def _is_adjudication_stage(stage_name: str) -> bool:
|
||||
"""Determine if a stage belongs to the adjudication path."""
|
||||
lower = stage_name.lower()
|
||||
return lower in ADJUDICATION_STAGES or "adjudicat" in lower
|
||||
|
||||
|
||||
def _compute_latency_percentiles(durations: list[float]) -> LatencyPercentiles:
|
||||
"""Compute latency percentile distribution from a list of durations."""
|
||||
if not durations:
|
||||
return LatencyPercentiles(
|
||||
p50=0.0, p90=0.0, p95=0.0, p99=0.0,
|
||||
mean=0.0, max=0.0, min=0.0, count=0,
|
||||
)
|
||||
|
||||
return LatencyPercentiles(
|
||||
p50=compute_percentile(durations, 50.0),
|
||||
p90=compute_percentile(durations, 90.0),
|
||||
p95=compute_percentile(durations, 95.0),
|
||||
p99=compute_percentile(durations, 99.0),
|
||||
mean=sum(durations) / len(durations),
|
||||
max=max(durations),
|
||||
min=min(durations),
|
||||
count=len(durations),
|
||||
)
|
||||
|
||||
|
||||
def _compute_document_durations(
|
||||
records: list[StageTimingRecord],
|
||||
) -> dict[str, float]:
|
||||
"""Compute total wall-clock duration per document.
|
||||
|
||||
Uses min(start_time) to max(end_time) for each document to handle
|
||||
overlapping/parallel stages.
|
||||
"""
|
||||
doc_times: dict[str, tuple[float, float]] = {}
|
||||
for r in records:
|
||||
if r.document_id not in doc_times:
|
||||
doc_times[r.document_id] = (r.start_time, r.end_time)
|
||||
else:
|
||||
existing = doc_times[r.document_id]
|
||||
doc_times[r.document_id] = (
|
||||
min(existing[0], r.start_time),
|
||||
max(existing[1], r.end_time),
|
||||
)
|
||||
return {doc_id: end - start for doc_id, (start, end) in doc_times.items()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_latency_metrics(
|
||||
records: list[StageTimingRecord],
|
||||
) -> tuple[LatencyPercentiles, list[StageLatencyBreakdown]]:
|
||||
"""Compute per-document and per-stage latency metrics.
|
||||
|
||||
Per-document latency is the wall-clock time from the earliest stage
|
||||
start to the latest stage end for each document.
|
||||
|
||||
Args:
|
||||
records: Stage timing records.
|
||||
|
||||
Returns:
|
||||
Tuple of (overall document latency, per-stage breakdown).
|
||||
"""
|
||||
if not records:
|
||||
return (
|
||||
LatencyPercentiles(
|
||||
p50=0.0, p90=0.0, p95=0.0, p99=0.0,
|
||||
mean=0.0, max=0.0, min=0.0, count=0,
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
# Per-document latency
|
||||
doc_durations = _compute_document_durations(records)
|
||||
overall = _compute_latency_percentiles(list(doc_durations.values()))
|
||||
|
||||
# Per-stage latency
|
||||
stage_durations: dict[str, list[float]] = {}
|
||||
for r in records:
|
||||
stage_durations.setdefault(r.stage_name, []).append(r.duration_seconds)
|
||||
|
||||
per_stage = [
|
||||
StageLatencyBreakdown(
|
||||
stage_name=stage,
|
||||
latency=_compute_latency_percentiles(durations),
|
||||
invocation_count=len(durations),
|
||||
)
|
||||
for stage, durations in sorted(stage_durations.items())
|
||||
]
|
||||
|
||||
return overall, per_stage
|
||||
|
||||
|
||||
def compute_throughput_metrics(
|
||||
records: list[StageTimingRecord],
|
||||
) -> ThroughputMetrics:
|
||||
"""Compute document throughput from timing records.
|
||||
|
||||
Args:
|
||||
records: Stage timing records.
|
||||
|
||||
Returns:
|
||||
ThroughputMetrics with documents/minute and documents/hour.
|
||||
"""
|
||||
if not records:
|
||||
return ThroughputMetrics(
|
||||
documents_per_minute=0.0,
|
||||
documents_per_hour=0.0,
|
||||
total_documents=0,
|
||||
total_wall_seconds=0.0,
|
||||
)
|
||||
|
||||
doc_ids = {r.document_id for r in records}
|
||||
total_docs = len(doc_ids)
|
||||
|
||||
# Total wall time: earliest start to latest end across all records
|
||||
earliest = min(r.start_time for r in records)
|
||||
latest = max(r.end_time for r in records)
|
||||
total_wall = latest - earliest
|
||||
|
||||
if total_wall <= 0.0:
|
||||
return ThroughputMetrics(
|
||||
documents_per_minute=0.0,
|
||||
documents_per_hour=0.0,
|
||||
total_documents=total_docs,
|
||||
total_wall_seconds=0.0,
|
||||
)
|
||||
|
||||
docs_per_second = total_docs / total_wall
|
||||
|
||||
return ThroughputMetrics(
|
||||
documents_per_minute=docs_per_second * 60.0,
|
||||
documents_per_hour=docs_per_second * 3600.0,
|
||||
total_documents=total_docs,
|
||||
total_wall_seconds=total_wall,
|
||||
)
|
||||
|
||||
|
||||
def compute_token_usage_metrics(
|
||||
records: list[StageTimingRecord],
|
||||
) -> TokenUsageMetrics:
|
||||
"""Compute token usage statistics per document and per stage.
|
||||
|
||||
Args:
|
||||
records: Stage timing records.
|
||||
|
||||
Returns:
|
||||
TokenUsageMetrics with aggregate and per-stage breakdowns.
|
||||
"""
|
||||
if not records:
|
||||
return TokenUsageMetrics(
|
||||
total_input_tokens=0,
|
||||
total_output_tokens=0,
|
||||
total_tokens=0,
|
||||
mean_input_tokens_per_document=0.0,
|
||||
mean_output_tokens_per_document=0.0,
|
||||
mean_total_tokens_per_document=0.0,
|
||||
per_stage={},
|
||||
)
|
||||
|
||||
total_input = sum(r.input_tokens for r in records)
|
||||
total_output = sum(r.output_tokens for r in records)
|
||||
total = total_input + total_output
|
||||
|
||||
doc_ids = {r.document_id for r in records}
|
||||
n_docs = len(doc_ids)
|
||||
|
||||
# Per-stage breakdown
|
||||
stage_records: dict[str, list[StageTimingRecord]] = {}
|
||||
for r in records:
|
||||
stage_records.setdefault(r.stage_name, []).append(r)
|
||||
|
||||
per_stage: dict[str, StageTokenUsage] = {}
|
||||
for stage, stage_recs in sorted(stage_records.items()):
|
||||
s_input = sum(r.input_tokens for r in stage_recs)
|
||||
s_output = sum(r.output_tokens for r in stage_recs)
|
||||
s_total = s_input + s_output
|
||||
count = len(stage_recs)
|
||||
per_stage[stage] = StageTokenUsage(
|
||||
total_input_tokens=s_input,
|
||||
total_output_tokens=s_output,
|
||||
total_tokens=s_total,
|
||||
mean_input_tokens=s_input / count if count > 0 else 0.0,
|
||||
mean_output_tokens=s_output / count if count > 0 else 0.0,
|
||||
mean_total_tokens=s_total / count if count > 0 else 0.0,
|
||||
count=count,
|
||||
)
|
||||
|
||||
return TokenUsageMetrics(
|
||||
total_input_tokens=total_input,
|
||||
total_output_tokens=total_output,
|
||||
total_tokens=total,
|
||||
mean_input_tokens_per_document=total_input / n_docs if n_docs > 0 else 0.0,
|
||||
mean_output_tokens_per_document=total_output / n_docs if n_docs > 0 else 0.0,
|
||||
mean_total_tokens_per_document=total / n_docs if n_docs > 0 else 0.0,
|
||||
per_stage=per_stage,
|
||||
)
|
||||
|
||||
|
||||
def compute_cpu_metrics(
|
||||
records: list[StageTimingRecord],
|
||||
) -> CpuMetrics:
|
||||
"""Compute CPU resource consumption metrics.
|
||||
|
||||
Args:
|
||||
records: Stage timing records.
|
||||
|
||||
Returns:
|
||||
CpuMetrics with totals and per-document statistics.
|
||||
"""
|
||||
if not records:
|
||||
return CpuMetrics(
|
||||
total_cpu_seconds=0.0,
|
||||
mean_cpu_seconds_per_document=0.0,
|
||||
peak_cpu_seconds=0.0,
|
||||
)
|
||||
|
||||
total_cpu = sum(r.cpu_seconds for r in records)
|
||||
|
||||
# Per-document CPU totals
|
||||
doc_cpu: dict[str, float] = {}
|
||||
for r in records:
|
||||
doc_cpu[r.document_id] = doc_cpu.get(r.document_id, 0.0) + r.cpu_seconds
|
||||
|
||||
n_docs = len(doc_cpu)
|
||||
peak = max(doc_cpu.values()) if doc_cpu else 0.0
|
||||
|
||||
return CpuMetrics(
|
||||
total_cpu_seconds=total_cpu,
|
||||
mean_cpu_seconds_per_document=total_cpu / n_docs if n_docs > 0 else 0.0,
|
||||
peak_cpu_seconds=peak,
|
||||
)
|
||||
|
||||
|
||||
def compute_gpu_metrics(
|
||||
records: list[StageTimingRecord],
|
||||
) -> GpuMetrics:
|
||||
"""Compute GPU resource consumption metrics.
|
||||
|
||||
Args:
|
||||
records: Stage timing records.
|
||||
|
||||
Returns:
|
||||
GpuMetrics with totals, peaks, and utilization percentage.
|
||||
"""
|
||||
if not records:
|
||||
return GpuMetrics(
|
||||
total_gpu_seconds=0.0,
|
||||
mean_gpu_seconds_per_document=0.0,
|
||||
peak_gpu_memory_mb=0.0,
|
||||
mean_gpu_memory_mb=0.0,
|
||||
gpu_utilization_percent=0.0,
|
||||
)
|
||||
|
||||
total_gpu = sum(r.gpu_seconds for r in records)
|
||||
|
||||
# Per-document GPU totals
|
||||
doc_gpu: dict[str, float] = {}
|
||||
for r in records:
|
||||
doc_gpu[r.document_id] = doc_gpu.get(r.document_id, 0.0) + r.gpu_seconds
|
||||
|
||||
n_docs = len(doc_gpu)
|
||||
|
||||
# GPU memory stats
|
||||
gpu_mem_values = [r.gpu_memory_mb for r in records if r.gpu_memory_mb > 0.0]
|
||||
peak_gpu_mem = max(gpu_mem_values) if gpu_mem_values else 0.0
|
||||
mean_gpu_mem = (
|
||||
sum(gpu_mem_values) / len(gpu_mem_values) if gpu_mem_values else 0.0
|
||||
)
|
||||
|
||||
# GPU utilization: fraction of wall time spent on GPU work
|
||||
earliest = min(r.start_time for r in records)
|
||||
latest = max(r.end_time for r in records)
|
||||
total_wall = latest - earliest
|
||||
|
||||
utilization = (
|
||||
(total_gpu / total_wall) * 100.0 if total_wall > 0.0 else 0.0
|
||||
)
|
||||
# Cap at 100% (parallel GPU stages could theoretically exceed wall time)
|
||||
utilization = min(utilization, 100.0)
|
||||
|
||||
return GpuMetrics(
|
||||
total_gpu_seconds=total_gpu,
|
||||
mean_gpu_seconds_per_document=total_gpu / n_docs if n_docs > 0 else 0.0,
|
||||
peak_gpu_memory_mb=peak_gpu_mem,
|
||||
mean_gpu_memory_mb=mean_gpu_mem,
|
||||
gpu_utilization_percent=utilization,
|
||||
)
|
||||
|
||||
|
||||
def compute_memory_metrics(
|
||||
records: list[StageTimingRecord],
|
||||
rss_samples_mb: list[float] | None = None,
|
||||
) -> MemoryMetrics:
|
||||
"""Compute memory consumption metrics.
|
||||
|
||||
Uses gpu_memory_mb as a proxy for working set if no explicit RSS
|
||||
samples are provided. When rss_samples_mb is given, it takes
|
||||
precedence for peak and mean calculations.
|
||||
|
||||
Args:
|
||||
records: Stage timing records.
|
||||
rss_samples_mb: Optional explicit RSS memory samples in MB.
|
||||
|
||||
Returns:
|
||||
MemoryMetrics with peak and mean working set.
|
||||
"""
|
||||
if rss_samples_mb:
|
||||
return MemoryMetrics(
|
||||
peak_rss_memory_mb=max(rss_samples_mb),
|
||||
mean_working_set_mb=sum(rss_samples_mb) / len(rss_samples_mb),
|
||||
)
|
||||
|
||||
if not records:
|
||||
return MemoryMetrics(peak_rss_memory_mb=0.0, mean_working_set_mb=0.0)
|
||||
|
||||
# Use gpu_memory_mb as working set proxy
|
||||
mem_values = [r.gpu_memory_mb for r in records if r.gpu_memory_mb > 0.0]
|
||||
if not mem_values:
|
||||
return MemoryMetrics(peak_rss_memory_mb=0.0, mean_working_set_mb=0.0)
|
||||
|
||||
return MemoryMetrics(
|
||||
peak_rss_memory_mb=max(mem_values),
|
||||
mean_working_set_mb=sum(mem_values) / len(mem_values),
|
||||
)
|
||||
|
||||
|
||||
def compute_efficiency_metrics(
|
||||
records: list[StageTimingRecord],
|
||||
) -> EfficiencyMetrics:
|
||||
"""Compute efficiency ratios including tokens/second and resource splits.
|
||||
|
||||
Fast-path vs adjudication split is determined by stage name matching.
|
||||
|
||||
Args:
|
||||
records: Stage timing records.
|
||||
|
||||
Returns:
|
||||
EfficiencyMetrics with ratios and resource splits.
|
||||
"""
|
||||
if not records:
|
||||
return EfficiencyMetrics(
|
||||
tokens_per_second=0.0,
|
||||
documents_per_gpu_second=0.0,
|
||||
fast_path_cpu_seconds=0.0,
|
||||
adjudication_cpu_seconds=0.0,
|
||||
fast_path_gpu_seconds=0.0,
|
||||
adjudication_gpu_seconds=0.0,
|
||||
fast_path_fraction=0.0,
|
||||
adjudication_fraction=0.0,
|
||||
)
|
||||
|
||||
total_tokens = sum(r.total_tokens for r in records)
|
||||
total_wall = max(r.end_time for r in records) - min(r.start_time for r in records)
|
||||
total_gpu = sum(r.gpu_seconds for r in records)
|
||||
n_docs = len({r.document_id for r in records})
|
||||
|
||||
tokens_per_second = total_tokens / total_wall if total_wall > 0.0 else 0.0
|
||||
docs_per_gpu_second = n_docs / total_gpu if total_gpu > 0.0 else 0.0
|
||||
|
||||
# Resource split
|
||||
fast_cpu = 0.0
|
||||
adj_cpu = 0.0
|
||||
fast_gpu = 0.0
|
||||
adj_gpu = 0.0
|
||||
|
||||
for r in records:
|
||||
if _is_adjudication_stage(r.stage_name):
|
||||
adj_cpu += r.cpu_seconds
|
||||
adj_gpu += r.gpu_seconds
|
||||
else:
|
||||
fast_cpu += r.cpu_seconds
|
||||
fast_gpu += r.gpu_seconds
|
||||
|
||||
total_resource = fast_cpu + adj_cpu + fast_gpu + adj_gpu
|
||||
fast_total = fast_cpu + fast_gpu
|
||||
adj_total = adj_cpu + adj_gpu
|
||||
|
||||
fast_fraction = fast_total / total_resource if total_resource > 0.0 else 0.0
|
||||
adj_fraction = adj_total / total_resource if total_resource > 0.0 else 0.0
|
||||
|
||||
return EfficiencyMetrics(
|
||||
tokens_per_second=tokens_per_second,
|
||||
documents_per_gpu_second=docs_per_gpu_second,
|
||||
fast_path_cpu_seconds=fast_cpu,
|
||||
adjudication_cpu_seconds=adj_cpu,
|
||||
fast_path_gpu_seconds=fast_gpu,
|
||||
adjudication_gpu_seconds=adj_gpu,
|
||||
fast_path_fraction=fast_fraction,
|
||||
adjudication_fraction=adj_fraction,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_resources(
|
||||
records: list[StageTimingRecord],
|
||||
rss_samples_mb: list[float] | None = None,
|
||||
) -> ResourceEvaluationReport:
|
||||
"""Run full resource evaluation producing a complete report.
|
||||
|
||||
Args:
|
||||
records: List of stage timing records from pipeline execution.
|
||||
rss_samples_mb: Optional explicit RSS memory samples.
|
||||
|
||||
Returns:
|
||||
ResourceEvaluationReport with all resource metrics.
|
||||
"""
|
||||
latency, per_stage_latency = compute_latency_metrics(records)
|
||||
throughput = compute_throughput_metrics(records)
|
||||
token_usage = compute_token_usage_metrics(records)
|
||||
cpu = compute_cpu_metrics(records)
|
||||
gpu = compute_gpu_metrics(records)
|
||||
memory = compute_memory_metrics(records, rss_samples_mb)
|
||||
efficiency = compute_efficiency_metrics(records)
|
||||
|
||||
doc_count = len({r.document_id for r in records}) if records else 0
|
||||
|
||||
return ResourceEvaluationReport(
|
||||
latency=latency,
|
||||
per_stage_latency=per_stage_latency,
|
||||
throughput=throughput,
|
||||
token_usage=token_usage,
|
||||
cpu=cpu,
|
||||
gpu=gpu,
|
||||
memory=memory,
|
||||
efficiency=efficiency,
|
||||
document_count=doc_count,
|
||||
)
|
||||
@@ -0,0 +1,443 @@
|
||||
"""Sentiment macro-F1, micro-F1, direction accuracy, and probability calibration metrics.
|
||||
|
||||
Implements evaluation metrics for company-specific sentiment extraction quality
|
||||
and probability calibration against a gold standard corpus. Includes Expected
|
||||
Calibration Error (ECE), Brier score, and reliability diagram data.
|
||||
|
||||
Sentiments are matched by company_entity_id between predicted and gold sets.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SENTIMENT_LABELS = ("positive", "negative", "neutral", "mixed")
|
||||
|
||||
|
||||
class SentimentLabel(str, Enum):
|
||||
"""Supported sentiment labels."""
|
||||
|
||||
positive = "positive"
|
||||
negative = "negative"
|
||||
neutral = "neutral"
|
||||
mixed = "mixed"
|
||||
|
||||
|
||||
class SentimentPrediction(BaseModel):
|
||||
"""A predicted or gold sentiment for a specific company entity."""
|
||||
|
||||
company_entity_id: str
|
||||
label: SentimentLabel
|
||||
positive_prob: float = Field(ge=0.0, le=1.0, default=0.0)
|
||||
negative_prob: float = Field(ge=0.0, le=1.0, default=0.0)
|
||||
neutral_prob: float = Field(ge=0.0, le=1.0, default=0.0)
|
||||
mixed_prob: float = Field(ge=0.0, le=1.0, default=0.0)
|
||||
document_id: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LabelF1(BaseModel):
|
||||
"""Per-label precision, recall, F1."""
|
||||
|
||||
label: str
|
||||
precision: float = Field(ge=0.0, le=1.0)
|
||||
recall: float = Field(ge=0.0, le=1.0)
|
||||
f1: float = Field(ge=0.0, le=1.0)
|
||||
support_predicted: int = Field(ge=0)
|
||||
support_gold: int = Field(ge=0)
|
||||
|
||||
|
||||
class SentimentF1Result(BaseModel):
|
||||
"""Sentiment classification F1 metrics."""
|
||||
|
||||
macro_f1: float = Field(ge=0.0, le=1.0)
|
||||
micro_f1: float = Field(ge=0.0, le=1.0)
|
||||
per_label: dict[str, LabelF1]
|
||||
support: int = Field(ge=0)
|
||||
|
||||
|
||||
class DirectionAccuracyResult(BaseModel):
|
||||
"""Binary direction accuracy (positive vs negative, ignoring neutral/mixed)."""
|
||||
|
||||
accuracy: float = Field(ge=0.0, le=1.0)
|
||||
correct: int = Field(ge=0)
|
||||
total: int = Field(ge=0)
|
||||
|
||||
|
||||
class CalibrationBin(BaseModel):
|
||||
"""A single bin in the reliability diagram."""
|
||||
|
||||
bin_lower: float = Field(ge=0.0, le=1.0)
|
||||
bin_upper: float = Field(ge=0.0, le=1.0)
|
||||
mean_predicted_prob: float = Field(ge=0.0, le=1.0)
|
||||
fraction_positive: float = Field(ge=0.0, le=1.0)
|
||||
count: int = Field(ge=0)
|
||||
|
||||
|
||||
class CalibrationResult(BaseModel):
|
||||
"""Probability calibration metrics."""
|
||||
|
||||
ece: float = Field(ge=0.0, le=1.0, description="Expected Calibration Error")
|
||||
brier_score: float = Field(ge=0.0, description="Brier score (mean squared error)")
|
||||
reliability_bins: list[CalibrationBin]
|
||||
n_samples: int = Field(ge=0)
|
||||
|
||||
|
||||
class SentimentEvaluationReport(BaseModel):
|
||||
"""Complete sentiment evaluation report."""
|
||||
|
||||
f1_metrics: SentimentF1Result
|
||||
direction_accuracy: DirectionAccuracyResult
|
||||
calibration: CalibrationResult
|
||||
document_count: int = Field(ge=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core Metric Computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_label_f1(
|
||||
predicted_labels: list[str],
|
||||
gold_labels: list[str],
|
||||
label: str,
|
||||
) -> LabelF1:
|
||||
"""Compute precision, recall, F1 for a single label (one-vs-rest)."""
|
||||
tp = 0
|
||||
fp = 0
|
||||
fn = 0
|
||||
|
||||
for pred, gold in zip(predicted_labels, gold_labels):
|
||||
if pred == label and gold == label:
|
||||
tp += 1
|
||||
elif pred == label and gold != label:
|
||||
fp += 1
|
||||
elif pred != label and gold == label:
|
||||
fn += 1
|
||||
|
||||
support_predicted = tp + fp
|
||||
support_gold = tp + fn
|
||||
|
||||
precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
|
||||
recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0
|
||||
|
||||
if precision + recall > 0:
|
||||
f1 = 2 * precision * recall / (precision + recall)
|
||||
else:
|
||||
f1 = 0.0
|
||||
|
||||
return LabelF1(
|
||||
label=label,
|
||||
precision=precision,
|
||||
recall=recall,
|
||||
f1=f1,
|
||||
support_predicted=support_predicted,
|
||||
support_gold=support_gold,
|
||||
)
|
||||
|
||||
|
||||
def compute_sentiment_f1(
|
||||
predicted: list[SentimentPrediction],
|
||||
gold: list[SentimentPrediction],
|
||||
) -> SentimentF1Result:
|
||||
"""Compute macro-F1, micro-F1, and per-label F1 for sentiment classification.
|
||||
|
||||
Matches predictions to gold by company_entity_id. Only matched pairs are
|
||||
evaluated (unmatched predictions/gold are ignored).
|
||||
|
||||
Args:
|
||||
predicted: Predicted sentiment labels with probabilities.
|
||||
gold: Gold standard sentiment labels.
|
||||
|
||||
Returns:
|
||||
SentimentF1Result with macro-F1, micro-F1, and per-label breakdown.
|
||||
"""
|
||||
# Match by company_entity_id
|
||||
gold_by_id = {g.company_entity_id: g for g in gold}
|
||||
matched_pred_labels: list[str] = []
|
||||
matched_gold_labels: list[str] = []
|
||||
|
||||
for p in predicted:
|
||||
if p.company_entity_id in gold_by_id:
|
||||
matched_pred_labels.append(p.label.value)
|
||||
matched_gold_labels.append(gold_by_id[p.company_entity_id].label.value)
|
||||
|
||||
support = len(matched_pred_labels)
|
||||
|
||||
if support == 0:
|
||||
empty_per_label = {
|
||||
label: LabelF1(
|
||||
label=label, precision=1.0, recall=1.0, f1=1.0,
|
||||
support_predicted=0, support_gold=0,
|
||||
)
|
||||
for label in SENTIMENT_LABELS
|
||||
}
|
||||
return SentimentF1Result(
|
||||
macro_f1=1.0,
|
||||
micro_f1=1.0,
|
||||
per_label=empty_per_label,
|
||||
support=0,
|
||||
)
|
||||
|
||||
# Per-label F1
|
||||
per_label: dict[str, LabelF1] = {}
|
||||
for label in SENTIMENT_LABELS:
|
||||
per_label[label] = _compute_label_f1(matched_pred_labels, matched_gold_labels, label)
|
||||
|
||||
# Macro-F1: average of per-label F1 scores (only labels with support)
|
||||
active_labels = [
|
||||
label for label in SENTIMENT_LABELS
|
||||
if per_label[label].support_predicted > 0 or per_label[label].support_gold > 0
|
||||
]
|
||||
if active_labels:
|
||||
label_f1_values = [per_label[label].f1 for label in active_labels]
|
||||
macro_f1 = sum(label_f1_values) / len(label_f1_values)
|
||||
else:
|
||||
macro_f1 = 1.0
|
||||
|
||||
# Micro-F1: global TP, FP, FN across all labels
|
||||
total_tp = 0
|
||||
total_fp = 0
|
||||
total_fn = 0
|
||||
|
||||
for label in SENTIMENT_LABELS:
|
||||
for pred, gold_label in zip(matched_pred_labels, matched_gold_labels):
|
||||
if pred == label and gold_label == label:
|
||||
total_tp += 1
|
||||
elif pred == label and gold_label != label:
|
||||
total_fp += 1
|
||||
elif pred != label and gold_label == label:
|
||||
total_fn += 1
|
||||
|
||||
micro_precision = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 1.0
|
||||
micro_recall = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 1.0
|
||||
|
||||
if micro_precision + micro_recall > 0:
|
||||
micro_f1 = 2 * micro_precision * micro_recall / (micro_precision + micro_recall)
|
||||
else:
|
||||
micro_f1 = 0.0
|
||||
|
||||
return SentimentF1Result(
|
||||
macro_f1=macro_f1,
|
||||
micro_f1=micro_f1,
|
||||
per_label=per_label,
|
||||
support=support,
|
||||
)
|
||||
|
||||
|
||||
def compute_direction_accuracy(
|
||||
predicted: list[SentimentPrediction],
|
||||
gold: list[SentimentPrediction],
|
||||
) -> DirectionAccuracyResult:
|
||||
"""Compute binary direction accuracy (positive vs negative).
|
||||
|
||||
Only considers matched pairs where BOTH predicted and gold labels are
|
||||
either 'positive' or 'negative'. Neutral and mixed are ignored.
|
||||
|
||||
Args:
|
||||
predicted: Predicted sentiment labels.
|
||||
gold: Gold standard sentiment labels.
|
||||
|
||||
Returns:
|
||||
DirectionAccuracyResult with accuracy and counts.
|
||||
"""
|
||||
gold_by_id = {g.company_entity_id: g for g in gold}
|
||||
correct = 0
|
||||
total = 0
|
||||
|
||||
directional_labels = {SentimentLabel.positive, SentimentLabel.negative}
|
||||
|
||||
for p in predicted:
|
||||
if p.company_entity_id not in gold_by_id:
|
||||
continue
|
||||
g = gold_by_id[p.company_entity_id]
|
||||
# Both must be directional (positive or negative)
|
||||
if p.label in directional_labels and g.label in directional_labels:
|
||||
total += 1
|
||||
if p.label == g.label:
|
||||
correct += 1
|
||||
|
||||
accuracy = correct / total if total > 0 else 1.0
|
||||
|
||||
return DirectionAccuracyResult(
|
||||
accuracy=accuracy,
|
||||
correct=correct,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def compute_calibration(
|
||||
predicted: list[SentimentPrediction],
|
||||
gold: list[SentimentPrediction],
|
||||
n_bins: int = 10,
|
||||
) -> CalibrationResult:
|
||||
"""Compute Expected Calibration Error (ECE), Brier score, and reliability diagram.
|
||||
|
||||
For each matched pair, we evaluate how well the predicted probability for
|
||||
the true label reflects observed frequency. Uses the maximum predicted
|
||||
probability (confidence) and checks if the predicted label matches gold.
|
||||
|
||||
Args:
|
||||
predicted: Predicted sentiments with probability distributions.
|
||||
gold: Gold standard sentiments.
|
||||
n_bins: Number of bins for ECE and reliability diagram.
|
||||
|
||||
Returns:
|
||||
CalibrationResult with ECE, Brier score, and per-bin data.
|
||||
"""
|
||||
gold_by_id = {g.company_entity_id: g for g in gold}
|
||||
|
||||
# Collect (confidence, correct) pairs
|
||||
confidences: list[float] = []
|
||||
corrects: list[int] = []
|
||||
brier_terms: list[float] = []
|
||||
|
||||
for p in predicted:
|
||||
if p.company_entity_id not in gold_by_id:
|
||||
continue
|
||||
g = gold_by_id[p.company_entity_id]
|
||||
|
||||
# Confidence = probability assigned to the predicted label
|
||||
confidence = _get_label_prob(p, p.label)
|
||||
is_correct = 1 if p.label == g.label else 0
|
||||
|
||||
confidences.append(confidence)
|
||||
corrects.append(is_correct)
|
||||
|
||||
# Brier score: sum of squared errors across all label probabilities
|
||||
# For each label, the "true" probability is 1 if it matches gold, else 0
|
||||
brier_term = 0.0
|
||||
for label in SENTIMENT_LABELS:
|
||||
pred_prob = _get_label_prob(p, SentimentLabel(label))
|
||||
true_indicator = 1.0 if label == g.label.value else 0.0
|
||||
brier_term += (pred_prob - true_indicator) ** 2
|
||||
brier_terms.append(brier_term)
|
||||
|
||||
n_samples = len(confidences)
|
||||
|
||||
if n_samples == 0:
|
||||
return CalibrationResult(
|
||||
ece=0.0,
|
||||
brier_score=0.0,
|
||||
reliability_bins=[],
|
||||
n_samples=0,
|
||||
)
|
||||
|
||||
# Brier score: mean of per-sample squared error sums
|
||||
brier_score = sum(brier_terms) / n_samples
|
||||
|
||||
# ECE and reliability diagram
|
||||
bin_width = 1.0 / n_bins
|
||||
reliability_bins: list[CalibrationBin] = []
|
||||
weighted_abs_diff_sum = 0.0
|
||||
|
||||
for i in range(n_bins):
|
||||
bin_lower = i * bin_width
|
||||
bin_upper = (i + 1) * bin_width
|
||||
|
||||
# Collect samples in this bin
|
||||
bin_confidences: list[float] = []
|
||||
bin_corrects: list[int] = []
|
||||
|
||||
for conf, correct in zip(confidences, corrects):
|
||||
# Include in bin if conf is in [bin_lower, bin_upper)
|
||||
# Last bin includes the upper boundary
|
||||
if i == n_bins - 1:
|
||||
in_bin = bin_lower <= conf <= bin_upper
|
||||
else:
|
||||
in_bin = bin_lower <= conf < bin_upper
|
||||
if in_bin:
|
||||
bin_confidences.append(conf)
|
||||
bin_corrects.append(correct)
|
||||
|
||||
bin_count = len(bin_confidences)
|
||||
|
||||
if bin_count > 0:
|
||||
mean_predicted = sum(bin_confidences) / bin_count
|
||||
fraction_positive = sum(bin_corrects) / bin_count
|
||||
weighted_abs_diff_sum += bin_count * abs(mean_predicted - fraction_positive)
|
||||
else:
|
||||
mean_predicted = (bin_lower + bin_upper) / 2
|
||||
fraction_positive = 0.0
|
||||
|
||||
reliability_bins.append(
|
||||
CalibrationBin(
|
||||
bin_lower=bin_lower,
|
||||
bin_upper=bin_upper,
|
||||
mean_predicted_prob=mean_predicted,
|
||||
fraction_positive=fraction_positive,
|
||||
count=bin_count,
|
||||
)
|
||||
)
|
||||
|
||||
ece = weighted_abs_diff_sum / n_samples
|
||||
|
||||
return CalibrationResult(
|
||||
ece=ece,
|
||||
brier_score=brier_score,
|
||||
reliability_bins=reliability_bins,
|
||||
n_samples=n_samples,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def evaluate_sentiment(
|
||||
predicted: list[SentimentPrediction],
|
||||
gold: list[SentimentPrediction],
|
||||
n_bins: int = 10,
|
||||
document_count: int = 1,
|
||||
) -> SentimentEvaluationReport:
|
||||
"""Run full sentiment evaluation producing a complete report.
|
||||
|
||||
Args:
|
||||
predicted: All predicted sentiment records.
|
||||
gold: All gold standard sentiment records.
|
||||
n_bins: Number of bins for calibration metrics.
|
||||
document_count: Number of documents evaluated.
|
||||
|
||||
Returns:
|
||||
SentimentEvaluationReport with F1, direction accuracy, and calibration.
|
||||
"""
|
||||
f1_metrics = compute_sentiment_f1(predicted, gold)
|
||||
direction_accuracy = compute_direction_accuracy(predicted, gold)
|
||||
calibration = compute_calibration(predicted, gold, n_bins=n_bins)
|
||||
|
||||
return SentimentEvaluationReport(
|
||||
f1_metrics=f1_metrics,
|
||||
direction_accuracy=direction_accuracy,
|
||||
calibration=calibration,
|
||||
document_count=document_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_label_prob(prediction: SentimentPrediction, label: SentimentLabel) -> float:
|
||||
"""Get the predicted probability for a specific label."""
|
||||
if label == SentimentLabel.positive:
|
||||
return prediction.positive_prob
|
||||
elif label == SentimentLabel.negative:
|
||||
return prediction.negative_prob
|
||||
elif label == SentimentLabel.neutral:
|
||||
return prediction.neutral_prob
|
||||
elif label == SentimentLabel.mixed:
|
||||
return prediction.mixed_prob
|
||||
return 0.0
|
||||
Reference in New Issue
Block a user