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.
@@ -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,5 @@
1
+ from .text_classification import TextClassificationReport
2
+ from .text_generation import TextGenerationReport
3
+
4
+ __all__ = ["TextClassificationReport", "TextGenerationReport"]
5
+
@@ -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,3 @@
1
+ from .report import RankingReport
2
+
3
+ __all__ = ["RankingReport"]
@@ -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
@@ -0,0 +1,4 @@
1
+ from .report import RegressionReport
2
+
3
+ __all__ = ["RegressionReport"]
4
+