Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion diffly/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def main(
list[str],
typer.Option(
help=(
"Metric presets to display per numerical column. Repeatable. "
"Metric presets to display per column. Repeatable. "
f"Available: {', '.join(DEFAULT_METRICS)}."
)
),
Expand Down
29 changes: 17 additions & 12 deletions diffly/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
lazy_len,
make_and_validate_mapping,
)
from .metrics import MetricFn, _make_numeric_metric
from .metrics import Metric, MetricFn, _make_numeric_metric

if TYPE_CHECKING: # pragma: no cover
# NOTE: We cannot import at runtime as we're otherwise running into circular
Expand Down Expand Up @@ -920,7 +920,7 @@ def summary(
right_name: str = Side.RIGHT,
slim: bool = False,
hidden_columns: list[str] | None = None,
metrics: Mapping[str, MetricFn] | None = None,
metrics: Mapping[str, MetricFn | Metric] | None = None,
) -> Summary:
"""Generate a summary of all aspects of the comparison.

Expand Down Expand Up @@ -950,16 +950,18 @@ def summary(
advanced users who are familiar with the summary format.
hidden_columns: Columns for which no values are printed, e.g. because they
contain sensitive information.
metrics: Optional mapping from display label to a metric callable
``(left_expr, right_expr) -> pl.Expr``. Each callable receives two
metrics: Optional mapping from display label to a metric. A value may be a
callable ``(left_expr, right_expr) -> pl.Expr`` or a
:class:`~diffly.metrics.Metric`. Each callable receives two
:class:`polars.Expr` referring to the left and right values of a single
numerical column across all joined rows, and must return a scalar
aggregation expression. See :doc:`/api/metrics` for the full list of
presets and the :data:`~diffly.metrics.MetricFn` type. When ``None``
(default), no metrics are computed; presets are not applied
automatically. Metrics are only computed for numerical columns. Prefer
short labels — the summary has a fixed width and many or long labels
degrade rendering.
column across all joined rows, and must return a scalar aggregation
expression. Bare callables are only computed for numerical columns; wrap
one in a :class:`~diffly.metrics.Metric` with a column selector to target
other column types (e.g. ``Metric(fn, selector=cs.all())``).
See :doc:`/api/metrics` for the full list of presets and the
:data:`~diffly.metrics.MetricFn` type. When ``None`` (default), no metrics
are computed; presets are not applied automatically. Prefer short labels —
the summary has a fixed width and many or long labels degrade rendering.

Returns:
A summary which can be printed or written to a file.
Expand All @@ -976,7 +978,10 @@ def summary(
from .summary import Summary

resolved_metrics = (
{label: _make_numeric_metric(fn) for label, fn in metrics.items()}
{
label: v if isinstance(v, Metric) else _make_numeric_metric(v)
for label, v in metrics.items()
}
if metrics is not None
else None
)
Expand Down
55 changes: 50 additions & 5 deletions diffly/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@

@dataclass(frozen=True)
class Metric:
"""A metric function paired with a column-applicability selector.

Internal only.
"""
"""A metric function paired with a column-applicability selector."""

fn: MetricFn
selector: cs.Selector
Expand Down Expand Up @@ -70,6 +67,53 @@ def mean_relative_deviation(left: pl.Expr, right: pl.Expr) -> pl.Expr:
return ((right - left) / left).abs().mean()


def _percentage_string(
fraction: pl.Expr, *, signed: bool = False, percent_sign: bool = True
) -> pl.Expr:
"""Format a fraction as a percentage string, optionally with an explicit sign."""
pct = (fraction * 100).round(2)
body = pl.format("{}%", pct) if percent_sign else pl.format("{}", pct)
if signed:
return pl.when(pct >= 0).then(pl.format("+{}", body)).otherwise(body)
return body


def _render_change(
old: pl.Expr,
new: pl.Expr,
format_value: Callable[[pl.Expr, bool], pl.Expr],
) -> pl.Expr:
"""Render a change as ``<old> -> <new> (<delta>)``.

``format_value(value, signed)`` formats a value for display; ``old`` and ``new`` are
rendered unsigned and the delta ``new - old`` is rendered signed (with an explicit
``+`` or ``-`` prefix).
"""
return pl.format(
"{} -> {} ({})",
format_value(old, False),
format_value(new, False),
format_value(new - old, True),
)
Comment thread
MoritzPotthoffQC marked this conversation as resolved.


def null_fraction_change(left: pl.Expr, right: pl.Expr) -> pl.Expr:
"""Change in the fraction of null entries, rendered as ``<old> -> <new> (<delta>)``.

