evaluma 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.
evaluma/cli.py ADDED
@@ -0,0 +1,275 @@
1
+ import os
2
+ import sys
3
+ import warnings
4
+
5
+ import click
6
+ import yaml
7
+
8
+ import evaluma
9
+
10
+
11
+ def _parse_metric_direction(ctx, param, value):
12
+ """Parse ``KEY:min`` / ``KEY:max`` tokens into a metric-direction dict.
13
+
14
+ Args:
15
+ ctx: Click context (unused; required by the callback protocol).
16
+ param: Click parameter (unused).
17
+ value: Tuple of strings, each formatted as ``"KEY:min"`` or
18
+ ``"KEY:max"``.
19
+
20
+ Returns:
21
+ dict | None: Mapping from dataset name to ``"min"`` or ``"max"``,
22
+ or ``None`` when ``value`` is empty.
23
+
24
+ Raises:
25
+ click.BadParameter: If a token is malformed or the direction is not
26
+ ``"min"`` or ``"max"``.
27
+ """
28
+ result = {}
29
+ for token in value:
30
+ if ":" not in token: # pragma: no cover
31
+ raise click.BadParameter(f"Expected KEY:min or KEY:max, got '{token}'")
32
+ key, direction = token.split(":", 1)
33
+ if direction not in ("min", "max"): # pragma: no cover
34
+ raise click.BadParameter(
35
+ f"Direction must be 'min' or 'max', got '{direction}'"
36
+ )
37
+ result[key] = direction
38
+ return result or None
39
+
40
+
41
+ def _common_options(f):
42
+ """Attach shared CLI options to a Click command."""
43
+ f = click.argument("csv_path")(f)
44
+ f = click.option("--model", default="model")(f)
45
+ f = click.option("--dataset", default="dataset")(f)
46
+ f = click.option("--metric", default="metric")(f)
47
+ f = click.option("--score", default="score")(f)
48
+ f = click.option("--config", "config_path", default=None, help="YAML config file")(
49
+ f
50
+ )
51
+ f = click.option(
52
+ "--metric-direction",
53
+ multiple=True,
54
+ callback=_parse_metric_direction,
55
+ is_eager=False,
56
+ help="KEY:min or KEY:max (repeatable)",
57
+ )(f)
58
+ f = click.option("--output", "output_dir", default=".", show_default=True)(f)
59
+ return f
60
+
61
+
62
+ def _load_bench(
63
+ csv_path,
64
+ model,
65
+ dataset,
66
+ metric,
67
+ score,
68
+ config_path,
69
+ metric_direction,
70
+ output_dir,
71
+ seed=None,
72
+ ):
73
+ """Load a CSV and return a normalized Benchmark, merging CLI args with config.
74
+
75
+ Args:
76
+ csv_path: Path to the input CSV file.
77
+ model: CLI value for the model column name.
78
+ dataset: CLI value for the dataset column name.
79
+ metric: CLI value for the metric column name.
80
+ score: CLI value for the score column name.
81
+ config_path: Optional path to a YAML config file.
82
+ metric_direction: Parsed metric-direction dict (or ``None``).
83
+ output_dir: Path to the output directory (created if absent).
84
+ seed: Optional column name for the random seed.
85
+
86
+ Returns:
87
+ Benchmark: Loaded and normalized benchmark.
88
+ """
89
+ cfg = {}
90
+ if config_path:
91
+ with open(config_path) as fh:
92
+ cfg = yaml.safe_load(fh) or {}
93
+
94
+ col_model = model if model != "model" else cfg.get("model", model)
95
+ col_dataset = dataset if dataset != "dataset" else cfg.get("dataset", dataset)
96
+ col_metric = metric if metric != "metric" else cfg.get("metric", metric)
97
+ col_score = score if score != "score" else cfg.get("score", score)
98
+ md = metric_direction or cfg.get("metric_direction") or None
99
+
100
+ raw_mtb = cfg.get("metric_type_bounds")
101
+ if raw_mtb is not None:
102
+ mtb = {k: tuple(v) for k, v in raw_mtb.items()}
103
+ else:
104
+ mtb = None
105
+
106
+ os.makedirs(output_dir, exist_ok=True)
107
+
108
+ with warnings.catch_warnings():
109
+ warnings.simplefilter("ignore", UserWarning)
110
+ if mtb is not None:
111
+ bench = evaluma.load_csv(
112
+ csv_path,
113
+ model=col_model,
114
+ dataset=col_dataset,
115
+ metric=col_metric,
116
+ score=col_score,
117
+ seed=seed,
118
+ metric_direction=md,
119
+ metric_type_bounds=mtb,
120
+ )
121
+ else:
122
+ bench = evaluma.load_csv(
123
+ csv_path,
124
+ model=col_model,
125
+ dataset=col_dataset,
126
+ metric=col_metric,
127
+ score=col_score,
128
+ seed=seed,
129
+ metric_direction=md,
130
+ norm_ref_low=0.0,
131
+ norm_ref_high=1.0,
132
+ )
133
+ return bench
134
+
135
+
136
+ def _save(result, stem, output_dir):
137
+ """Serialize a result to CSV and PNG inside ``output_dir``."""
138
+ csv_path = os.path.join(output_dir, f"{stem}.csv")
139
+ png_path = os.path.join(output_dir, f"{stem}.png")
140
+ result.table.to_csv(csv_path, index=False)
141
+ fig = result.plot()
142
+ fig.savefig(png_path, bbox_inches="tight")
143
+
144
+
145
+ @click.group()
146
+ def main():
147
+ """evaluma — ML benchmark evaluation tools."""
148
+
149
+
150
+ @main.command()
151
+ @_common_options
152
+ def report(
153
+ csv_path, model, dataset, metric, score, config_path, metric_direction, output_dir
154
+ ):
155
+ """Run all three analyses and write results to ``--output``."""
156
+ bench = _load_bench(
157
+ csv_path,
158
+ model,
159
+ dataset,
160
+ metric,
161
+ score,
162
+ config_path,
163
+ metric_direction,
164
+ output_dir,
165
+ )
166
+ _save(bench.aggregate_ranking(), "aggregate_ranking", output_dir)
167
+ _save(bench.bayesian_comparison(), "bayesian_comparison", output_dir)
168
+ _save(bench.performance_profiles(), "performance_profiles", output_dir)
169
+
170
+
171
+ @main.command()
172
+ @_common_options
173
+ @click.option("--seed", default=None, help="Column name for the random seed.")
174
+ def rank(
175
+ csv_path,
176
+ model,
177
+ dataset,
178
+ metric,
179
+ score,
180
+ config_path,
181
+ metric_direction,
182
+ output_dir,
183
+ seed,
184
+ ):
185
+ """Compute IQM rankings (requires seed column) and write iqm_ranking.{csv,png}."""
186
+ bench = _load_bench(
187
+ csv_path,
188
+ model,
189
+ dataset,
190
+ metric,
191
+ score,
192
+ config_path,
193
+ metric_direction,
194
+ output_dir,
195
+ seed=seed,
196
+ )
197
+ if bench._raw_runs is None:
198
+ click.echo(
199
+ "Error: iqm_ranking() requires multiple seeds — "
200
+ "use evaluma aggregate for single-run data.",
201
+ err=True,
202
+ )
203
+ sys.exit(1)
204
+ _save(bench.iqm_ranking(), "iqm_ranking", output_dir)
205
+
206
+
207
+ @main.command()
208
+ @_common_options
209
+ @click.option(
210
+ "--agg",
211
+ default="trimmed_mean",
212
+ show_default=True,
213
+ help="Aggregation mode: trimmed_mean, mean, or median.",
214
+ )
215
+ def aggregate(
216
+ csv_path,
217
+ model,
218
+ dataset,
219
+ metric,
220
+ score,
221
+ config_path,
222
+ metric_direction,
223
+ output_dir,
224
+ agg,
225
+ ):
226
+ """Compute point-estimate aggregate ranking and write aggregate_ranking.csv/png."""
227
+ bench = _load_bench(
228
+ csv_path,
229
+ model,
230
+ dataset,
231
+ metric,
232
+ score,
233
+ config_path,
234
+ metric_direction,
235
+ output_dir,
236
+ )
237
+ _save(bench.aggregate_ranking(agg=agg), "aggregate_ranking", output_dir)
238
+
239
+
240
+ @main.command()
241
+ @_common_options
242
+ def compare(
243
+ csv_path, model, dataset, metric, score, config_path, metric_direction, output_dir
244
+ ):
245
+ """Compute Bayesian pairwise comparisons and write results."""
246
+ bench = _load_bench(
247
+ csv_path,
248
+ model,
249
+ dataset,
250
+ metric,
251
+ score,
252
+ config_path,
253
+ metric_direction,
254
+ output_dir,
255
+ )
256
+ _save(bench.bayesian_comparison(), "bayesian_comparison", output_dir)
257
+
258
+
259
+ @main.command()
260
+ @_common_options
261
+ def profiles(
262
+ csv_path, model, dataset, metric, score, config_path, metric_direction, output_dir
263
+ ):
264
+ """Compute Dolan-Moré performance profiles and write results."""
265
+ bench = _load_bench(
266
+ csv_path,
267
+ model,
268
+ dataset,
269
+ metric,
270
+ score,
271
+ config_path,
272
+ metric_direction,
273
+ output_dir,
274
+ )
275
+ _save(bench.performance_profiles(), "performance_profiles", output_dir)
File without changes
@@ -0,0 +1,44 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy.stats import trim_mean
4
+
5
+ from evaluma.results import AggregateResult
6
+
7
+ _AGG_MODES = {"trimmed_mean", "mean", "median"}
8
+
9
+
10
+ def compute_aggregate(
11
+ scores_matrix: pd.DataFrame, agg="trimmed_mean"
12
+ ) -> AggregateResult:
13
+ """Compute a point-estimate descriptive ranking from a normalized score matrix.
14
+
15
+ Args:
16
+ scores_matrix: Normalized model × dataset score matrix.
17
+ agg: Aggregation mode — one of ``"trimmed_mean"``, ``"mean"``,
18
+ ``"median"``.
19
+
20
+ Returns:
21
+ AggregateResult: Result with ``.table`` sorted descending by ``score``.
22
+
23
+ Raises:
24
+ ValueError: If ``agg`` is not one of the supported modes.
25
+ """
26
+ if agg not in _AGG_MODES:
27
+ raise ValueError(f"agg must be one of {sorted(_AGG_MODES)!r}, got {agg!r}")
28
+
29
+ models = scores_matrix.index.tolist()
30
+ data = scores_matrix.values # shape (n_models, n_datasets)
31
+
32
+ if agg == "trimmed_mean":
33
+ scores = np.array([trim_mean(row, proportiontocut=0.25) for row in data])
34
+ elif agg == "mean":
35
+ scores = data.mean(axis=1)
36
+ else:
37
+ scores = np.median(data, axis=1)
38
+
39
+ table = (
40
+ pd.DataFrame({"model": models, "score": scores})
41
+ .sort_values("score", ascending=False)
42
+ .reset_index(drop=True)
43
+ )
44
+ return AggregateResult(table)
@@ -0,0 +1,64 @@
1
+ from itertools import combinations
2
+
3
+ import baycomp
4
+ import pandas as pd
5
+
6
+ from evaluma.results import BayesianResult
7
+
8
+
9
+ def compute_bayesian(
10
+ scores_matrix: pd.DataFrame,
11
+ *,
12
+ rope=0.01,
13
+ reference=None,
14
+ pairs=None,
15
+ random_state=None,
16
+ ) -> BayesianResult:
17
+ """Compute pairwise Bayesian comparisons using a signed-rank test.
18
+
19
+ For each pair, :func:`baycomp.two_on_multiple` returns the posterior
20
+ probability that model A is better, that they are practically
21
+ equivalent (within ``rope``), and that model B is better.
22
+
23
+ Args:
24
+ scores_matrix: Normalized model × dataset score matrix.
25
+ rope: Region of practical equivalence half-width. Differences
26
+ smaller than ``rope`` are treated as ties.
27
+ reference: If provided, only compare every other model against this
28
+ reference model.
29
+ pairs: Explicit list of ``(model_a, model_b)`` pairs to test.
30
+ Overrides ``reference``.
31
+ random_state: Seed forwarded to baycomp.
32
+
33
+ Returns:
34
+ BayesianResult: Result with ``.table`` containing columns
35
+ ``model_a``, ``model_b``, ``p_a_better``, ``p_equiv``,
36
+ ``p_b_better``.
37
+ """
38
+ models = scores_matrix.index.tolist()
39
+
40
+ if pairs is not None:
41
+ pair_list = list(pairs)
42
+ elif reference is not None:
43
+ pair_list = [(reference, m) for m in models if m != reference]
44
+ else:
45
+ pair_list = list(combinations(models, 2))
46
+
47
+ rows = []
48
+ for model_a, model_b in pair_list:
49
+ vec_a = scores_matrix.loc[model_a].values.astype(float)
50
+ vec_b = scores_matrix.loc[model_b].values.astype(float)
51
+ p_a, p_eq, p_b = baycomp.two_on_multiple(
52
+ vec_a, vec_b, rope=rope, random_state=random_state
53
+ )
54
+ rows.append(
55
+ {
56
+ "model_a": model_a,
57
+ "model_b": model_b,
58
+ "p_a_better": p_a,
59
+ "p_equiv": p_eq,
60
+ "p_b_better": p_b,
61
+ }
62
+ )
63
+
64
+ return BayesianResult(pd.DataFrame(rows), reference=reference)
evaluma/methods/iqm.py ADDED
@@ -0,0 +1,93 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy.stats import trim_mean
4
+
5
+ from evaluma.results import IQMResult
6
+
7
+
8
+ def compute_iqm(raw_runs, norm_bounds, n_bootstrap=1000, random_state=None):
9
+ """Compute Agarwal IQM on the flat run×dataset array with stratified bootstrap CIs.
10
+
11
+ Implements the IQM from Agarwal et al. 2021 (rliable): trim the outer 25%
12
+ of the concatenated per-dataset, per-seed normalized scores and average the
13
+ remainder. Bootstrap CIs are stratified — seeds are resampled independently
14
+ within each dataset stratum.
15
+
16
+ Args:
17
+ raw_runs: Long-format DataFrame with columns
18
+ ``["model", "dataset", "seed", "score"]``.
19
+ norm_bounds: ``(low, high, metric_direction)`` where ``low`` and
20
+ ``high`` are per-dataset ``pd.Series`` of normalization bounds
21
+ and ``metric_direction`` is a ``{dataset: "min"|"max"}`` dict
22
+ (or ``None``).
23
+ n_bootstrap: Number of stratified bootstrap replicates for the 95% CI.
24
+ random_state: Seed for :func:`numpy.random.default_rng`.
25
+
26
+ Returns:
27
+ IQMResult: Result with ``.table`` sorted descending by IQM.
28
+ """
29
+ low, high, metric_direction = norm_bounds
30
+ rng = np.random.default_rng(random_state)
31
+
32
+ models = raw_runs["model"].unique().tolist()
33
+ datasets = raw_runs["dataset"].unique().tolist()
34
+
35
+ def _norm_score(score, dataset):
36
+ lo = float(low[dataset])
37
+ hi = float(high[dataset])
38
+ if metric_direction and metric_direction.get(dataset) == "min":
39
+ return (hi - score) / (hi - lo)
40
+ return (score - lo) / (hi - lo)
41
+
42
+ # Build per-(model, dataset) arrays of normalized scores
43
+ per_dataset = {m: [] for m in models}
44
+ for d in datasets:
45
+ lo = float(low[d])
46
+ hi = float(high[d])
47
+ is_min = metric_direction and metric_direction.get(d) == "min"
48
+ for m in models:
49
+ mask = (raw_runs["model"] == m) & (raw_runs["dataset"] == d)
50
+ scores = raw_runs.loc[mask, "score"].values.astype(float)
51
+ if is_min:
52
+ norm = (hi - scores) / (hi - lo)
53
+ else:
54
+ norm = (scores - lo) / (hi - lo)
55
+ per_dataset[m].append(norm)
56
+
57
+ # Point estimates: flat-array IQM
58
+ iqms_arr = np.array(
59
+ [trim_mean(np.concatenate(per_dataset[m]), 0.25) for m in models]
60
+ )
61
+
62
+ if n_bootstrap == 0:
63
+ table = (
64
+ pd.DataFrame(
65
+ {"model": models, "IQM": iqms_arr, "CI_low": np.nan, "CI_high": np.nan}
66
+ )
67
+ .sort_values("IQM", ascending=False)
68
+ .reset_index(drop=True)
69
+ )
70
+ return IQMResult(table)
71
+
72
+ # Stratified bootstrap: resample seeds within each dataset independently
73
+ boot_iqms = np.empty((n_bootstrap, len(models)))
74
+ for b in range(n_bootstrap):
75
+ for mi, m in enumerate(models):
76
+ resampled = []
77
+ for d_scores in per_dataset[m]:
78
+ n = len(d_scores)
79
+ idx = rng.integers(0, n, size=n)
80
+ resampled.append(d_scores[idx])
81
+ boot_iqms[b, mi] = trim_mean(np.concatenate(resampled), 0.25)
82
+
83
+ ci_low = np.percentile(boot_iqms, 2.5, axis=0)
84
+ ci_high = np.percentile(boot_iqms, 97.5, axis=0)
85
+
86
+ table = (
87
+ pd.DataFrame(
88
+ {"model": models, "IQM": iqms_arr, "CI_low": ci_low, "CI_high": ci_high}
89
+ )
90
+ .sort_values("IQM", ascending=False)
91
+ .reset_index(drop=True)
92
+ )
93
+ return IQMResult(table)
@@ -0,0 +1,72 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from evaluma.results import ProfileResult
5
+
6
+
7
+ def compute_profiles(
8
+ scores_matrix: pd.DataFrame,
9
+ metric_direction: dict | None = None,
10
+ ) -> ProfileResult:
11
+ """Compute Dolan-Moré performance profiles.
12
+
13
+ Ratios are computed directly from raw scores without normalization:
14
+
15
+ - **max metrics** (higher is better): r_ij = best_j / score_ij
16
+ - **min metrics** (lower is better): r_ij = score_ij / best_j
17
+
18
+ where best_j is the best score across all models on dataset j. Both
19
+ definitions yield r_ij ≥ 1, with r_ij = 1 when model i is the best on
20
+ dataset j.
21
+
22
+ The tau grid uses the exact observed ratio values as breakpoints, giving
23
+ the exact Dolan-Moré step function. The plot renders on a log₁₀(τ) axis,
24
+ following ML-GYM (Batra et al., 2025) and the AutoML Decathlon (Roberts
25
+ et al., 2022), which extended Dolan & Moré (2002). Use
26
+ ``ProfileResult.aup`` for the scalar Area Under the Profile summary.
27
+
28
+ Args:
29
+ scores_matrix: Raw model × dataset score matrix. All values must be
30
+ strictly positive.
31
+ metric_direction: Dict mapping dataset (column) names to ``"min"`` or
32
+ ``"max"``. Datasets absent from the dict default to ``"max"``.
33
+
34
+ Returns:
35
+ ProfileResult with ``.table`` (long-format: ``tau``, ``model``,
36
+ ``fraction_within_tau``).
37
+
38
+ Raises:
39
+ ValueError: If any value in ``scores_matrix`` is zero or negative.
40
+ """
41
+ if (scores_matrix.values <= 0).any():
42
+ raise ValueError(
43
+ "Cannot compute performance profiles: score of 0 or below detected. "
44
+ "All scores must be strictly positive."
45
+ )
46
+
47
+ metric_direction = metric_direction or {}
48
+ models = scores_matrix.index.tolist()
49
+ data = scores_matrix.values.astype(float) # (n_models, n_datasets)
50
+ datasets = scores_matrix.columns.tolist()
51
+
52
+ # Compute per-dataset ratios respecting direction.
53
+ ratios = np.ones_like(data)
54
+ for j, dataset in enumerate(datasets):
55
+ col = data[:, j]
56
+ if metric_direction.get(dataset) == "min":
57
+ best = col.min()
58
+ ratios[:, j] = col / best
59
+ else:
60
+ best = col.max()
61
+ ratios[:, j] = best / col
62
+
63
+ # Exact breakpoints: every observed ratio value, plus 1.0 as anchor.
64
+ tau_grid = np.sort(np.unique(np.concatenate([[1.0], ratios.flatten()])))
65
+
66
+ rows = []
67
+ for tau in tau_grid:
68
+ for i, model in enumerate(models):
69
+ frac = (ratios[i] <= tau).mean()
70
+ rows.append({"tau": tau, "model": model, "fraction_within_tau": frac})
71
+
72
+ return ProfileResult(pd.DataFrame(rows))
@@ -0,0 +1,69 @@
1
+ """Metric name registry: maps names to optimization direction and natural bounds."""
2
+
3
+ KNOWN_METRICS = {
4
+ # name: (direction, (natural_low, natural_high)) — None high: user must specify
5
+ "accuracy": ("max", (0.0, 1.0)),
6
+ "acc": ("max", (0.0, 1.0)),
7
+ "iou": ("max", (0.0, 1.0)),
8
+ "miou": ("max", (0.0, 1.0)),
9
+ "f1": ("max", (0.0, 1.0)),
10
+ "auc": ("max", (0.0, 1.0)),
11
+ "ap": ("max", (0.0, 1.0)),
12
+ "map": ("max", (0.0, 1.0)),
13
+ "precision": ("max", (0.0, 1.0)),
14
+ "recall": ("max", (0.0, 1.0)),
15
+ "r2": ("max", (0.0, 1.0)),
16
+ "rmse": ("min", (0.0, None)),
17
+ "mae": ("min", (0.0, None)),
18
+ "mse": ("min", (0.0, None)),
19
+ "mape": ("min", (0.0, None)),
20
+ }
21
+
22
+ _KNOWN_LIST = ", ".join(sorted(KNOWN_METRICS))
23
+
24
+
25
+ def get_direction(metric_name: str) -> str:
26
+ """Return ``"min"`` or ``"max"`` for a known metric name.
27
+
28
+ Args:
29
+ metric_name: Metric identifier (case-insensitive).
30
+
31
+ Returns:
32
+ str: ``"min"`` (lower is better) or ``"max"`` (higher is better).
33
+
34
+ Raises:
35
+ ValueError: If ``metric_name`` is not in the registry. The message
36
+ lists all known names and suggests using ``metric_direction`` for
37
+ custom metrics.
38
+ """
39
+ name = metric_name.lower()
40
+ if name not in KNOWN_METRICS:
41
+ raise ValueError(
42
+ f"Unknown metric '{metric_name}'. Known metrics: {_KNOWN_LIST}. "
43
+ f"For custom metrics, specify direction via metric_direction and "
44
+ f"bounds via metric_type_bounds."
45
+ )
46
+ return KNOWN_METRICS[name][0]
47
+
48
+
49
+ def get_natural_bounds(metric_name: str) -> tuple:
50
+ """Return ``(natural_low, natural_high)`` for a known metric name.
51
+
52
+ ``natural_high`` is ``None`` for unbounded metrics (e.g. ``rmse``, ``mae``),
53
+ meaning the upper bound must be supplied by the caller.
54
+
55
+ Args:
56
+ metric_name: Metric identifier (case-insensitive).
57
+
58
+ Returns:
59
+ tuple: ``(float, float | None)`` natural lower and upper bounds.
60
+
61
+ Raises:
62
+ ValueError: If ``metric_name`` is not in the registry.
63
+ """
64
+ name = metric_name.lower()
65
+ if name not in KNOWN_METRICS:
66
+ raise ValueError(
67
+ f"Unknown metric '{metric_name}'. Known metrics: {_KNOWN_LIST}."
68
+ )
69
+ return KNOWN_METRICS[name][1]