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,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,
|
||||
)
|
||||
Reference in New Issue
Block a user