``old`` and ``new`` are the null percentages of the left and right side, and
``delta`` is their signed difference (``+`` when the right side has proportionally
more nulls, ``-`` when it has fewer). Unlike the other presets, this applies to
columns of any type.
"""
return _render_change(
left.is_null().mean(),
right.is_null().mean(),
lambda value, signed: _percentage_string(
value, signed=signed, percent_sign=not signed
),
)
Comment thread
MoritzPotthoffQC marked this conversation as resolved.


def quantile(q: float) -> MetricFn:
"""Factory returning a metric that computes the ``q``-quantile of
``right - left``."""
Expand All @@ -82,12 +126,13 @@ def _quantile(left: pl.Expr, right: pl.Expr) -> pl.Expr:
return _quantile


DEFAULT_METRICS: dict[str, MetricFn] = {
DEFAULT_METRICS: dict[str, MetricFn | Metric] = {
"Mean": mean,
"Median": median,
"Min": min,
"Max": max,
"Std": std,
"Mean absolute deviation": mean_absolute_deviation,
"Mean relative deviation": mean_relative_deviation,
"Null%": Metric(fn=null_fraction_change, selector=cs.all()),
}
5 changes: 4 additions & 1 deletion diffly/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -1131,10 +1131,13 @@ def _format_value(value: Any, *, float_format: str | None = None) -> str:
def _format_metric_value(value: Any) -> str:
"""Format a metric value for the column summary.
Blanks out ``None`` and renders floats with ``.4g`` precision.
Blanks out ``None``, renders string values verbatim, and renders floats with ``.4g``
precision.
"""
if value is None:
return ""
if isinstance(value, str):
return _yellow(value)
return _format_value(value, float_format=".4g")
Comment thread
MoritzPotthoffQC marked this conversation as resolved.


Expand Down
22 changes: 13 additions & 9 deletions diffly/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from ._compat import dy
from .comparison import DataFrameComparison, compare_frames
from .metrics import MetricFn
from .metrics import Metric, MetricFn


def assert_collection_equal(
Expand All @@ -40,7 +40,7 @@ def assert_collection_equal(
right_name: str = Side.RIGHT,
slim: bool = False,
hidden_columns: list[str] | None = None,
metrics: Mapping[str, MetricFn] | None = None,
metrics: Mapping[str, MetricFn | Metric] | None = None,
) -> None:
"""Assert that two :mod:`dataframely` collections are equal.

