Skip to content

Commit f32981d

Browse files
authored
Merge pull request #983 from sushobhan2024/feature/metric_clean_up
Metric interface clean up
2 parents 61dc1b4 + 963d667 commit f32981d

7 files changed

Lines changed: 19 additions & 196 deletions

File tree

docs/releases/2026.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ Breaking Changes
2121
- LensKit now requires Python 3.12 or newer, along with NumPy 2.x, Pandas 2.3 or
2222
newer, and SciPy 1.13 or newer (see :ref:`dep-policy`, :pr:`954`).
2323
- We no longer publish 32-bit binary wheels.
24+
- Removed ``DecomposedMetric``, as the :class:`~lenskit.metrics.Metric` interface
25+
is now decomposed. All metrics based on listwise measurements or intermediate
26+
results should directly extend from ``Metric`` or :class:`~lenskit.metrics.ListMetric`.
27+
(:pr:`983`)
28+
- ``GlobalMetric`` no longer inherits from ``Metric``, and may be removed in a future release.
2429

2530
Minor Changes
2631
-------------

src/lenskit/metrics/_base.py

Lines changed: 1 addition & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def summarize(
154154
}
155155

156156

157-
class GlobalMetric(Metric):
157+
class GlobalMetric:
158158
"""
159159
Base class for metrics that measure entire runs at a time.
160160
@@ -172,71 +172,3 @@ def measure_run(self, output: ItemListCollection, test: ItemListCollection, /) -
172172
Individual metric classes need to implement this method.
173173
"""
174174
raise NotImplementedError() # pragma: no cover
175-
176-
def measure_list(self, output: ItemList, test: ItemList, /) -> Any:
177-
raise NotImplementedError("Global metrics don't support per-list measurement")
178-
179-
def summarize(self, values: list[Any] | pa.Array | pa.ChunkedArray, /) -> float:
180-
raise NotImplementedError("Global metrics should implement measure_run instead")
181-
182-
183-
class DecomposedMetric(Metric):
184-
"""
185-
Deprecated base class for decomposed metrics.
186-
187-
.. deprecated:: 2025.4
188-
This class is deprecated and its functionality has been moved to :class:`Metric`.
189-
It is scheduled for removal in 2026.
190-
191-
Base class for metrics that measure entire runs through flexible
192-
aggregations of per-list intermediate measurements. They can optionally
193-
extract individual-list metrics from the per-list measurements.
194-
195-
Stability:
196-
Full
197-
"""
198-
199-
def measure_list(self, output: ItemList, test: ItemList, /) -> Any:
200-
return self.compute_list_data(output, test)
201-
202-
def extract_list_metrics(self, data: Any, /) -> float | None:
203-
return self.extract_list_metric(data)
204-
205-
def summarize(self, values: list[Any] | pa.Array | pa.ChunkedArray, /) -> dict[str, float]:
206-
if isinstance(values, (pa.Array, pa.ChunkedArray)):
207-
values = values.to_pylist()
208-
result = self.global_aggregate(values)
209-
if isinstance(result, (float, int, np.floating, np.integer)):
210-
return {"value": float(result)}
211-
return result
212-
213-
@abstractmethod
214-
def compute_list_data(self, output: ItemList, test: ItemList, /) -> Any:
215-
"""
216-
Compute measurements for a single list.
217-
218-
Use `measure_list` in `Metric` for new implementations.
219-
"""
220-
raise NotImplementedError() # pragma: no cover
221-
222-
def extract_list_metric(self, data: Any, /) -> float | None:
223-
"""
224-
Extract a single-list metric from the per-list measurement result (if
225-
applicable).
226-
227-
Returns:
228-
The per-list metric, or ``None`` if this metric does not compute
229-
per-list metrics.
230-
231-
Implement :meth:`Metric.extract_list_metrics` in new implementations.
232-
"""
233-
return None
234-
235-
@abstractmethod
236-
def global_aggregate(self, values: list[Any], /) -> float | dict[str, float]:
237-
"""
238-
Aggregate list metrics to compute a global value.
239-
240-
Implement :meth:`Metric.summarize` in new implementations.
241-
"""
242-
raise NotImplementedError() # pragma: no cover

src/lenskit/metrics/_collect.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from lenskit.data import ItemList, ItemListCollection
1818

19-
from ._base import DecomposedMetric, GlobalMetric, ListMetric, Metric, MetricFunction
19+
from ._base import GlobalMetric, ListMetric, Metric, MetricFunction
2020

