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/__init__.py ADDED
@@ -0,0 +1,258 @@
1
+ import pandas as pd
2
+
3
+ from evaluma._version import __version__ # noqa: F401
4
+ from evaluma.benchmark import Benchmark # noqa: F401
5
+
6
+
7
+ def load_df(
8
+ df,
9
+ *,
10
+ model="model",
11
+ dataset="dataset",
12
+ metric="metric",
13
+ score="score",
14
+ seed=None,
15
+ metric_type_bounds=None,
16
+ norm_ref_low=None,
17
+ norm_ref_high=None,
18
+ metric_direction=None,
19
+ drop_incomplete=False,
20
+ ):
21
+ """Load a DataFrame and return a ready-to-use Benchmark object.
22
+
23
+ Args:
24
+ df: A pandas DataFrame in long format (one row per model/dataset pair).
25
+ To load from a CSV file, use :func:`evaluma.load_csv` instead.
26
+ model: Column name for the model identifier.
27
+ dataset: Column name for the dataset identifier.
28
+ metric: Column name for the metric identifier.
29
+ score: Column name for the score values.
30
+ seed: Column name for the random seed. When provided, all seed rows
31
+ are preserved and a ``seed`` column is included in the loaded
32
+ DataFrame.
33
+ metric_type_bounds: Dict mapping metric names to ``(low, high)``
34
+ bound tuples. ``high`` may be a model name string, resolved
35
+ per-dataset to that model's score. When provided, ``norm_ref_low``
36
+ and ``norm_ref_high`` must be ``None``. Metric direction is inferred
37
+ from the built-in registry; unknown metrics raise ``ValueError``.
38
+ Bounded metrics not listed here (e.g. accuracy, iou, f1) use their
39
+ natural ``[0, 1]`` bounds automatically. Unbounded metrics (rmse,
40
+ mae, mse) must be listed; omitting them raises ``ValueError``.
41
+ norm_ref_low: Lower normalization reference — scalar, model name,
42
+ or per-dataset dict. If ``None``, the per-dataset minimum is
43
+ used and a ``UserWarning`` is emitted. Cannot be combined with
44
+ ``metric_type_bounds``.
45
+ norm_ref_high: Upper normalization reference, same format as
46
+ ``norm_ref_low``. If ``None``, the per-dataset maximum is used.
47
+ Cannot be combined with ``metric_type_bounds``.
48
+ metric_direction: Dict mapping dataset names to ``"min"`` or
49
+ ``"max"``. When used with ``metric_type_bounds``, these entries
50
+ take precedence over the registry-inferred direction. Without
51
+ ``metric_type_bounds``, datasets mapped to ``"min"`` are negated
52
+ before normalization so that higher is always better.
53
+ drop_incomplete: If ``True``, silently drop models with missing
54
+ scores instead of raising.
55
+
56
+ Returns:
57
+ Benchmark: Normalized benchmark ready for analysis.
58
+
59
+ Raises:
60
+ TypeError: If ``df`` is not a pandas DataFrame.
61
+ ValueError: If ``metric_type_bounds`` is provided together with
62
+ ``norm_ref_low`` or ``norm_ref_high``.
63
+ ValueError: If the data contains more than one metric per
64
+ (model, dataset) pair, or if the score matrix is incomplete
65
+ and ``drop_incomplete`` is ``False``.
66
+ ValueError: If a metric referenced by a dataset is not in the
67
+ registry and not covered by ``metric_type_bounds``.
68
+ ValueError: If a regression metric (rmse, mae, mse) is present but
69
+ no upper bound is specified in ``metric_type_bounds``.
70
+ """
71
+ if not isinstance(df, pd.DataFrame):
72
+ raise TypeError(
73
+ f"load_df() expects a pandas DataFrame, got {type(df).__name__}. "
74
+ "To load from a file, use evaluma.load_csv()."
75
+ )
76
+
77
+ if metric_type_bounds is not None and (
78
+ norm_ref_low is not None or norm_ref_high is not None
79
+ ):
80
+ raise ValueError(
81
+ "metric_type_bounds cannot be combined with norm_ref_low or norm_ref_high. "
82
+ "Use metric_type_bounds to specify all bounds, "
83
+ "or use norm_ref_low/norm_ref_high."
84
+ )
85
+
86
+ df = df.copy()
87
+ rename = {model: "model", dataset: "dataset", metric: "metric", score: "score"}
88
+ if seed is not None:
89
+ rename[seed] = "seed"
90
+ df = df.rename(columns=rename)
91
+
92
+ metrics_per_dataset = df.groupby(["model", "dataset"])["metric"].nunique()
93
+ if (metrics_per_dataset > 1).any():
94
+ raise ValueError("more than one metric found per (model, dataset) cell")
95
+
96
+ check_df = df.drop_duplicates(subset=["model", "dataset"])
97
+ pivot = check_df.pivot(index="model", columns="dataset", values="score")
98
+ missing = pivot.isna()
99
+ if missing.to_numpy().any():
100
+ missing_cells = [
101
+ (m, d)
102
+ for m in pivot.index
103
+ for d in pivot.columns
104
+ if pd.isna(pivot.loc[m, d])
105
+ ]
106
+ if drop_incomplete:
107
+ complete_models = pivot.index[~missing.any(axis=1)]
108
+ df = df[df["model"].isin(complete_models)].reset_index(drop=True)
109
+ else:
110
+ cell_str = ", ".join(f"({m}, {d})" for m, d in missing_cells)
111
+ raise ValueError(f"Incomplete score matrix: missing cells {cell_str}")
112
+
113
+ if seed is not None:
114
+ df = df[["model", "dataset", "metric", "score", "seed"]]
115
+ else:
116
+ df = df[["model", "dataset", "metric", "score"]]
117
+
118
+ raw_runs = None
119
+ if seed is not None:
120
+ raw_runs = df[["model", "dataset", "seed", "score"]].reset_index(drop=True)
121
+ raw_matrix = (
122
+ df.groupby(["model", "dataset"], as_index=False)["score"]
123
+ .mean()
124
+ .pivot(index="model", columns="dataset", values="score")
125
+ )
126
+ else:
127
+ raw_matrix = df.pivot(index="model", columns="dataset", values="score")
128
+ raw_matrix.columns.name = None
129
+
130
+ if metric_type_bounds is not None:
131
+ dataset_metric_map = (
132
+ df.drop_duplicates("dataset").set_index("dataset")["metric"].to_dict()
133
+ )
134
+ norm_ref_low, norm_ref_high, metric_direction = _resolve_metric_type_bounds(
135
+ metric_type_bounds, dataset_metric_map, raw_matrix, metric_direction
136
+ )
137
+
138
+ return Benchmark(
139
+ raw_matrix,
140
+ norm_ref_low=norm_ref_low,
141
+ norm_ref_high=norm_ref_high,
142
+ metric_direction=metric_direction,
143
+ raw_runs=raw_runs,
144
+ )
145
+
146
+
147
+ def load_csv(
148
+ path,
149
+ *,
150
+ model="model",
151
+ dataset="dataset",
152
+ metric="metric",
153
+ score="score",
154
+ seed=None,
155
+ metric_type_bounds=None,
156
+ norm_ref_low=None,
157
+ norm_ref_high=None,
158
+ metric_direction=None,
159
+ drop_incomplete=False,
160
+ ):
161
+ """Load a benchmark CSV file and return a ready-to-use Benchmark object.
162
+
163
+ Args:
164
+ path: Path to the CSV file.
165
+ model: Column name for the model identifier.
166
+ dataset: Column name for the dataset identifier.
167
+ metric: Column name for the metric identifier.
168
+ score: Column name for the score values.
169
+ seed: Column name for the random seed.
170
+ metric_type_bounds: See :func:`evaluma.load_df`.
171
+ norm_ref_low: See :func:`evaluma.load_df`.
172
+ norm_ref_high: See :func:`evaluma.load_df`.
173
+ metric_direction: See :func:`evaluma.load_df`.
174
+ drop_incomplete: See :func:`evaluma.load_df`.
175
+
176
+ Returns:
177
+ Benchmark: Normalized benchmark ready for analysis.
178
+ """
179
+ df = pd.read_csv(path)
180
+ return load_df(
181
+ df,
182
+ model=model,
183
+ dataset=dataset,
184
+ metric=metric,
185
+ score=score,
186
+ seed=seed,
187
+ metric_type_bounds=metric_type_bounds,
188
+ norm_ref_low=norm_ref_low,
189
+ norm_ref_high=norm_ref_high,
190
+ metric_direction=metric_direction,
191
+ drop_incomplete=drop_incomplete,
192
+ )
193
+
194
+
195
+ def _resolve_metric_type_bounds(
196
+ metric_type_bounds, dataset_metric_map, raw_matrix, metric_direction_override
197
+ ):
198
+ """Resolve per-dataset normalization bounds and directions from the metric registry.
199
+
200
+ For each dataset, consults ``metric_type_bounds`` first, then falls back to the
201
+ built-in registry for metrics with natural bounds. Raises if an unbounded metric
202
+ (rmse, mae, mse) has no entry in ``metric_type_bounds``.
203
+
204
+ Args:
205
+ metric_type_bounds: Dict mapping metric names → ``(low, high)`` tuples.
206
+ dataset_metric_map: Dict mapping dataset names → metric name strings.
207
+ raw_matrix: Model × dataset score DataFrame (used to resolve model-name bounds).
208
+ metric_direction_override: Optional dict mapping dataset names →
209
+ ``"min"``/``"max"``; these entries override registry-inferred directions.
210
+
211
+ Returns:
212
+ tuple: ``(norm_ref_low, norm_ref_high, metric_direction)`` where the first
213
+ two are
214
+ ``pd.Series`` keyed by dataset and the last is a dict (or ``None`` if empty).
215
+ """
216
+ from evaluma.metric_registry import get_direction, get_natural_bounds
217
+
218
+ mtb_lower = {k.lower(): v for k, v in metric_type_bounds.items()}
219
+
220
+ low_dict = {}
221
+ high_dict = {}
222
+ direction_dict = {}
223
+
224
+ for dataset, metric_name in dataset_metric_map.items():
225
+ metric_lower = metric_name.lower()
226
+
227
+ if metric_lower in mtb_lower:
228
+ low, high = mtb_lower[metric_lower]
229
+ if isinstance(high, str):
230
+ model_name = high
231
+ if model_name not in raw_matrix.index:
232
+ raise ValueError(
233
+ f"Reference model '{model_name}' not found in score matrix. "
234
+ f"Available models: {list(raw_matrix.index)}"
235
+ )
236
+ high = raw_matrix.loc[model_name, dataset]
237
+ low_dict[dataset] = float(low)
238
+ high_dict[dataset] = float(high)
239
+ direction_dict[dataset] = get_direction(metric_lower)
240
+ else:
241
+ direction = get_direction(metric_lower) # raises ValueError for unknowns
242
+ nat_low, nat_high = get_natural_bounds(metric_lower)
243
+ if nat_high is None:
244
+ raise ValueError(
245
+ f"Metric '{metric_name}' on dataset '{dataset}' has no natural "
246
+ f"upper bound: add '{metric_lower}' to metric_type_bounds "
247
+ f"in your config."
248
+ )
249
+ low_dict[dataset] = nat_low
250
+ high_dict[dataset] = nat_high
251
+ direction_dict[dataset] = direction
252
+
253
+ direction_dict.update(metric_direction_override or {})
254
+ return (
255
+ pd.Series(low_dict),
256
+ pd.Series(high_dict),
257
+ direction_dict or None,
258
+ )
evaluma/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
evaluma/benchmark.py ADDED
@@ -0,0 +1,211 @@
1
+ from functools import cached_property
2
+
3
+ import pandas as pd
4
+
5
+ from evaluma.normalize import normalize
6
+
7
+
8
+ class Benchmark:
9
+ """Container for a normalized model-vs-dataset score matrix.
10
+
11
+ After construction the normalized scores are available as ``scores_``.
12
+ Use the analysis methods to compute rankings, comparisons, and profiles.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ raw_matrix: pd.DataFrame,
18
+ *,
19
+ norm_ref_low=None,
20
+ norm_ref_high=None,
21
+ metric_direction=None,
22
+ raw_runs=None,
23
+ ):
24
+ """Initialize and normalize the score matrix.
25
+
26
+ Args:
27
+ raw_matrix: Model × dataset score matrix (models as row index,
28
+ datasets as columns).
29
+ norm_ref_low: Lower normalization reference — scalar, model
30
+ name, or per-dataset dict. ``None`` triggers data-dependent
31
+ bounds and a ``UserWarning``.
32
+ norm_ref_high: Upper normalization reference, same format as
33
+ ``norm_ref_low``.
34
+ metric_direction: Dict mapping dataset names to ``"min"`` or
35
+ ``"max"``; datasets mapped to ``"min"`` are negated before
36
+ normalization.
37
+ raw_runs: Long-format DataFrame with columns
38
+ ``["model", "dataset", "seed", "score"]`` containing
39
+ per-seed scores. When provided, ``iqm_ranking()`` uses
40
+ stratified bootstrap over seeds.
41
+ """
42
+ self._raw = raw_matrix
43
+ self._norm_ref_low = norm_ref_low
44
+ self._norm_ref_high = norm_ref_high
45
+ self._metric_direction = metric_direction
46
+ self._raw_runs = raw_runs
47
+
48
+ def _normalize(self, matrix):
49
+ import warnings
50
+
51
+ with warnings.catch_warnings():
52
+ if self._norm_ref_low is not None or self._norm_ref_high is not None:
53
+ warnings.simplefilter("ignore", UserWarning)
54
+ return normalize(
55
+ matrix,
56
+ norm_ref_low=self._norm_ref_low,
57
+ norm_ref_high=self._norm_ref_high,
58
+ metric_direction=self._metric_direction,
59
+ )
60
+
61
+ @cached_property
62
+ def scores_(self):
63
+ """Normalized model × dataset score matrix."""
64
+ return self._normalize(self._raw)
65
+
66
+ def _new(self, raw_matrix, raw_runs=None):
67
+ return Benchmark(
68
+ raw_matrix,
69
+ norm_ref_low=self._norm_ref_low,
70
+ norm_ref_high=self._norm_ref_high,
71
+ metric_direction=self._metric_direction,
72
+ raw_runs=raw_runs,
73
+ )
74
+
75
+ def select_models(self, models):
76
+ """Subset the benchmark to the given models.
77
+
78
+ Args:
79
+ models: List of model names to retain.
80
+
81
+ Returns:
82
+ Benchmark: New benchmark containing only the selected models.
83
+ """
84
+ raw_runs = None
85
+ if self._raw_runs is not None:
86
+ raw_runs = self._raw_runs[self._raw_runs["model"].isin(models)].reset_index(
87
+ drop=True
88
+ )
89
+ return self._new(self._raw.loc[models], raw_runs=raw_runs)
90
+
91
+ def select_datasets(self, datasets):
92
+ """Subset the benchmark to the given datasets.
93
+
94
+ Args:
95
+ datasets: List of dataset names to retain.
96
+
97
+ Returns:
98
+ Benchmark: New benchmark containing only the selected datasets.
99
+ """
100
+ raw_runs = None
101
+ if self._raw_runs is not None:
102
+ raw_runs = self._raw_runs[
103
+ self._raw_runs["dataset"].isin(datasets)
104
+ ].reset_index(drop=True)
105
+ return self._new(self._raw[datasets], raw_runs=raw_runs)
106
+
107
+ def drop_incomplete(self):
108
+ """Remove models that have missing scores for any dataset.
109
+
110
+ Returns:
111
+ Benchmark: New benchmark with incomplete models removed.
112
+ """
113
+ complete = self._raw.index[~self._raw.isna().any(axis=1)]
114
+ raw_runs = None
115
+ if self._raw_runs is not None:
116
+ raw_runs = self._raw_runs[
117
+ self._raw_runs["model"].isin(complete)
118
+ ].reset_index(drop=True)
119
+ return self._new(self._raw.loc[complete], raw_runs=raw_runs)
120
+
121
+ def iqm_ranking(self, n_bootstrap=1000, random_state=None):
122
+ """Compute IQM rankings with stratified bootstrap confidence intervals.
123
+
124
+ Implements the Agarwal et al. 2021 (rliable) IQM on the flat
125
+ run×dataset score array. Requires multiple seeds; use
126
+ ``aggregate_ranking()`` for single-run data.
127
+
128
+ Args:
129
+ n_bootstrap: Number of bootstrap samples for the 95 % CI.
130
+ random_state: Seed for the random number generator.
131
+
132
+ Returns:
133
+ IQMResult: Result with ``.table`` and ``.plot()``.
134
+
135
+ Raises:
136
+ ValueError: If no seed data is available (``_raw_runs is None``).
137
+ """
138
+ if self._raw_runs is None:
139
+ raise ValueError(
140
+ "iqm_ranking() requires multiple seeds — "
141
+ "use aggregate_ranking() for single-run data."
142
+ )
143
+ from evaluma.methods.iqm import compute_iqm
144
+ from evaluma.normalize import _resolve_bound
145
+
146
+ low = _resolve_bound(self._raw, self._norm_ref_low, use_min=True)
147
+ high = _resolve_bound(self._raw, self._norm_ref_high, use_min=False)
148
+ return compute_iqm(
149
+ self._raw_runs,
150
+ norm_bounds=(low, high, self._metric_direction),
151
+ n_bootstrap=n_bootstrap,
152
+ random_state=random_state,
153
+ )
154
+
155
+ def aggregate_ranking(self, agg="trimmed_mean"):
156
+ """Compute a point-estimate descriptive ranking (no CI).
157
+
158
+ Works on any benchmark regardless of whether seed data is present.
159
+
160
+ Args:
161
+ agg: Aggregation mode — ``"trimmed_mean"`` (default), ``"mean"``,
162
+ or ``"median"``.
163
+
164
+ Returns:
165
+ AggregateResult: Result with ``.table`` and ``.plot()``.
166
+
167
+ Raises:
168
+ ValueError: If ``agg`` is not a supported mode.
169
+ """
170
+ from evaluma.methods.aggregate import compute_aggregate
171
+
172
+ return compute_aggregate(self.scores_, agg=agg)
173
+
174
+ def bayesian_comparison(
175
+ self, rope=0.01, reference=None, pairs=None, random_state=None
176
+ ):
177
+ """Compute pairwise Bayesian comparisons via signed-rank test.
178
+
179
+ Args:
180
+ rope: Region of practical equivalence half-width.
181
+ reference: If given, only compare each other model against this
182
+ one.
183
+ pairs: Explicit list of ``(model_a, model_b)`` pairs to test.
184
+ Overrides ``reference``.
185
+ random_state: Seed for baycomp's sampler.
186
+
187
+ Returns:
188
+ BayesianResult: Result with ``.table`` and ``.plot()``.
189
+ """
190
+ from evaluma.methods.bayesian import compute_bayesian
191
+
192
+ return compute_bayesian(
193
+ self.scores_,
194
+ rope=rope,
195
+ reference=reference,
196
+ pairs=pairs,
197
+ random_state=random_state,
198
+ )
199
+
200
+ def performance_profiles(self):
201
+ """Compute Dolan-Moré performance profiles.
202
+
203
+ Returns:
204
+ ProfileResult: Result with ``.table`` and ``.plot()``.
205
+
206
+ Raises:
207
+ ValueError: If any raw score is zero or negative.
208
+ """
209
+ from evaluma.methods.profiles import compute_profiles
210
+
211
+ return compute_profiles(self._raw, metric_direction=self._metric_direction)