Source code for iguanas.rule_monitoring
from __future__ import annotations
import polars as pl
# Columns produced by compute_metrics that are not metrics and should be excluded from comparison.
_NON_METRIC_COLS: frozenset[str] = frozenset(
{
"rule",
"num_rules",
"TP", "FP", "TN", "FN",
"TP_weight", "FP_weight", "TN_weight", "FN_weight",
"total_weight",
}
)
[docs]
def compare_rule_metrics(
ref_metrics: pl.DataFrame,
curr_metrics: pl.DataFrame,
thresholds: dict[str, float] | None = None,
) -> pl.DataFrame:
"""Compare rule metrics between a reference period and a current period.
Takes two :func:`~iguanas.metrics.compute_metrics` outputs and returns the
per-rule delta for every shared metric column, together with a boolean flag
indicating whether the rule has degraded beyond an optional threshold.
Parameters
----------
ref_metrics : pl.DataFrame
Baseline metrics from :func:`~iguanas.metrics.compute_metrics`.
Must contain a ``rule`` column.
curr_metrics : pl.DataFrame
Current-period metrics from :func:`~iguanas.metrics.compute_metrics`.
Must contain a ``rule`` column. Only rules present in both DataFrames
are compared (inner join on ``rule``).
thresholds : dict[str, float] | None, default=None
Maximum allowed drop per metric, e.g. ``{"precision": 0.05}`` flags
rules whose precision fell by more than 5 pp. When ``None``, any
negative delta is flagged.
Returns
-------
pl.DataFrame
One row per rule with columns:
- ``rule``
- ``{metric}_ref`` — metric value in the reference period
- ``{metric}_curr`` — metric value in the current period
- ``{metric}_delta`` — ``curr - ref`` (negative means degradation)
- ``{metric}_degraded`` — ``True`` when the drop exceeds the threshold
Examples
--------
>>> import polars as pl
>>> from iguanas.metrics import compute_metrics
>>> from iguanas.rule_monitoring import compare_rule_metrics
>>> R_ref = pl.DataFrame({"rule_A": [True, False, True]})
>>> y_ref = pl.Series([True, True, True])
>>> R_curr = pl.DataFrame({"rule_A": [True, False, False]})
>>> y_curr = pl.Series([True, True, True])
>>> ref = compute_metrics(R_ref, y_ref)
>>> curr = compute_metrics(R_curr, y_curr)
>>> compare_rule_metrics(ref, curr, thresholds={"precision": 0.1})
"""
shared_metrics = [
c for c in ref_metrics.columns
if c not in _NON_METRIC_COLS and c in curr_metrics.columns
]
ref_renamed = ref_metrics.select(["rule", *shared_metrics]).rename(
{c: f"{c}_ref" for c in shared_metrics}
)
curr_renamed = curr_metrics.select(["rule", *shared_metrics]).rename(
{c: f"{c}_curr" for c in shared_metrics}
)
joined = ref_renamed.join(curr_renamed, on="rule", how="inner")
delta_exprs = []
for metric in shared_metrics:
ref_col = f"{metric}_ref"
curr_col = f"{metric}_curr"
allowed_drop = -abs((thresholds or {}).get(metric, 0.0))
delta_exprs.append((pl.col(curr_col) - pl.col(ref_col)).alias(f"{metric}_delta"))
delta_exprs.append(
((pl.col(curr_col) - pl.col(ref_col)) < allowed_drop).alias(f"{metric}_degraded")
)
return joined.with_columns(delta_exprs)