Expand Down Expand Up @@ -85,9 +85,11 @@ def assert_collection_equal(
hidden_columns: Columns for which no values are printed, e.g. because they
contain sensitive information.
metrics: Optional mapping from display label to a metric callable
``(left_expr, right_expr) -> pl.Expr``. See :mod:`diffly.metrics` for
presets. When ``None`` (default), no metrics are computed; presets are
not applied automatically.
``(left_expr, right_expr) -> pl.Expr`` or a :class:`~diffly.metrics.Metric`.
Bare callables are only computed for numerical columns; wrap one in a
:class:`~diffly.metrics.Metric` with a column selector to target other column
types. See :mod:`diffly.metrics` for presets. When ``None`` (default), no
metrics are computed; presets are not applied automatically.

Raises:
AssertionError: If the collections are not equal.
Expand Down Expand Up @@ -174,7 +176,7 @@ def assert_frame_equal(
right_name: str = Side.RIGHT,
slim: bool = False,
hidden_columns: list[str] | None = None,
metrics: Mapping[str, MetricFn] | None = None,
metrics: Mapping[str, MetricFn | Metric] | None = None,
) -> None:
"""Assert that two :mod:`polars` data frames are equal.

Expand Down Expand Up @@ -226,9 +228,11 @@ def assert_frame_equal(
hidden_columns: Columns for which no values are printed, e.g. because they
contain sensitive information.
metrics: Optional mapping from display label to a metric callable
``(left_expr, right_expr) -> pl.Expr``. See :mod:`diffly.metrics` for
presets. When ``None`` (default), no metrics are computed; presets are
not applied automatically.
``(left_expr, right_expr) -> pl.Expr`` or a :class:`~diffly.metrics.Metric`.
Bare callables are only computed for numerical columns; wrap one in a
:class:`~diffly.metrics.Metric` with a column selector to target other column
types. See :mod:`diffly.metrics` for presets. When ``None`` (default), no
metrics are computed; presets are not applied automatically.

Raises:
AssertionError: If the data frames are not equal.
Expand Down
9 changes: 8 additions & 1 deletion docs/api/metrics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ Metrics

.. currentmodule:: diffly.metrics

Metrics are scalar aggregations computed per numerical column when generating a
Metrics are scalar aggregations computed per column when generating a
:meth:`~diffly.comparison.DataFrameComparison.summary`. Pass them via the
``metrics`` argument as a mapping from display label to a :data:`MetricFn`
callable. :mod:`diffly.metrics` ships a set of presets; you can also supply
your own callable ``(left_expr, right_expr) -> pl.Expr``.

A bare callable is only computed for numerical columns. To target other column
types, wrap it in a :class:`Metric` with a column selector, e.g.
``Metric(fn, selector=cs.string())`` or ``selector=cs.all()``.
Comment thread
MoritzPotthoffQC marked this conversation as resolved.

.. autodata:: MetricFn
:no-value:

.. autoclass:: Metric

Presets
=======

Expand All @@ -26,4 +32,5 @@ Presets
std
mean_absolute_deviation
mean_relative_deviation
null_fraction_change
quantile
21 changes: 21 additions & 0 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,27 @@ def test_cli_hidden_columns_alias_warns(tmp_path: Path) -> None:
assert result.exit_code == 0


def test_cli_null_fraction_metric(tmp_path: Path) -> None:
left = pl.DataFrame({"id": [1, 2, 3], "status": ["a", "b", "c"]})
right = pl.DataFrame({"id": [1, 2, 3], "status": ["a", None, "x"]})
left.write_parquet(tmp_path / "left.parquet")
right.write_parquet(tmp_path / "right.parquet")

result = runner.invoke(
app,
[
str(tmp_path / "left.parquet"),
str(tmp_path / "right.parquet"),
"--primary-key",
"id",
"--metric",
"Null%",
],
)
assert result.exit_code == 0
assert "Null%" in result.output


def test_cli_unknown_metric(tmp_path: Path) -> None:
left = pl.DataFrame({"id": [1, 2], "x": [1.0, 2.0]})
right = pl.DataFrame({"id": [1, 2], "x": [1.0, 3.0]})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Diffly Summary ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Primary key: id

Schemas
▔▔▔▔▔▔▔
Schemas match exactly (column count: 3).

Rows
▔▔▔▔
Left count Right count
5 (no change) 5

┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯

Columns
▔▔▔▔▔▔▔
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Column ┃ Match Rate ┃ Mean ┃ Null% ┃ str_len_delta ┃
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ price │ 40.00% │ 0.75 │ 20.0% -> 0.0% (-20.0) │ │
│ status │ 40.00% │ │ 0.0% -> 40.0% (+40.0) │ 0 │
└────────┴────────────┴──────┴───────────────────────┴───────────────┘
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Diffly Summary ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Primary key: id

Schemas
▔▔▔▔▔▔▔
Schemas match exactly (column count: 3).

Rows
▔▔▔▔
Left count Right count
5 (no change) 5

┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯

Columns
▔▔▔▔▔▔▔
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Column ┃ Match Rate ┃ Mean ┃ Null% ┃ str_len_delta ┃
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ price │ 40.00% │ 0.75 │ 20.0% -> 0.0% (-20.0) │ │
│ status │ 40.00% │ │ 0.0% -> 40.0% (+40.0) │ 0 │
└────────┴────────────┴──────┴───────────────────────┴───────────────┘
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Rows
▔▔▔▔
┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯

Columns
▔▔▔▔▔▔▔
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Column ┃ Match Rate ┃ Mean ┃ Null% ┃ str_len_delta ┃
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ price │ 40.00% │ 0.75 │ 20.0% -> 0.0% (-20.0) │ │
│ status │ 40.00% │ │ 0.0% -> 40.0% (+40.0) │ 0 │
└────────┴────────────┴──────┴───────────────────────┴───────────────┘
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Rows
▔▔▔▔
┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯

Columns
▔▔▔▔▔▔▔
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Column ┃ Match Rate ┃ Mean ┃ Null% ┃ str_len_delta ┃
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ price │ 40.00% │ 0.75 │ 20.0% -> 0.0% (-20.0) │ │
│ status │ 40.00% │ │ 0.0% -> 40.0% (+40.0) │ 0 │
└────────┴────────────┴──────┴───────────────────────┴───────────────┘
Loading