Picarones / picarones /evaluation /metrics /specialization.py
Claude
docs(sprint-H.8): cleanup obsolete legacy/shim language in production docstrings
e407ec0 unverified
Raw
History Blame
5.69 kB
"""Score de spécialisation inter-moteurs — Sprint 89 (A.II.8b).
Sprint 89 — A.II.8b du plan d'évolution 2026.
Pourquoi ce module
------------------
La matrice de divergence taxonomique (Sprint 35
``inter_engine.taxonomy_divergence_matrix``) répond à *« à quel
point ces moteurs se trompent-ils différemment ? »*. Ce
sprint la transforme en un **score de spécialisation** lisible
et complète la lecture par :
- une **classification** discrète (similar / distinct /
highly_specialized) que le chercheur peut consommer sans
avoir à interpréter une distance ;
- un **top-N des paires** les plus spécialisées, qui répond
directement à la question *« quels moteurs sont les meilleurs
candidats pour un voting ensemble ? »*.
Ce module **ne recommande pas** de pipeline d'ensemble — il
fournit l'observation factuelle et laisse le chercheur arbitrer.
Convention de score
-------------------
On utilise la **Jensen-Shannon divergence** déjà calculée par
``inter_engine.jensen_shannon_divergence`` : elle est
symétrique, bornée dans [0, 1], et son interprétation est
intuitive :
- ≈ 0 → profils taxonomiques identiques
- 1 → distributions totalement disjointes
Dépendances
-----------
S'appuie strictement sur ``picarones.evaluation.metrics.inter_engine`` (Sprint
35) — pas de double calcul, pas de logique nouvelle de
divergence.
"""
from __future__ import annotations
import logging
from typing import Optional
from picarones.evaluation.metrics.inter_engine import jensen_shannon_divergence
logger = logging.getLogger(__name__)
# Seuils par convention éditoriale. La roadmap ne fixe rien :
# ces seuils sont des **guides de lecture**, pas des verdicts.
# Le chercheur peut les surcharger via ``classify_specialization``.
DEFAULT_THRESHOLDS = (
("similar", 0.10),
("distinct", 0.30),
("highly_specialized", 1.01), # tout score ≥ 0.30
)
def compute_specialization_score(
taxonomy_a: dict[str, float],
taxonomy_b: dict[str, float],
) -> float:
"""Score de spécialisation entre deux moteurs ∈ [0, 1].
0 = mêmes erreurs, 1 = erreurs totalement disjointes.
Délègue à ``jensen_shannon_divergence`` (Sprint 35).
"""
return jensen_shannon_divergence(taxonomy_a, taxonomy_b)
def classify_specialization(
score: float,
thresholds: Optional[tuple[tuple[str, float], ...]] = None,
) -> str:
"""Classe le score en catégorie discrète.
Convention :
- score < 0.10 → ``similar``
- 0.10 ≤ score < 0.30 → ``distinct``
- score ≥ 0.30 → ``highly_specialized``
L'utilisateur peut passer ses propres ``thresholds`` (liste
triée par valeur croissante de tuples ``(label, max_score)``).
"""
rules = thresholds or DEFAULT_THRESHOLDS
for label, max_score in rules:
if score < max_score:
return label
# Garde-fou : si aucun seuil ne match, dernière catégorie
return rules[-1][0]
def compute_specialization_matrix(
taxonomies: dict[str, dict[str, float]],
) -> Optional[dict]:
"""Matrice de spécialisation symétrique entre tous les moteurs.
Parameters
----------
taxonomies:
Map ``{engine_name: {error_class: count_or_proportion}}``.
Returns
-------
dict | None
``{
"engines": list[str],
"matrix": list[list[float]], # carrée, symétrique
"n_pairs": int, # paires distinctes
"max_score": float,
"max_pair": (str, str) | None,
}`` ; ``None`` si moins de 2 moteurs.
"""
if not taxonomies or len(taxonomies) < 2:
return None
engines = sorted(taxonomies.keys())
n = len(engines)
matrix = [[0.0] * n for _ in range(n)]
n_pairs = 0
max_score = 0.0
max_pair: Optional[tuple[str, str]] = None
for i in range(n):
for j in range(i + 1, n):
score = compute_specialization_score(
taxonomies[engines[i]], taxonomies[engines[j]],
)
matrix[i][j] = score
matrix[j][i] = score
n_pairs += 1
if score > max_score:
max_score = score
max_pair = (engines[i], engines[j])
return {
"engines": engines,
"matrix": matrix,
"n_pairs": n_pairs,
"max_score": max_score,
"max_pair": max_pair,
}
def top_specialized_pairs(
matrix_data: Optional[dict],
n: int = 5,
*,
min_score: float = 0.0,
) -> list[dict]:
"""Top-N paires de moteurs triées par score décroissant.
Returns
-------
list[dict]
Une liste de ``{
"engine_a": str, "engine_b": str,
"score": float, "category": str,
}`` triée par score décroissant. Liste vide si
``matrix_data`` est ``None`` ou que toutes les paires
sont sous ``min_score``.
"""
if not matrix_data:
return []
engines = matrix_data["engines"]
matrix = matrix_data["matrix"]
pairs: list[dict] = []
for i, engine_a in enumerate(engines):
for j in range(i + 1, len(engines)):
score = matrix[i][j]
if score < min_score:
continue
pairs.append({
"engine_a": engine_a,
"engine_b": engines[j],
"score": score,
"category": classify_specialization(score),
})
pairs.sort(key=lambda p: -p["score"])
return pairs[:n]
__all__ = [
"DEFAULT_THRESHOLDS",
"compute_specialization_score",
"classify_specialization",
"compute_specialization_matrix",
"top_specialized_pairs",
]