2121
_log = logging.getLogger(__name__)
2222
K1 = TypeVar("K1", bound=tuple)
@@ -46,11 +46,6 @@ def is_global(self) -> bool:
4646
"Check if this metric is global."
4747
return isinstance(self.metric, GlobalMetric)
4848

49-
@property
50-
def is_decomposed(self) -> bool:
51-
"Check if this metric is decomposed."
52-
return isinstance(self.metric, DecomposedMetric)
53-
5449
def measure_list(self, list: ItemList, test: ItemList) -> Any:
5550
"""Get intermediate measurement data from the metric."""
5651
if isinstance(self.metric, Callable):

src/lenskit/metrics/predict.py

Lines changed: 3 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from lenskit.data.adapt import ITEM_COMPAT_COLUMN, normalize_columns
2323
from lenskit.data.types import AliasedColumn
2424

25-
from ._base import DecomposedMetric, ListMetric, Metric
25+
from ._base import ListMetric, Metric
2626

2727
_log = logging.getLogger(__name__)
2828

@@ -108,7 +108,7 @@ def align_scores(
108108
return pred_s, rate_s
109109

110110

111-
class RMSE(PredictMetric, ListMetric, DecomposedMetric):
111+
class RMSE(PredictMetric, ListMetric):
112112
"""
113113
Compute RMSE (root mean squared error). This is computed as:
114114
@@ -131,36 +131,8 @@ def measure_list(self, predictions: ItemList, test: ItemList | None = None, /) -
131131
err *= err
132132
return np.sqrt(np.mean(err))
133133

134-
@override
135-
def compute_list_data(self, output, test):
136-
ps, ts = self.align_scores(output, test)
137-
err = ps - ts
138-
err *= err
139-
return np.sum(err), len(err)
140-
141-
@override
142-
def extract_list_metric(self, metric):
143-
tot, n = metric
144-
if n > 0:
145-
return np.sqrt(tot / n)
146-
else:
147-
return np.nan
148-
149-
@override
150-
def global_aggregate(self, values):
151-
tot_sqerr = 0.0
152-
tot_n = 0.0
153-
for t, n in values:
154-
tot_sqerr += t
155-
tot_n += n
156-
157-
if tot_n > 0:
158-
return np.sqrt(tot_sqerr / tot_n)
159-
else:
160-
return np.nan
161-
162134

163-
class MAE(PredictMetric, ListMetric, DecomposedMetric):
135+
class MAE(PredictMetric, ListMetric):
164136
"""
165137
Compute MAE (mean absolute error). This is computed as:
166138
@@ -181,30 +153,3 @@ def measure_list(self, predictions: ItemList, test: ItemList | None = None, /) -
181153
ps, ts = self.align_scores(predictions, test)
182154
err = ps - ts
183155
return np.mean(np.abs(err)).item()
184-
185-
@override
186-
def compute_list_data(self, output, test):
187-
ps, ts = self.align_scores(output, test)
188-
err = ps - ts
189-
return np.sum(np.abs(err)), len(err)
190-
191-
@override
192-
def extract_list_metric(self, metric):
193-
tot, n = metric
194-
if n > 0:
195-
return tot / n
196-
else:
197-
return np.nan
198-
199-
@override
200-
def global_aggregate(self, values):
201-
tot_err = 0.0
202-
tot_n = 0.0
203-
for t, n in values:
204-
tot_err += t
205-
tot_n += n
206-
207-
if n > 0:
208-
return tot_err / tot_n
209-
else:
210-
return np.nan

src/lenskit/metrics/ranking/_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88

99
from lenskit.data import ItemList
1010

11-
from .._base import DecomposedMetric, GlobalMetric, ListMetric, Metric
11+
from .._base import ListMetric, Metric
1212

13-
__all__ = ["Metric", "ListMetric", "GlobalMetric", "DecomposedMetric", "RankingMetricBase"]
13+
__all__ = ["Metric", "ListMetric", "RankingMetricBase"]
1414

1515

1616
class RankingMetricBase(Metric):

src/lenskit/metrics/ranking/_gini.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
from lenskit.logging import get_logger
1717
from lenskit.stats import gini
1818

19-
from ._base import DecomposedMetric, RankingMetricBase
19+
from ._base import RankingMetricBase
2020
from ._weighting import GeometricRankWeight, RankWeight
2121

2222
_log = get_logger(__name__)
2323

2424

25-
class GiniBase(DecomposedMetric, RankingMetricBase):
25+
class GiniBase(RankingMetricBase):
2626
"""
2727
Base class for Gini diversity / popularity concentration metrics.
2828
"""
@@ -66,12 +66,12 @@ class ListGini(GiniBase):
6666
"""
6767

6868
@override
69-
def compute_list_data(self, output: ItemList, test):
69+
def measure_list(self, output: ItemList, test):
7070
recs = self.truncate(output)
7171
return recs.ids(format="arrow")
7272

7373
@override
74-
def global_aggregate(self, values: list[pa.Array]):
74+
def summarize(self, values: list[pa.Array] | pa.ChunkedArray, /):
7575
log = _log.bind(metric=self.label, item_count=self.item_count)
7676
log.debug("aggregating for %d lists", len(values))
7777
chunked = pa.chunked_array(values)
@@ -119,13 +119,13 @@ def __init__(
119119
self.weight = weight
120120

121121
@override
122-
def compute_list_data(self, output: ItemList, test):
122+
def measure_list(self, output: ItemList, test):
123123
recs = self.truncate(output)
124124
weights = self.weight.weight(np.arange(1, len(recs) + 1))
125125
return (recs.ids(format="arrow"), pa.array(weights, type=pa.float32()))
126126

127127
@override
128-
def global_aggregate(self, values: list[tuple[pa.Array, pa.FloatArray]]):
128+
def summarize(self, values: list[tuple[pa.Array, pa.FloatArray]]):
129129
log = _log.bind(metric=self.label, item_count=self.item_count)
130130
log.debug("aggregating for %d lists", len(values))
131131
table = pa.Table.from_batches(

tests/eval/test_measurement_collector.py

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from lenskit.basic import PopScorer
1616
from lenskit.data import ItemList, ItemListCollection
1717
from lenskit.metrics import NDCG, Recall
18-
from lenskit.metrics._base import DecomposedMetric, GlobalMetric, ListMetric, Metric
18+
from lenskit.metrics._base import GlobalMetric, ListMetric, Metric
1919
from lenskit.metrics._collect import MeasurementCollector, MetricWrapper
2020
from lenskit.metrics.basic import ListLength
2121
from lenskit.splitting import split_temporal_fraction
@@ -270,28 +270,6 @@ def summarize(self, values):
270270
# metricWrapper properties and summarization
271271

272272

273-
def test_metricwrapper_is_decomposed_property():
274-
class DummyDecomposed(DecomposedMetric):
275-
label = "dummy_decomp"
276-
277-
def compute_list_data(self, recs, test):
278-
return {"a": 1.0}
279-
280-
def global_aggregate(self, values):
281-
return {"mean": 1.0}
282-
283-
def measure_list(self, recs, test):
284-
return {"a": 1.0}
285-
286-
def summarize(self, values):
287-
return {"mean": 1.0}
288-
289-
wrapper = MetricWrapper(DummyDecomposed(), "decomp")
290-
assert wrapper.is_decomposed
291-
wrapper_non = MetricWrapper(ListLength(), "len")
292-
assert not wrapper_non.is_decomposed
293-
294-
295273
def test_measure_metric_with_none_summarize():
296274
"""Test metric that returns None from summarize."""
297275

@@ -347,25 +325,6 @@ def test_full_workflow_integration_improved(ml_ds):
347325
assert 0 <= value <= 1
348326

349327

350-
# test that global metric raises errors for unsupported operations
351-
352-
353-
def test_global_metric_unsupported():
354-
class AnotherGlobalMetric(GlobalMetric):
355-
label = "global"
356-
357-
def measure_run(self, run, test):
358-
return 1.0
359-
360-
metric = AnotherGlobalMetric()
361-
362-
with raises(NotImplementedError, match="Global metrics don't support per-list measurement"):
363-
metric.measure_list(ItemList([1, 2]), ItemList([1]))
364-
365-
with raises(NotImplementedError, match="Global metrics should implement measure_run instead"):
366-
metric.summarize([1, 2, 3])
367-
368-
369328
# test edge cases in Metric.summarize
370329

371330

@@ -390,19 +349,6 @@ def measure_list(self, output, test):
390349
assert result["std"] == 1.0
391350

392351

393-
def test_decomposed_metric_numeric_return():
394-
class TestDecomposedMetric(DecomposedMetric):
395-
def compute_list_data(self, output, test):
396-
return len(output)
397-
398-
def global_aggregate(self, values):
399-
return 5.0
400-
401-
metric = TestDecomposedMetric()
402-
result = metric.summarize([1, 2, 3])
403-
assert result == {"value": 5.0}
404-
405-
406352
def test_empty_intermediate_values():
407353
class TestMetric(Metric):
408354
label = "test"

0 commit comments

Comments
 (0)