model-eval-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalreport/__init__.py +28 -0
- evalreport/__version__.py +2 -0
- evalreport/classification/__init__.py +4 -0
- evalreport/classification/report.py +319 -0
- evalreport/clustering/__init__.py +4 -0
- evalreport/clustering/report.py +174 -0
- evalreport/core/base_report.py +479 -0
- evalreport/core/entrypoints.py +97 -0
- evalreport/core/task_inference.py +180 -0
- evalreport/nlp/__init__.py +5 -0
- evalreport/nlp/text_classification.py +21 -0
- evalreport/nlp/text_generation.py +202 -0
- evalreport/ranking/__init__.py +3 -0
- evalreport/ranking/report.py +274 -0
- evalreport/regression/__init__.py +4 -0
- evalreport/regression/report.py +173 -0
- evalreport/timeseries/__init__.py +4 -0
- evalreport/timeseries/report.py +211 -0
- evalreport/vision/__init__.py +6 -0
- evalreport/vision/detection.py +359 -0
- evalreport/vision/image_classification.py +25 -0
- evalreport/vision/segmentation.py +140 -0
- model_eval_toolkit-0.1.0.dist-info/METADATA +339 -0
- model_eval_toolkit-0.1.0.dist-info/RECORD +27 -0
- model_eval_toolkit-0.1.0.dist-info/WHEEL +5 -0
- model_eval_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- model_eval_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Iterable, Optional
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def infer_task(
|
|
8
|
+
*,
|
|
9
|
+
y_true: Optional[Iterable[Any]] = None,
|
|
10
|
+
y_pred: Optional[Iterable[Any]] = None,
|
|
11
|
+
X: Optional[Iterable[Any]] = None,
|
|
12
|
+
timestamps: Optional[Iterable[Any]] = None,
|
|
13
|
+
embeddings: Optional[Iterable[Any]] = None,
|
|
14
|
+
y_prob: Optional[Iterable[Any]] = None,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Infer ML task type from provided inputs.
|
|
17
|
+
|
|
18
|
+
Notes
|
|
19
|
+
-----
|
|
20
|
+
This is a heuristic v0.1 inference intended to be *good enough* for common
|
|
21
|
+
scenarios. Power users should still prefer `task="classification"` /
|
|
22
|
+
`task="regression"` / etc. when they know the correct task.
|
|
23
|
+
"""
|
|
24
|
+
if timestamps is not None:
|
|
25
|
+
return "timeseries"
|
|
26
|
+
if embeddings is not None:
|
|
27
|
+
return "clustering"
|
|
28
|
+
|
|
29
|
+
def _is_seq_of_strings(x: Any) -> bool:
|
|
30
|
+
if x is None:
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
seq = list(x)
|
|
34
|
+
if not seq:
|
|
35
|
+
return False
|
|
36
|
+
return all(isinstance(v, str) for v in seq)
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def _avg_tokens(seq: Iterable[Any]) -> float:
|
|
41
|
+
vals = list(seq)
|
|
42
|
+
if not vals:
|
|
43
|
+
return 0.0
|
|
44
|
+
total = 0
|
|
45
|
+
n = 0
|
|
46
|
+
for v in vals:
|
|
47
|
+
if v is None:
|
|
48
|
+
continue
|
|
49
|
+
s = str(v)
|
|
50
|
+
total += len(s.split())
|
|
51
|
+
n += 1
|
|
52
|
+
return (total / max(1, n)) if n else 0.0
|
|
53
|
+
|
|
54
|
+
def _looks_like_text_generation(y_t: Any, y_p: Any) -> bool:
|
|
55
|
+
# Both sides are strings; generation outputs tend to be longer.
|
|
56
|
+
if not (_is_seq_of_strings(y_t) and _is_seq_of_strings(y_p)):
|
|
57
|
+
return False
|
|
58
|
+
avg_t = _avg_tokens(y_t)
|
|
59
|
+
avg_p = _avg_tokens(y_p)
|
|
60
|
+
# Conservative threshold: labels like "pos"/"neg" usually have ~1 token.
|
|
61
|
+
return (avg_t > 3.0) or (avg_p > 3.0)
|
|
62
|
+
|
|
63
|
+
def _looks_like_text_classification(y_t: Any, y_p: Any) -> bool:
|
|
64
|
+
if not (_is_seq_of_strings(y_t) and _is_seq_of_strings(y_p)):
|
|
65
|
+
return False
|
|
66
|
+
# If they are strings but short, treat as classification labels.
|
|
67
|
+
avg_t = _avg_tokens(y_t)
|
|
68
|
+
avg_p = _avg_tokens(y_p)
|
|
69
|
+
return (avg_t <= 3.0) and (avg_p <= 3.0)
|
|
70
|
+
|
|
71
|
+
def _looks_like_detection(y_t: Any, y_p: Any) -> bool:
|
|
72
|
+
# Expect: list of images -> list of dict boxes with 'bbox'
|
|
73
|
+
try:
|
|
74
|
+
gt = list(y_t) if y_t is not None else None
|
|
75
|
+
pr = list(y_p) if y_p is not None else None
|
|
76
|
+
if not gt or not pr:
|
|
77
|
+
return False
|
|
78
|
+
if not isinstance(gt[0], (list, tuple)) or not isinstance(pr[0], (list, tuple)):
|
|
79
|
+
return False
|
|
80
|
+
# Check first non-empty box dict
|
|
81
|
+
for boxes in (gt[0], pr[0]):
|
|
82
|
+
if not boxes:
|
|
83
|
+
continue
|
|
84
|
+
first = boxes[0]
|
|
85
|
+
if isinstance(first, dict) and "bbox" in first:
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
except Exception:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def _looks_like_segmentation(y_t: Any, y_p: Any) -> bool:
|
|
92
|
+
# Expect: masks with at least 2D structure (H,W) or (N,H,W)
|
|
93
|
+
try:
|
|
94
|
+
gt = np.asarray(list(y_t)) if y_t is not None else None
|
|
95
|
+
pr = np.asarray(list(y_p)) if y_p is not None else None
|
|
96
|
+
if gt is None or pr is None:
|
|
97
|
+
return False
|
|
98
|
+
# classification labels are 1D; segmentation should be 2D or 3D
|
|
99
|
+
return gt.ndim >= 2 and pr.ndim >= 2
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def _looks_like_recommendation(y_t: Any, y_p: Any) -> bool:
|
|
104
|
+
# Expect per-user relevant items + ranked lists:
|
|
105
|
+
# y_true: list[ list[item] ] (or set)
|
|
106
|
+
# y_pred: list[ list[item] ]
|
|
107
|
+
try:
|
|
108
|
+
gt = list(y_t) if y_t is not None else None
|
|
109
|
+
pr = list(y_p) if y_p is not None else None
|
|
110
|
+
if not gt or not pr:
|
|
111
|
+
return False
|
|
112
|
+
if not isinstance(gt[0], (list, tuple, set)):
|
|
113
|
+
return False
|
|
114
|
+
if not isinstance(pr[0], (list, tuple)):
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
except Exception:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
def _looks_like_discrete_labels(arr: np.ndarray) -> bool:
|
|
121
|
+
# If labels are integer-like (or small number of unique values),
|
|
122
|
+
# likely classification/clustering.
|
|
123
|
+
if arr.size == 0:
|
|
124
|
+
return False
|
|
125
|
+
if arr.dtype.kind in {"i", "u", "b"}:
|
|
126
|
+
return True
|
|
127
|
+
if arr.dtype.kind == "f":
|
|
128
|
+
# float but close to integers?
|
|
129
|
+
rounded = np.rint(arr)
|
|
130
|
+
if np.allclose(arr, rounded, atol=1e-8):
|
|
131
|
+
return True
|
|
132
|
+
# otherwise: not enough evidence
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# Vision --------------------------------------------------------------
|
|
136
|
+
if y_true is not None and y_pred is not None:
|
|
137
|
+
if _looks_like_detection(y_true, y_pred):
|
|
138
|
+
return "detection"
|
|
139
|
+
if _looks_like_segmentation(y_true, y_pred):
|
|
140
|
+
return "segmentation"
|
|
141
|
+
if _looks_like_recommendation(y_true, y_pred):
|
|
142
|
+
return "recommendation"
|
|
143
|
+
|
|
144
|
+
# NLP --------------------------------------------------------------
|
|
145
|
+
if _looks_like_text_generation(y_true, y_pred):
|
|
146
|
+
return "text_generation"
|
|
147
|
+
if _looks_like_text_classification(y_true, y_pred):
|
|
148
|
+
return "text_classification"
|
|
149
|
+
|
|
150
|
+
# Clustering ----------------------------------------------------------
|
|
151
|
+
if X is not None and y_true is None and y_pred is not None:
|
|
152
|
+
try:
|
|
153
|
+
labels = np.asarray(list(y_pred))
|
|
154
|
+
if _looks_like_discrete_labels(labels) and labels.ndim == 1:
|
|
155
|
+
return "clustering"
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# Regression vs classification ---------------------------------------
|
|
160
|
+
if y_true is not None and y_pred is not None:
|
|
161
|
+
try:
|
|
162
|
+
y_true_arr = np.asarray(list(y_true))
|
|
163
|
+
if y_true_arr.ndim == 0:
|
|
164
|
+
return "classification"
|
|
165
|
+
|
|
166
|
+
if y_true_arr.dtype.kind == "f":
|
|
167
|
+
# If float targets look integer-like, treat as classification labels
|
|
168
|
+
# only when predictions are also integer-like (otherwise it's regression).
|
|
169
|
+
y_pred_arr = np.asarray(list(y_pred))
|
|
170
|
+
if _looks_like_discrete_labels(y_true_arr) and _looks_like_discrete_labels(y_pred_arr):
|
|
171
|
+
return "classification"
|
|
172
|
+
return "regression"
|
|
173
|
+
except Exception: # pragma: no cover - heuristic fallback
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# Non-float targets default to classification.
|
|
177
|
+
return "classification"
|
|
178
|
+
|
|
179
|
+
return "classification"
|
|
180
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Iterable, Optional, Sequence
|
|
5
|
+
|
|
6
|
+
from ..classification.report import ClassificationReport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TextClassificationReport(ClassificationReport):
|
|
11
|
+
"""Text classification report.
|
|
12
|
+
|
|
13
|
+
Currently reuses the core ClassificationReport logic (metrics, plots, insights)
|
|
14
|
+
and can be extended later with token-level error analysis.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
y_true: Optional[Iterable[Any]] = None
|
|
18
|
+
y_pred: Optional[Iterable[Any]] = None
|
|
19
|
+
y_prob: Optional[Iterable[Any]] = None
|
|
20
|
+
labels: Optional[Sequence[Any]] = None
|
|
21
|
+
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Iterable, List, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
import matplotlib
|
|
8
|
+
|
|
9
|
+
matplotlib.use("Agg")
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..core.base_report import BaseReport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _as_str_list(x: Optional[Iterable[Any]]) -> Optional[List[str]]:
|
|
17
|
+
if x is None:
|
|
18
|
+
return None
|
|
19
|
+
return ["" if v is None else str(v) for v in x]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tokenize(text: str) -> List[str]:
|
|
23
|
+
# Minimal tokenizer: whitespace split. Keeps v0.1 dependency-light.
|
|
24
|
+
return [t for t in text.strip().split() if t]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ngram_counts(tokens: List[str], n: int) -> dict[tuple[str, ...], int]:
|
|
28
|
+
counts: dict[tuple[str, ...], int] = {}
|
|
29
|
+
if n <= 0:
|
|
30
|
+
return counts
|
|
31
|
+
for i in range(0, max(0, len(tokens) - n + 1)):
|
|
32
|
+
ng = tuple(tokens[i : i + n])
|
|
33
|
+
counts[ng] = counts.get(ng, 0) + 1
|
|
34
|
+
return counts
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bleu_like(references: Sequence[str], predictions: Sequence[str], max_n: int = 4) -> float:
|
|
38
|
+
"""A lightweight BLEU-like score (not sacreBLEU).
|
|
39
|
+
|
|
40
|
+
Computes clipped n-gram precisions with a brevity penalty and uniform weights.
|
|
41
|
+
Intended as a reasonable v0.1 metric without extra dependencies.
|
|
42
|
+
"""
|
|
43
|
+
# corpus n-gram stats
|
|
44
|
+
clipped_hits = np.zeros(max_n, dtype=float)
|
|
45
|
+
total_preds = np.zeros(max_n, dtype=float)
|
|
46
|
+
ref_len = 0
|
|
47
|
+
pred_len = 0
|
|
48
|
+
|
|
49
|
+
for ref, pred in zip(references, predictions):
|
|
50
|
+
ref_toks = _tokenize(ref)
|
|
51
|
+
pred_toks = _tokenize(pred)
|
|
52
|
+
ref_len += len(ref_toks)
|
|
53
|
+
pred_len += len(pred_toks)
|
|
54
|
+
for n in range(1, max_n + 1):
|
|
55
|
+
ref_counts = _ngram_counts(ref_toks, n)
|
|
56
|
+
pred_counts = _ngram_counts(pred_toks, n)
|
|
57
|
+
total_preds[n - 1] += sum(pred_counts.values())
|
|
58
|
+
for ng, c in pred_counts.items():
|
|
59
|
+
clipped_hits[n - 1] += min(c, ref_counts.get(ng, 0))
|
|
60
|
+
|
|
61
|
+
precisions = []
|
|
62
|
+
for n in range(max_n):
|
|
63
|
+
if total_preds[n] == 0:
|
|
64
|
+
precisions.append(0.0)
|
|
65
|
+
else:
|
|
66
|
+
precisions.append(float(clipped_hits[n] / total_preds[n]))
|
|
67
|
+
|
|
68
|
+
# geometric mean with smoothing for zeros
|
|
69
|
+
eps = 1e-12
|
|
70
|
+
log_p = np.mean([np.log(max(p, eps)) for p in precisions])
|
|
71
|
+
geo_mean = float(np.exp(log_p))
|
|
72
|
+
|
|
73
|
+
# brevity penalty
|
|
74
|
+
if pred_len == 0:
|
|
75
|
+
return 0.0
|
|
76
|
+
bp = 1.0 if pred_len > ref_len else float(np.exp(1.0 - (ref_len / max(1, pred_len))))
|
|
77
|
+
return float(bp * geo_mean)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rouge_l_like(references: Sequence[str], predictions: Sequence[str]) -> float:
|
|
81
|
+
"""Lightweight ROUGE-L F1 over whitespace tokens (corpus average)."""
|
|
82
|
+
|
|
83
|
+
def lcs(a: List[str], b: List[str]) -> int:
|
|
84
|
+
# DP LCS length
|
|
85
|
+
dp = np.zeros((len(a) + 1, len(b) + 1), dtype=int)
|
|
86
|
+
for i in range(1, len(a) + 1):
|
|
87
|
+
for j in range(1, len(b) + 1):
|
|
88
|
+
if a[i - 1] == b[j - 1]:
|
|
89
|
+
dp[i, j] = dp[i - 1, j - 1] + 1
|
|
90
|
+
else:
|
|
91
|
+
dp[i, j] = max(dp[i - 1, j], dp[i, j - 1])
|
|
92
|
+
return int(dp[len(a), len(b)])
|
|
93
|
+
|
|
94
|
+
scores = []
|
|
95
|
+
for ref, pred in zip(references, predictions):
|
|
96
|
+
r = _tokenize(ref)
|
|
97
|
+
p = _tokenize(pred)
|
|
98
|
+
if not r and not p:
|
|
99
|
+
scores.append(1.0)
|
|
100
|
+
continue
|
|
101
|
+
if not r or not p:
|
|
102
|
+
scores.append(0.0)
|
|
103
|
+
continue
|
|
104
|
+
l = lcs(r, p)
|
|
105
|
+
prec = l / max(1, len(p))
|
|
106
|
+
rec = l / max(1, len(r))
|
|
107
|
+
if prec + rec == 0:
|
|
108
|
+
scores.append(0.0)
|
|
109
|
+
else:
|
|
110
|
+
scores.append(2 * prec * rec / (prec + rec))
|
|
111
|
+
return float(np.mean(scores)) if scores else 0.0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class TextGenerationReport(BaseReport):
|
|
116
|
+
references: Optional[Iterable[Any]] = None
|
|
117
|
+
predictions: Optional[Iterable[Any]] = None
|
|
118
|
+
|
|
119
|
+
def _compute_metrics(self) -> None:
|
|
120
|
+
refs = _as_str_list(self.references)
|
|
121
|
+
preds = _as_str_list(self.predictions)
|
|
122
|
+
if refs is None or preds is None:
|
|
123
|
+
raise ValueError("TextGenerationReport requires references and predictions.")
|
|
124
|
+
if len(refs) != len(preds):
|
|
125
|
+
raise ValueError("TextGenerationReport requires references and predictions of equal length.")
|
|
126
|
+
|
|
127
|
+
bleu = _bleu_like(refs, preds)
|
|
128
|
+
rouge_l = _rouge_l_like(refs, preds)
|
|
129
|
+
|
|
130
|
+
# Simple lexical overlap ratio (Jaccard over tokens) as a semantic-gap proxy
|
|
131
|
+
overlaps = []
|
|
132
|
+
for r, p in zip(refs, preds):
|
|
133
|
+
rt = set(_tokenize(r))
|
|
134
|
+
pt = set(_tokenize(p))
|
|
135
|
+
denom = len(rt | pt)
|
|
136
|
+
overlaps.append(1.0 if denom == 0 else (len(rt & pt) / denom))
|
|
137
|
+
jaccard = float(np.mean(overlaps)) if overlaps else 0.0
|
|
138
|
+
|
|
139
|
+
self.metrics.update(
|
|
140
|
+
{
|
|
141
|
+
"bleu_like": float(bleu),
|
|
142
|
+
"rouge_l_f1_like": float(rouge_l),
|
|
143
|
+
"token_jaccard": float(jaccard),
|
|
144
|
+
"num_samples": int(len(refs)),
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
self._cached_scores = {
|
|
150
|
+
"bleu_like": [bleu], # corpus-level; keep structure for plots
|
|
151
|
+
"rouge_l_f1_like": [rouge_l],
|
|
152
|
+
"token_jaccard": overlaps,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def _generate_plots(self) -> None:
|
|
156
|
+
root = self.output_dir or Path("reports")
|
|
157
|
+
plot_dir = root / "evalreport_plots"
|
|
158
|
+
plot_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
plots: dict[str, str] = {}
|
|
161
|
+
|
|
162
|
+
# Distribution of token_jaccard per sample
|
|
163
|
+
try:
|
|
164
|
+
vals = np.asarray(self._cached_scores.get("token_jaccard", []), dtype=float)
|
|
165
|
+
if vals.size > 0:
|
|
166
|
+
plt.figure(figsize=(5, 3.5))
|
|
167
|
+
plt.hist(vals, bins=20, alpha=0.85)
|
|
168
|
+
plt.xlabel("Token Jaccard overlap")
|
|
169
|
+
plt.ylabel("Count")
|
|
170
|
+
plt.title("Generation overlap distribution")
|
|
171
|
+
path = plot_dir / "nlp_generation_overlap_distribution.png"
|
|
172
|
+
plt.tight_layout()
|
|
173
|
+
plt.savefig(path)
|
|
174
|
+
plt.close()
|
|
175
|
+
plots["overlap_distribution"] = str(path)
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
self.plots = plots
|
|
180
|
+
|
|
181
|
+
def _generate_insights(self) -> None:
|
|
182
|
+
insights: List[str] = []
|
|
183
|
+
j = self.metrics.get("token_jaccard")
|
|
184
|
+
b = self.metrics.get("bleu_like")
|
|
185
|
+
r = self.metrics.get("rouge_l_f1_like")
|
|
186
|
+
|
|
187
|
+
if isinstance(j, (int, float)) and j < 0.2:
|
|
188
|
+
insights.append("Low lexical overlap; model outputs may be paraphrasing heavily or drifting semantically.")
|
|
189
|
+
if isinstance(b, (int, float)) and b < 0.1 and isinstance(r, (int, float)) and r < 0.2:
|
|
190
|
+
insights.append("Very low overlap-based scores; check formatting, tokenization, or reference/prediction alignment.")
|
|
191
|
+
|
|
192
|
+
self.insights = insights
|
|
193
|
+
|
|
194
|
+
self.metric_descriptions.update(
|
|
195
|
+
{
|
|
196
|
+
"bleu_like": "N-gram overlap score with brevity penalty (lightweight BLEU-like). Higher is better.",
|
|
197
|
+
"rouge_l_f1_like": "Sequence overlap based on LCS (lightweight ROUGE-L F1). Higher is better.",
|
|
198
|
+
"token_jaccard": "Token-set Jaccard overlap between prediction and reference (lexical overlap proxy).",
|
|
199
|
+
"num_samples": "Number of evaluated reference/prediction pairs.",
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from math import log2
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Iterable, List, Optional, Sequence, Set, Tuple
|
|
7
|
+
|
|
8
|
+
import matplotlib
|
|
9
|
+
|
|
10
|
+
matplotlib.use("Agg")
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from ..core.base_report import BaseReport
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_set(x: Iterable[Any]) -> Set[Any]:
|
|
18
|
+
return set(x)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _to_list(x: Iterable[Any]) -> List[Any]:
|
|
22
|
+
return list(x)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def precision_at_k(relevant: Set[Any], ranked: Sequence[Any], k: int) -> float:
|
|
26
|
+
if k <= 0:
|
|
27
|
+
return 0.0
|
|
28
|
+
top = ranked[:k]
|
|
29
|
+
if not top:
|
|
30
|
+
return 0.0
|
|
31
|
+
hits = sum(1 for item in top if item in relevant)
|
|
32
|
+
return hits / float(k)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def recall_at_k(relevant: Set[Any], ranked: Sequence[Any], k: int) -> float:
|
|
36
|
+
if not relevant:
|
|
37
|
+
return 0.0
|
|
38
|
+
top = ranked[:k]
|
|
39
|
+
hits = sum(1 for item in top if item in relevant)
|
|
40
|
+
return hits / float(len(relevant))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def hit_rate_at_k(relevant: Set[Any], ranked: Sequence[Any], k: int) -> float:
|
|
44
|
+
if k <= 0:
|
|
45
|
+
return 0.0
|
|
46
|
+
top = ranked[:k]
|
|
47
|
+
return 1.0 if any(item in relevant for item in top) else 0.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def average_precision(relevant: Set[Any], ranked: Sequence[Any]) -> float:
|
|
51
|
+
"""Mean average precision for one user (binary relevance)."""
|
|
52
|
+
if not relevant:
|
|
53
|
+
return 0.0
|
|
54
|
+
hits = 0
|
|
55
|
+
prec_sum = 0.0
|
|
56
|
+
for i, item in enumerate(ranked, start=1):
|
|
57
|
+
if item in relevant:
|
|
58
|
+
hits += 1
|
|
59
|
+
prec_sum += hits / float(i)
|
|
60
|
+
return prec_sum / float(len(relevant))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def dcg_at_k(relevant: Set[Any], ranked: Sequence[Any], k: int) -> float:
|
|
64
|
+
dcg = 0.0
|
|
65
|
+
for i, item in enumerate(ranked[:k], start=1):
|
|
66
|
+
rel = 1.0 if item in relevant else 0.0
|
|
67
|
+
dcg += rel / log2(i + 1)
|
|
68
|
+
return dcg
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def idcg_at_k(relevant: Set[Any], k: int) -> float:
|
|
72
|
+
"""Ideal DCG when all |R| relevant items are at the top (binary)."""
|
|
73
|
+
n_rel = min(len(relevant), k)
|
|
74
|
+
if n_rel == 0:
|
|
75
|
+
return 0.0
|
|
76
|
+
return sum(1.0 / log2(i + 1) for i in range(1, n_rel + 1))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ndcg_at_k(relevant: Set[Any], ranked: Sequence[Any], k: int) -> float:
|
|
80
|
+
ideal = idcg_at_k(relevant, k)
|
|
81
|
+
if ideal <= 0:
|
|
82
|
+
return 0.0
|
|
83
|
+
return dcg_at_k(relevant, ranked, k) / ideal
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RankingReport(BaseReport):
|
|
88
|
+
"""Recommendation / ranking evaluation (list-wise per user or query).
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
relevant :
|
|
93
|
+
Per-user (or per-query) ground-truth relevant items. Iterable of iterables.
|
|
94
|
+
ranked :
|
|
95
|
+
Per-user ranked recommendation lists (best first). Same length as ``relevant``.
|
|
96
|
+
k_values :
|
|
97
|
+
K cutoffs for P@K, R@K, NDCG@K, Hit@K.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
relevant: Optional[Iterable[Iterable[Any]]] = None
|
|
101
|
+
ranked: Optional[Iterable[Iterable[Any]]] = None
|
|
102
|
+
k_values: Tuple[int, ...] = (1, 5, 10)
|
|
103
|
+
|
|
104
|
+
def _normalize_inputs(self) -> Tuple[List[Set[Any]], List[List[Any]]]:
|
|
105
|
+
if self.relevant is None or self.ranked is None:
|
|
106
|
+
raise ValueError("RankingReport requires `relevant` and `ranked` (same number of users/queries).")
|
|
107
|
+
rel_list = [_to_set(r) for r in self.relevant]
|
|
108
|
+
rank_list = [_to_list(r) for r in self.ranked]
|
|
109
|
+
if len(rel_list) != len(rank_list):
|
|
110
|
+
raise ValueError("`relevant` and `ranked` must have the same length (one list per user/query).")
|
|
111
|
+
if len(rel_list) == 0:
|
|
112
|
+
raise ValueError("RankingReport requires at least one user/query.")
|
|
113
|
+
return rel_list, rank_list
|
|
114
|
+
|
|
115
|
+
def _compute_metrics(self) -> None:
|
|
116
|
+
rel_list, rank_list = self._normalize_inputs()
|
|
117
|
+
n_users = len(rel_list)
|
|
118
|
+
|
|
119
|
+
# Per-K aggregates
|
|
120
|
+
ks = sorted(set(max(1, int(k)) for k in self.k_values))
|
|
121
|
+
max_k = max(ks)
|
|
122
|
+
|
|
123
|
+
mean_p: dict[str, float] = {}
|
|
124
|
+
mean_r: dict[str, float] = {}
|
|
125
|
+
mean_ndcg: dict[str, float] = {}
|
|
126
|
+
mean_hit: dict[str, float] = {}
|
|
127
|
+
|
|
128
|
+
for k in ks:
|
|
129
|
+
ps, rs, nds, hs = [], [], [], []
|
|
130
|
+
for rel, ranked in zip(rel_list, rank_list):
|
|
131
|
+
ps.append(precision_at_k(rel, ranked, k))
|
|
132
|
+
rs.append(recall_at_k(rel, ranked, k))
|
|
133
|
+
nds.append(ndcg_at_k(rel, ranked, k))
|
|
134
|
+
hs.append(hit_rate_at_k(rel, ranked, k))
|
|
135
|
+
mean_p[f"precision_at_{k}"] = float(np.mean(ps))
|
|
136
|
+
mean_r[f"recall_at_{k}"] = float(np.mean(rs))
|
|
137
|
+
mean_ndcg[f"ndcg_at_{k}"] = float(np.mean(nds))
|
|
138
|
+
mean_hit[f"hit_rate_at_{k}"] = float(np.mean(hs))
|
|
139
|
+
|
|
140
|
+
# MAP (mean of per-user AP)
|
|
141
|
+
aps = [average_precision(rel, ranked) for rel, ranked in zip(rel_list, rank_list)]
|
|
142
|
+
map_score = float(np.mean(aps))
|
|
143
|
+
|
|
144
|
+
self.metrics.update(
|
|
145
|
+
{
|
|
146
|
+
"map": map_score,
|
|
147
|
+
"num_users": n_users,
|
|
148
|
+
**mean_p,
|
|
149
|
+
**mean_r,
|
|
150
|
+
**mean_ndcg,
|
|
151
|
+
**mean_hit,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Descriptions for every metric key (HTML/PDF “Metric reference” section).
|
|
156
|
+
self.metric_descriptions.update(
|
|
157
|
+
{
|
|
158
|
+
"map": (
|
|
159
|
+
"Mean Average Precision (MAP): average of per-user Average Precision, "
|
|
160
|
+
"where AP rewards placing relevant items higher in the ranked list (binary relevance per item)."
|
|
161
|
+
),
|
|
162
|
+
"num_users": "Number of users or queries in the evaluation set (one ranked list per row).",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
for k in ks:
|
|
166
|
+
self.metric_descriptions[f"precision_at_{k}"] = (
|
|
167
|
+
f"Mean Precision@{k}: averaged across users—of the top-{k} recommendations, "
|
|
168
|
+
f"what fraction are relevant (|relevant ∩ top-{k}| / {k})."
|
|
169
|
+
)
|
|
170
|
+
self.metric_descriptions[f"recall_at_{k}"] = (
|
|
171
|
+
f"Mean Recall@{k}: averaged across users—what fraction of that user’s relevant items "
|
|
172
|
+
f"appear in the top-{k} list (|relevant ∩ top-{k}| / |relevant|; 0 if no relevant items)."
|
|
173
|
+
)
|
|
174
|
+
self.metric_descriptions[f"ndcg_at_{k}"] = (
|
|
175
|
+
f"Mean NDCG@{k}: averaged across users—normalized Discounted Cumulative Gain at rank {k}, "
|
|
176
|
+
f"comparing the ranked list to an ideal that puts all relevant items first (binary relevance)."
|
|
177
|
+
)
|
|
178
|
+
self.metric_descriptions[f"hit_rate_at_{k}"] = (
|
|
179
|
+
f"Mean Hit Rate@{k}: averaged across users—fraction of users who have at least one relevant item "
|
|
180
|
+
f"in their top-{k} recommendations."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
self._cached_ks = ks
|
|
184
|
+
self._cached_max_k = max_k
|
|
185
|
+
self._cached_rel = rel_list
|
|
186
|
+
self._cached_rank = rank_list
|
|
187
|
+
|
|
188
|
+
def _generate_plots(self) -> None:
|
|
189
|
+
root = self.output_dir or Path("reports")
|
|
190
|
+
plot_dir = root / "evalreport_plots"
|
|
191
|
+
plot_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
plots: dict[str, str] = {}
|
|
193
|
+
|
|
194
|
+
ks = getattr(self, "_cached_ks", [])
|
|
195
|
+
rel_list = getattr(self, "_cached_rel", None)
|
|
196
|
+
rank_list = getattr(self, "_cached_rank", None)
|
|
197
|
+
if not ks or rel_list is None or rank_list is None:
|
|
198
|
+
self.plots = plots
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Precision@K curve
|
|
202
|
+
try:
|
|
203
|
+
precs = [self.metrics.get(f"precision_at_{k}", 0.0) for k in ks]
|
|
204
|
+
plt.figure(figsize=(5.5, 4))
|
|
205
|
+
plt.plot(ks, precs, marker="o", linewidth=2)
|
|
206
|
+
plt.xlabel("K")
|
|
207
|
+
plt.ylabel("Mean Precision@K")
|
|
208
|
+
plt.title("Precision@K curve")
|
|
209
|
+
plt.ylim(0, 1.05)
|
|
210
|
+
plt.grid(True, alpha=0.3)
|
|
211
|
+
path = plot_dir / "ranking_precision_at_k.png"
|
|
212
|
+
plt.tight_layout()
|
|
213
|
+
plt.savefig(path)
|
|
214
|
+
plt.close()
|
|
215
|
+
plots["precision_at_k_curve"] = str(path)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
# Cumulative gain: mean cumulative relevant count vs rank (up to max_k)
|
|
220
|
+
try:
|
|
221
|
+
max_k = int(getattr(self, "_cached_max_k", max(ks)))
|
|
222
|
+
cum = np.zeros(max_k, dtype=float)
|
|
223
|
+
for rel, ranked in zip(rel_list, rank_list):
|
|
224
|
+
for pos in range(max_k):
|
|
225
|
+
if pos < len(ranked) and ranked[pos] in rel:
|
|
226
|
+
cum[pos] += 1.0
|
|
227
|
+
cum_mean = np.cumsum(cum) / float(len(rel_list))
|
|
228
|
+
ranks = np.arange(1, max_k + 1)
|
|
229
|
+
plt.figure(figsize=(5.5, 4))
|
|
230
|
+
plt.plot(ranks, cum_mean, marker="s", linewidth=2, color="#2ca02c")
|
|
231
|
+
plt.xlabel("Rank cutoff")
|
|
232
|
+
plt.ylabel("Mean cumulative relevant retrieved")
|
|
233
|
+
plt.title("Cumulative gain (mean over users)")
|
|
234
|
+
plt.grid(True, alpha=0.3)
|
|
235
|
+
path = plot_dir / "ranking_cumulative_gain.png"
|
|
236
|
+
plt.tight_layout()
|
|
237
|
+
plt.savefig(path)
|
|
238
|
+
plt.close()
|
|
239
|
+
plots["cumulative_gain"] = str(path)
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
self.plots = plots
|
|
244
|
+
|
|
245
|
+
def _generate_insights(self) -> None:
|
|
246
|
+
insights: List[str] = []
|
|
247
|
+
ks = getattr(self, "_cached_ks", [])
|
|
248
|
+
if len(ks) >= 2:
|
|
249
|
+
k_small, k_large = ks[0], ks[-1]
|
|
250
|
+
p_small = self.metrics.get(f"precision_at_{k_small}")
|
|
251
|
+
p_large = self.metrics.get(f"precision_at_{k_large}")
|
|
252
|
+
if isinstance(p_small, (int, float)) and isinstance(p_large, (int, float)):
|
|
253
|
+
if p_large + 1e-6 < p_small * 0.85:
|
|
254
|
+
insights.append(
|
|
255
|
+
f"Precision@K drops from {k_small} to {k_large}; quality may degrade in deeper ranks."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Long-tail: users with many relevant items vs few
|
|
259
|
+
rel_list = getattr(self, "_cached_rel", [])
|
|
260
|
+
if rel_list:
|
|
261
|
+
sizes = [len(r) for r in rel_list]
|
|
262
|
+
if sizes:
|
|
263
|
+
med = float(np.median(sizes))
|
|
264
|
+
mx = max(sizes)
|
|
265
|
+
if mx > 0 and med > 0 and mx / med >= 5:
|
|
266
|
+
insights.append(
|
|
267
|
+
"Large spread in number of relevant items per user; check long-tail users separately."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
map_score = self.metrics.get("map")
|
|
271
|
+
if isinstance(map_score, (int, float)) and map_score < 0.1:
|
|
272
|
+
insights.append("Low MAP; consider improving candidate generation, re-ranking, or relevance signals.")
|
|
273
|
+
|
|
274
|
+
self.insights = insights
|