Skip to content

Commit 01cef28

Browse files
MoritzPotthoffQCCopilotEgeKaraismailogluQCquant-ranger[bot]MariusMerkleQC
authored
feat: Generalize metrics and add nullability delta metric (#43)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: EgeKaraismailogluQC <128645043+EgeKaraismailogluQC@users.noreply.github.com> Co-authored-by: quant-ranger[bot] <132915763+quant-ranger[bot]@users.noreply.github.com> Co-authored-by: Marius Merkle <marius.merkle@quantco.com> Co-authored-by: Marius Merkle <122545105+MariusMerkleQC@users.noreply.github.com>
1 parent ea4ee01 commit 01cef28

44 files changed

Lines changed: 1208 additions & 56 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎diffly/cli.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@
1212

1313
from ._compat import typer
1414
from ._utils import ABS_TOL_DEFAULT, ABS_TOL_TEMPORAL_DEFAULT, REL_TOL_DEFAULT
15-
from .metrics import DEFAULT_METRICS
15+
from .metrics import Metric, MetricFn
16+
from .metrics.change import DEFAULT_CHANGE_METRICS
17+
from .metrics.data import DEFAULT_DATA_METRICS
1618

1719
app = typer.Typer()
1820

21+
#: All metric presets selectable via ``--metric``, combining the change and data sets.
22+
AVAILABLE_METRICS: dict[str, MetricFn | Metric] = {
23+
**DEFAULT_CHANGE_METRICS,
24+
**DEFAULT_DATA_METRICS,
25+
}
26+
1927

2028
@app.command()
2129
def main(
@@ -139,8 +147,8 @@ def main(
139147
list[str],
140148
typer.Option(
141149
help=(
142-
"Metric presets to display per numerical column. Repeatable. "
143-
f"Available: {', '.join(DEFAULT_METRICS)}."
150+
"Metric presets to display per column. Repeatable. "
151+
f"Available: {', '.join(AVAILABLE_METRICS)}."
144152
)
145153
),
146154
] = [],
@@ -154,11 +162,11 @@ def main(
154162
hidden_column = [*hidden_column, *hidden_columns]
155163

156164
for name in metric:
157-
if name not in DEFAULT_METRICS:
165+
if name not in AVAILABLE_METRICS:
158166
raise typer.BadParameter(
159-
f"Unknown metric: {name!r}. Available: {', '.join(DEFAULT_METRICS)}."
167+
f"Unknown metric: {name!r}. Available: {', '.join(AVAILABLE_METRICS)}."
160168
)
161-
metrics = {name: DEFAULT_METRICS[name] for name in metric}
169+
metrics = {name: AVAILABLE_METRICS[name] for name in metric}
162170

163171
comparison = compare_frames(
164172
pl.scan_parquet(left),

‎diffly/comparison.py‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
lazy_len,
2626
make_and_validate_mapping,
2727
)
28-
from .metrics import MetricFn, _make_numeric_metric
28+
from .metrics import Metric, MetricFn, _make_numeric_metric
2929

3030
if TYPE_CHECKING: # pragma: no cover
3131
# NOTE: We cannot import at runtime as we're otherwise running into circular
@@ -920,7 +920,7 @@ def summary(
920920
right_name: str = Side.RIGHT,
921921
slim: bool = False,
922922
hidden_columns: list[str] | None = None,
923-
metrics: Mapping[str, MetricFn] | None = None,
923+
metrics: Mapping[str, MetricFn | Metric] | None = None,
924924
) -> Summary:
925925
"""Generate a summary of all aspects of the comparison.
926926
@@ -950,16 +950,18 @@ def summary(
950950
advanced users who are familiar with the summary format.
951951
hidden_columns: Columns for which no values are printed, e.g. because they
952952
contain sensitive information.
953-
metrics: Optional mapping from display label to a metric callable
954-
``(left_expr, right_expr) -> pl.Expr``. Each callable receives two
953+
metrics: Optional mapping from display label to a metric. A value may be a
954+
callable ``(left_expr, right_expr) -> pl.Expr`` or a
955+
:class:`~diffly.metrics.Metric`. Each callable receives two
955956
:class:`polars.Expr` referring to the left and right values of a single
956-
numerical column across all joined rows, and must return a scalar
957-
aggregation expression. See :doc:`/api/metrics` for the full list of
958-
presets and the :data:`~diffly.metrics.MetricFn` type. When ``None``
959-
(default), no metrics are computed; presets are not applied
960-
automatically. Metrics are only computed for numerical columns. Prefer
961-
short labels — the summary has a fixed width and many or long labels
962-
degrade rendering.
957+
column across all joined rows, and must return a scalar aggregation
958+
expression. Bare callables are only computed for numerical columns; wrap
959+
one in a :class:`~diffly.metrics.Metric` with a column selector to target
960+
other column types (e.g. ``Metric(fn, selector=cs.all())``).
961+
See :doc:`/api/metrics` for the full list of presets and the
962+
:data:`~diffly.metrics.MetricFn` type. When ``None`` (default), no metrics
963+
are computed; presets are not applied automatically. Prefer short labels —
964+
the summary has a fixed width and many or long labels degrade rendering.
963965
964966
Returns:
965967
A summary which can be printed or written to a file.
@@ -976,7 +978,10 @@ def summary(
976978
from .summary import Summary
977979

978980
resolved_metrics = (
979-
{label: _make_numeric_metric(fn) for label, fn in metrics.items()}
981+
{
982+
label: v if isinstance(v, Metric) else _make_numeric_metric(v)
983+
for label, v in metrics.items()
984+
}
980985
if metrics is not None
981986
else None
982987
)

‎diffly/metrics/__init__.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright (c) QuantCo 2025-2026
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
4+
"""Metrics computed per column when generating a summary.
5+
6+
Two families are provided:
7+
8+
- Metrics in :mod:`~diffly.metrics.change` describe the change between numeric
9+
columns itself by aggregating over ``right - left``.
10+
- Metrics in :mod:`~diffly.metrics.data` describe the left and right datasets
11+
individually, explaining how a change affects the data.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from . import change, data
17+
from ._common import Metric, MetricFn
18+
from .change import (
19+
_make_numeric_metric,
20+
max,
21+
mean,
22+
mean_absolute_deviation,
23+
mean_relative_deviation,
24+
median,
25+
min,
26+
quantile,
27+
std,
28+
)
29+
30+
DEFAULT_METRICS: dict[str, MetricFn | Metric] = {
31+
**change.DEFAULT_CHANGE_METRICS,
32+
}
33+
"""The default preset metrics, consisting of the change default set."""
34+
35+
__all__ = [
36+
"DEFAULT_METRICS",
37+
"Metric",
38+
"MetricFn",
39+
"change",
40+
"data",
41+
"max",
42+
"mean",
43+
"mean_absolute_deviation",
44+
"mean_relative_deviation",
45+
"median",
46+
"min",
47+
"quantile",
48+
"std",
49+
"_make_numeric_metric",
50+
]

‎diffly/metrics/_common.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright (c) QuantCo 2025-2026
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
4+
from __future__ import annotations
5+
6+
from collections.abc import Callable
7+
from dataclasses import dataclass
8+
9+
import polars as pl
10+
import polars.selectors as cs
11+
12+
13+
@dataclass(frozen=True)
14+
class Metric:
15+
"""A metric function paired with a column-applicability selector."""
16+
17+
fn: MetricFn
18+
selector: cs.Selector
19+
20+
21+
MetricFn = Callable[[pl.Expr, pl.Expr], pl.Expr]
22+
"""A metric function maps ``(left_expr, right_expr)`` to a scalar aggregation
23+
expression.
24+
25+
The expressions refer to the left-side and right-side values of a single column across
26+
all joined rows.
27+
"""
Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,17 @@
11
# Copyright (c) QuantCo 2025-2026
22
# SPDX-License-Identifier: BSD-3-Clause
33

4-
from __future__ import annotations
4+
"""Metrics describing the change between numeric columns.
5+
6+
These aggregate over ``right - left`` to characterize the change itself.
7+
"""
58

6-
from collections.abc import Callable
7-
from dataclasses import dataclass
9+
from __future__ import annotations
810

911
import polars as pl
1012
import polars.selectors as cs
1113

12-
13-
@dataclass(frozen=True)
14-
class Metric:
15-
"""A metric function paired with a column-applicability selector.
16-
17-
Internal only.
18-
"""
19-
20-
fn: MetricFn
21-
selector: cs.Selector
22-
23-
24-
MetricFn = Callable[[pl.Expr, pl.Expr], pl.Expr]
25-
"""A metric function maps ``(left_expr, right_expr)`` to a scalar aggregation
26-
expression.
27-
28-
The expressions refer to the left-side and right-side values of a single column across
29-
all joined rows.
30-
"""
14+
from ._common import Metric, MetricFn
3115

3216

3317
def _make_numeric_metric(fn: MetricFn) -> Metric:
@@ -82,7 +66,7 @@ def _quantile(left: pl.Expr, right: pl.Expr) -> pl.Expr:
8266
return _quantile
8367

8468

85-
DEFAULT_METRICS: dict[str, MetricFn] = {
69+
DEFAULT_CHANGE_METRICS: dict[str, MetricFn] = {
8670
"Mean": mean,
8771
"Median": median,
8872
"Min": min,
@@ -91,3 +75,4 @@ def _quantile(left: pl.Expr, right: pl.Expr) -> pl.Expr:
9175
"Mean absolute deviation": mean_absolute_deviation,
9276
"Mean relative deviation": mean_relative_deviation,
9377
}
78+
"""Preset metrics describing the change between numeric columns."""

‎diffly/metrics/data.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright (c) QuantCo 2025-2026
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
4+
"""Metrics describing the left and right datasets individually.
5+
6+
These characterize each side of a change so you can understand how the change affects
7+
the data, rather than describing the change itself.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections.abc import Callable
13+
14+
import polars as pl
15+
import polars.selectors as cs
16+
17+
from ._common import Metric, MetricFn
18+
19+
20+
def null_fraction_change(left: pl.Expr, right: pl.Expr) -> pl.Expr:
21+
"""Change in the fraction of null entries, rendered as ``<old> -> <new> (<delta>)``.
22+
23+
``old`` and ``new`` are the null percentages of the left and right side, and
24+
``delta`` is their signed difference (``+`` when the right side has proportionally
25+
more nulls, ``-`` when it has fewer). This metric function can be applied to columns
26+
of any type.
27+
"""
28+
return _render_change(
29+
left.is_null().mean(),
30+
right.is_null().mean(),
31+
lambda value, signed: _percentage_string(
32+
value, signed=signed, percent_sign=not signed
33+
),
34+
)
35+
36+
37+
DEFAULT_DATA_METRICS: dict[str, MetricFn | Metric] = {
38+
"Null%": Metric(fn=null_fraction_change, selector=cs.all()),
39+
}
40+
"""Preset metrics describing the left and right datasets individually."""
41+
42+
43+
# ------------------------------------------------------------------------------------ #
44+
# UTILITY METHODS #
45+
# ------------------------------------------------------------------------------------ #
46+
47+
48+
def _percentage_string(
49+
fraction: pl.Expr, *, signed: bool = False, percent_sign: bool = True
50+
) -> pl.Expr:
51+
"""Format a fraction as a percentage string, optionally with an explicit sign."""
52+
pct = (fraction * 100).round(2)
53+
body = pl.format("{}%", pct) if percent_sign else pl.format("{}", pct)
54+
if signed:
55+
return pl.when(pct >= 0).then(pl.format("+{}", body)).otherwise(body)
56+
return body
57+
58+
59+
def _render_change(
60+
old: pl.Expr,
61+
new: pl.Expr,
62+
format_value: Callable[[pl.Expr, bool], pl.Expr],
63+
) -> pl.Expr:
64+
"""Render a change as ``<old> -> <new> (<delta>)``.
65+
66+
``format_value(value, signed)`` formats a value for display; ``old`` and ``new`` are
67+
rendered unsigned and the delta ``new - old`` is rendered signed (with an explicit
68+
``+`` prefix for positive values).
69+
"""
70+
return pl.format(
71+
"{} -> {} ({})",
72+
format_value(old, False),
73+
format_value(new, False),
74+
format_value(new - old, True),
75+
)

‎diffly/summary.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1131,10 +1131,13 @@ def _format_value(value: Any, *, float_format: str | None = None) -> str:
11311131
def _format_metric_value(value: Any) -> str:
11321132
"""Format a metric value for the column summary.
11331133
1134-
Blanks out ``None`` and renders floats with ``.4g`` precision.
1134+
Blanks out ``None``, renders string values verbatim, and renders floats with ``.4g``
1135+
precision.
11351136
"""
11361137
if value is None:
11371138
return ""
1139+
if isinstance(value, str):
1140+
return _yellow(value)
11381141
return _format_value(value, float_format=".4g")
11391142

11401143

‎diffly/testing.py‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from ._compat import dy
2121
from .comparison import DataFrameComparison, compare_frames
22-
from .metrics import MetricFn
22+
from .metrics import Metric, MetricFn
2323

2424

2525
def assert_collection_equal(
@@ -40,7 +40,7 @@ def assert_collection_equal(
4040
right_name: str = Side.RIGHT,
4141
slim: bool = False,
4242
hidden_columns: list[str] | None = None,
43-
metrics: Mapping[str, MetricFn] | None = None,
43+
metrics: Mapping[str, MetricFn | Metric] | None = None,
4444
) -> None:
4545
"""Assert that two :mod:`dataframely` collections are equal.
4646
@@ -85,9 +85,11 @@ def assert_collection_equal(
8585
hidden_columns: Columns for which no values are printed, e.g. because they
8686
contain sensitive information.
8787
metrics: Optional mapping from display label to a metric callable
88-
``(left_expr, right_expr) -> pl.Expr``. See :mod:`diffly.metrics` for
89-
presets. When ``None`` (default), no metrics are computed; presets are
90-
not applied automatically.
88+
``(left_expr, right_expr) -> pl.Expr`` or a :class:`~diffly.metrics.Metric`.
89+
Bare callables are only computed for numerical columns; wrap one in a
90+
:class:`~diffly.metrics.Metric` with a column selector to target other column
91+
types. See :mod:`diffly.metrics` for presets. When ``None`` (default), no
92+
metrics are computed; presets are not applied automatically.
9193
9294
Raises:
9395
AssertionError: If the collections are not equal.
@@ -174,7 +176,7 @@ def assert_frame_equal(
174176
right_name: str = Side.RIGHT,
175177
slim: bool = False,
176178
hidden_columns: list[str] | None = None,
177-
metrics: Mapping[str, MetricFn] | None = None,
179+
metrics: Mapping[str, MetricFn | Metric] | None = None,
178180
) -> None:
179181
"""Assert that two :mod:`polars` data frames are equal.
180182
@@ -226,9 +228,11 @@ def assert_frame_equal(
226228
hidden_columns: Columns for which no values are printed, e.g. because they
227229
contain sensitive information.
228230
metrics: Optional mapping from display label to a metric callable
229-
``(left_expr, right_expr) -> pl.Expr``. See :mod:`diffly.metrics` for
230-
presets. When ``None`` (default), no metrics are computed; presets are
231-
not applied automatically.
231+
``(left_expr, right_expr) -> pl.Expr`` or a :class:`~diffly.metrics.Metric`.
232+
Bare callables are only computed for numerical columns; wrap one in a
233+
:class:`~diffly.metrics.Metric` with a column selector to target other column
234+
types. See :mod:`diffly.metrics` for presets. When ``None`` (default), no
235+
metrics are computed; presets are not applied automatically.
232236
233237
Raises:
234238
AssertionError: If the data frames are not equal.

0 commit comments

Comments
 (0)