Spaces:
Sleeping
Sleeping
File size: 6,333 Bytes
2e9e564 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | """``EvaluationEngine`` — Sprint A14-S27.
Pendant de ``ProjectionEngine`` (cf. ``projection_engine.py``).
Le S13 fusionnait dans ``DefaultEvaluationViewExecutor`` projection
**et** évaluation ; la cible architecturale les sépare en deux
moteurs spécialisés à responsabilité unique.
``EvaluationEngine`` calcule un ensemble nommé de métriques sur
une paire ``(reference, hypothesis)`` de payloads. Une métrique
qui lève en interne va dans ``failed_metrics`` au lieu de planter
l'évaluation complète — l'erreur est capturée et associée au nom
de la métrique.
Pourquoi cette séparation
-------------------------
- **Réutilisation** : le ``PipelineExecutor`` (S28+) peut appeler
``EvaluationEngine.evaluate`` pour des métriques de jonction
intra-pipeline (ex : « score de stabilité entre deux étapes ») sans
passer par un ``EvaluationView``.
- **Testabilité** : on teste la collecte d'erreurs (métrique cassée,
métrique inconnue) sans instancier de vue ni de projecteur.
- **Découplage** : ``EvaluationEngine`` ne sait rien des artefacts,
des projections, des vues — il prend des payloads bruts.
Anti-sur-ingénierie
-------------------
Pas de batch (évaluer N paires en une passe), pas de cache de
payload normalisé, pas de pré-tri des métriques. Le moteur est
volontairement minimal — la complexité vit dans les métriques
elles-mêmes (cf. ``picarones/evaluation/metrics/``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from picarones.evaluation.registry import (
MetricNotFoundError,
MetricRegistry,
)
@dataclass(frozen=True)
class EvaluationResult:
"""Résultat d'un appel à ``EvaluationEngine.evaluate``.
Attributes
----------
metric_values:
Métriques calculées avec succès, ``{name: value}``.
failed_metrics:
Métriques qui ont échoué, ``{name: error_message}``. Les
deux dicts sont disjoints : une métrique apparaît dans l'un
ou l'autre, jamais les deux.
Notes
-----
Frozen dataclass : container immuable ; les dicts internes le
sont aussi grâce à ``field(default_factory=dict)`` qu'on ne
mute pas après construction. Le caller doit considérer les
dicts comme lecture seule.
"""
metric_values: dict[str, Any] = field(default_factory=dict)
failed_metrics: dict[str, str] = field(default_factory=dict)
@property
def n_succeeded(self) -> int:
return len(self.metric_values)
@property
def n_failed(self) -> int:
return len(self.failed_metrics)
@property
def all_succeeded(self) -> bool:
return self.n_failed == 0
def with_global_failure(self, error: str) -> "EvaluationResult":
"""Retourne un nouveau ``EvaluationResult`` où **toutes** les
métriques portent le même message d'erreur global. Utile à
un caller qui constate qu'un payload n'a pas pu être chargé
et veut marquer l'évaluation entière en échec."""
return EvaluationResult(
metric_values={},
failed_metrics={
name: error
for name in (
list(self.metric_values) + list(self.failed_metrics)
)
},
)
class EvaluationEngine:
"""Moteur de calcul de métriques sur une paire de payloads.
Responsabilité unique : prendre un ``MetricRegistry``, une liste
de noms de métriques, et une paire ``(reference, hypothesis)``,
retourner un ``EvaluationResult``. Pas de connaissance des
artefacts, des projections, des vues.
Parameters
----------
metric_registry:
Registre des métriques, instancié explicitement au démarrage
(pas de singleton global, pas de side-effect d'import).
"""
def __init__(self, metric_registry: MetricRegistry) -> None:
if not isinstance(metric_registry, MetricRegistry):
raise TypeError(
"metric_registry doit être un MetricRegistry."
)
self._metrics = metric_registry
@property
def metrics(self) -> MetricRegistry:
"""Accès en lecture au registre sous-jacent (utile aux tests)."""
return self._metrics
def evaluate(
self,
metric_names: tuple[str, ...] | list[str],
reference: Any,
hypothesis: Any,
) -> EvaluationResult:
"""Calcule chaque métrique nommée sur la paire (référence, hypothèse).
Comportement :
- Une métrique enregistrée et qui retourne une valeur → entrée
dans ``metric_values``.
- Une métrique enregistrée qui lève une exception → entrée
dans ``failed_metrics`` avec le message ``f"{type}: {message}"``.
- Un nom de métrique non enregistré → entrée dans
``failed_metrics`` avec un message explicite.
L'ordre d'évaluation suit l'ordre de ``metric_names`` ; les
deux dicts résultats préservent cet ordre (Python 3.7+
garantit l'ordre d'insertion sur les ``dict``).
"""
metric_values: dict[str, Any] = {}
failed_metrics: dict[str, str] = {}
for name in metric_names:
try:
value = self._metrics.compute(name, reference, hypothesis)
metric_values[name] = value
except MetricNotFoundError as exc:
failed_metrics[name] = (
f"métrique non enregistrée dans le MetricRegistry : "
f"{exc}"
)
except Exception as exc: # noqa: BLE001
failed_metrics[name] = f"{type(exc).__name__}: {exc}"
return EvaluationResult(
metric_values=metric_values,
failed_metrics=failed_metrics,
)
def evaluate_one(
self,
metric_name: str,
reference: Any,
hypothesis: Any,
) -> EvaluationResult:
"""Cas particulier : une seule métrique. Sucre syntaxique sur
``evaluate``. Utile aux callers qui pilotent une jonction
unique (typiquement le pipeline executor sur une métrique de
jonction)."""
return self.evaluate((metric_name,), reference, hypothesis)
__all__ = ["EvaluationEngine", "EvaluationResult"]
|