Skip to content
Draft
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
4 changes: 2 additions & 2 deletions diffly/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@

from ._compat import typer
from ._utils import ABS_TOL_DEFAULT, ABS_TOL_TEMPORAL_DEFAULT, REL_TOL_DEFAULT
from .metrics import Metric, MetricFn
from .metrics import ChangeMetricFn, Metric
from .metrics.change import DEFAULT_CHANGE_METRICS
from .metrics.data import DEFAULT_DATA_METRICS

app = typer.Typer()

#: All metric presets selectable via ``--metric``, combining the change and data sets.
AVAILABLE_METRICS: dict[str, MetricFn | Metric] = {
AVAILABLE_METRICS: dict[str, ChangeMetricFn | Metric] = {
**DEFAULT_CHANGE_METRICS,
**DEFAULT_DATA_METRICS,
}
Expand Down
47 changes: 30 additions & 17 deletions diffly/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from __future__ import annotations

import datetime as dt
import inspect
import warnings
from collections.abc import Iterable, Mapping, Sequence
from functools import cached_property
from typing import TYPE_CHECKING, Literal, Self, overload
from typing import TYPE_CHECKING, Literal, Self, cast, overload

import polars as pl
from polars.schema import Schema as PolarsSchema
Expand All @@ -25,7 +26,13 @@
lazy_len,
make_and_validate_mapping,
)
from .metrics import Metric, MetricFn, _make_numeric_metric
from .metrics import (
ChangeMetric,
ChangeMetricFn,
DataMetric,
DataMetricFn,
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 +927,7 @@ def summary(
right_name: str = Side.RIGHT,
slim: bool = False,
hidden_columns: list[str] | None = None,
metrics: Mapping[str, MetricFn | Metric] | None = None,
metrics: Mapping[str, ChangeMetricFn | DataMetricFn | Metric] | None = None,
) -> Summary:
"""Generate a summary of all aspects of the comparison.
Expand Down Expand Up @@ -951,16 +958,16 @@ def summary(
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. 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
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 —
:class:`~diffly.metrics.ChangeMetric` (callable
``(left_expr, right_expr) -> pl.Expr`` aggregating over the change), a
:class:`~diffly.metrics.DataMetric` (callable ``(col_expr) -> pl.Expr``
evaluated on each side to describe the data), or a bare callable resolved
by its arity (two arguments → change metric on numerical columns, one
argument → data metric on all columns). To target other column types,
construct the metric explicitly with a column selector
(e.g. ``ChangeMetric(fn, selector=cs.all())``). See :doc:`/api/metrics`
for the full list of presets. 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:
Expand All @@ -977,11 +984,17 @@ def summary(
# NOTE: We're importing here to prevent circular imports
from .summary import Summary

def _resolve(v: ChangeMetricFn | DataMetricFn | Metric) -> Metric:
if isinstance(v, (ChangeMetric, DataMetric)):
return v
# Infer the metric family from the callable's arity: a single-argument
# callable describes one side (data), two arguments describe a change.
if len(inspect.signature(v).parameters) >= 2:
return ChangeMetric(fn=cast(ChangeMetricFn, v))
return DataMetric(fn=cast(DataMetricFn, v))

resolved_metrics = (
{
label: v if isinstance(v, Metric) else _make_numeric_metric(v)
for label, v in metrics.items()
}
{label: _resolve(v) for label, v in metrics.items()}
if metrics is not None
else None
)
Expand Down
13 changes: 12 additions & 1 deletion diffly/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@
from __future__ import annotations

from . import change, data
from ._common import Metric, MetricFn
from ._common import (
ChangeMetric,
ChangeMetricFn,
DataMetric,
DataMetricFn,
Metric,
MetricFn,
)
from .change import (
_make_numeric_metric,
max,
Expand All @@ -34,6 +41,10 @@

__all__ = [
"DEFAULT_METRICS",
"ChangeMetric",
"ChangeMetricFn",
"DataMetric",
"DataMetricFn",
"Metric",
"MetricFn",
"change",
Expand Down
62 changes: 51 additions & 11 deletions diffly/metrics/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,64 @@
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any

import polars as pl
import polars.selectors as cs

ChangeMetricFn = Callable[[pl.Expr, pl.Expr], pl.Expr]
"""A change metric maps ``(left_expr, right_expr)`` to a scalar aggregation expression.

The expressions refer to the left-side and right-side values of a single column across
all joined rows.
"""

DataMetricFn = Callable[[pl.Expr], pl.Expr]
"""A data metric maps a single column expression to a scalar aggregation expression.

It is evaluated on the left and right side separately to describe each dataset on its
own, rather than the change between them.
"""

# Retained for backwards compatibility; a plain metric callable is a change metric.
MetricFn = ChangeMetricFn


@dataclass(frozen=True)
class Metric:
"""A metric function paired with a column-applicability selector."""
class ChangeMetric:
"""A metric quantifying the *change* between the two sides of a comparison.

fn: MetricFn
selector: cs.Selector
``fn`` aggregates over ``right - left`` (e.g. the mean delta) to describe the change
itself. Change metrics are rendered as extra columns in the "Columns" table,
alongside the match rate.
"""

fn: ChangeMetricFn
selector: cs.Selector = field(default_factory=cs.numeric)

MetricFn = Callable[[pl.Expr, pl.Expr], pl.Expr]
"""A metric function maps ``(left_expr, right_expr)`` to a scalar aggregation
expression.

The expressions refer to the left-side and right-side values of a single column across
all joined rows.
"""
@dataclass(frozen=True)
class DataMetric:
"""A metric describing each dataset *individually*.

``fn`` is applied to the left and right side separately (e.g. the fraction of null
entries on each side), characterizing the data rather than the change between the
sides. Data metrics are rendered in a dedicated "Data Inspection" section, showing
the left and right value side by side, followed by their signed delta for numeric
values.

``formatter`` formats a single left/right value for display. ``delta_formatter``
formats the (always non-negative) magnitude of the delta, which is rendered with an
explicit sign; when ``None``, it falls back to ``formatter``. Both fall back to the
default numeric precision when unset.
"""

fn: DataMetricFn
selector: cs.Selector = field(default_factory=cs.all)
formatter: Callable[[Any], str] | None = None
delta_formatter: Callable[[Any], str] | None = None


Metric = ChangeMetric | DataMetric
"""A change or data metric paired with a column-applicability selector."""
10 changes: 5 additions & 5 deletions diffly/metrics/change.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
import polars as pl
import polars.selectors as cs

from ._common import Metric, MetricFn
from ._common import ChangeMetric, ChangeMetricFn


def _make_numeric_metric(fn: MetricFn) -> Metric:
return Metric(fn=fn, selector=cs.numeric())
def _make_numeric_metric(fn: ChangeMetricFn) -> ChangeMetric:
return ChangeMetric(fn=fn, selector=cs.numeric())


def mean(left: pl.Expr, right: pl.Expr) -> pl.Expr:
Expand Down Expand Up @@ -54,7 +54,7 @@ def mean_relative_deviation(left: pl.Expr, right: pl.Expr) -> pl.Expr:
return ((right - left) / left).abs().mean()


def quantile(q: float) -> MetricFn:
def quantile(q: float) -> ChangeMetricFn:
"""Factory returning a metric that computes the ``q``-quantile of
``right - left``."""
if not 0 <= q <= 1:
Expand All @@ -66,7 +66,7 @@ def _quantile(left: pl.Expr, right: pl.Expr) -> pl.Expr:
return _quantile


DEFAULT_CHANGE_METRICS: dict[str, MetricFn] = {
DEFAULT_CHANGE_METRICS: dict[str, ChangeMetricFn] = {
"Mean": mean,
"Median": median,
"Min": min,
Expand Down
66 changes: 13 additions & 53 deletions diffly/metrics/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,67 +9,27 @@

from __future__ import annotations

from collections.abc import Callable

import polars as pl
import polars.selectors as cs

from ._common import Metric, MetricFn
from ._common import DataMetric


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

``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). This metric function can be applied to columns
of any type.
Evaluated on each side separately and rendered as a percentage. This metric can be
applied 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
),
)
return col.is_null().mean()


DEFAULT_DATA_METRICS: dict[str, MetricFn | Metric] = {
"Null%": Metric(fn=null_fraction_change, selector=cs.all()),
DEFAULT_DATA_METRICS: dict[str, DataMetric] = {
"Null%": DataMetric(
fn=null_fraction,
selector=cs.all(),
formatter=lambda value: f"{round(value * 100, 2)}%",
delta_formatter=lambda value: f"{round(value * 100, 2)}",
),
}
"""Preset metrics describing the left and right datasets individually."""


# ------------------------------------------------------------------------------------ #
# UTILITY METHODS #
# ------------------------------------------------------------------------------------ #


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
``+`` prefix for positive values).
"""
return pl.format(
"{} -> {} ({})",
format_value(old, False),
format_value(new, False),
format_value(new - old, True),
)
Loading