import re
import polars as pl
# Default shrinkage strength for the m-estimate. Larger values pull the estimate
# of a low-coverage rule further toward the base rate.
M_ESTIMATE_M = 10.0
_COVERAGE_METRICS = frozenset({"lift", "wracc", "laplace", "m_estimate"})
# A rule references each feature as X["col"] / X['col'], once per condition.
_FEATURE_REF_PATTERN = r"""X\[["'][^"']+["']\]"""
[docs]
def count_conditions(rule: str) -> int:
"""Number of atomic conditions in a rule expression.
Complexity is measured in conditions rather than rules: a 3-rule disjunction
with 12 conditions is not simpler than a 5-rule one with 8.
Parameters
----------
rule : str
Rule expression, e.g. ``'(X["a"] > 1) & (X["b"] <= 2)'``.
Returns
-------
int
Condition count. Zero for a string that references no features, which is
what a plain rule *name* such as ``"rule_A"`` will yield.
Examples
--------
>>> count_conditions('(X["a"] > 1) & (X["b"] <= 2)')
2
"""
return len(re.findall(_FEATURE_REF_PATTERN, rule))
[docs]
def count_features(rule: str) -> int:
"""Number of *distinct* features a rule expression references.
Two conditions on the same feature (a range) are easier to read than two
conditions on different features, so this complements :func:`count_conditions`.
Examples
--------
>>> count_features('(X["a"] > 1) & (X["a"] < 5)')
1
"""
return len(set(re.findall(_FEATURE_REF_PATTERN, rule)))
[docs]
def compute_single_metric(
y_pred: pl.Series,
y: pl.Series,
metric: str,
weights: pl.Series | None = None,
) -> float:
"""Compute a single performance metric for one boolean prediction series.
Faster than compute_metrics when only one scalar is needed, because it
skips computing all 25+ derived metrics. Used internally by
combine_rules_beam_search during candidate evaluation.
Parameters
----------
y_pred : pl.Series
Boolean prediction series.
y : pl.Series
Boolean target series.
metric : str
Metric name: "precision", "recall", "accuracy", "mcc", an F-beta score
(f<number>), or one of the coverage-aware rule metrics "lift", "wracc",
"laplace" and "m_estimate".
weights : pl.Series | None, default=None
Optional sample weights. When provided, all counts use weighted sums.
Returns
-------
float
The requested metric value.
"""
y_bool = y.cast(pl.Boolean)
y_pred_bool = y_pred.cast(pl.Boolean)
if weights is not None:
TP = float(weights.filter(y_bool & y_pred_bool).sum())
FP = float(weights.filter(~y_bool & y_pred_bool).sum())
FN = float(weights.filter(y_bool & ~y_pred_bool).sum())
else:
TP = float((y_bool & y_pred_bool).sum())
FP = float((~y_bool & y_pred_bool).sum())
FN = float((y_bool & ~y_pred_bool).sum())
if metric == "precision":
return TP / (TP + FP) if (TP + FP) > 0 else 0.0
if metric == "recall":
return TP / (TP + FN) if (TP + FN) > 0 else 0.0
if metric == "accuracy":
TN = (
float((~y_bool & ~y_pred_bool).sum())
if weights is None
else float(weights.filter(~y_bool & ~y_pred_bool).sum())
)
return (TP + TN) / (TP + TN + FP + FN) if (TP + TN + FP + FN) > 0 else 0.0
if metric == "mcc":
TN = (
float((~y_bool & ~y_pred_bool).sum())
if weights is None
else float(weights.filter(~y_bool & ~y_pred_bool).sum())
)
denom = ((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN)) ** 0.5
return (TP * TN - FP * FN) / denom if denom > 0 else 0.0
if metric in _COVERAGE_METRICS:
total = float(len(y_bool)) if weights is None else float(weights.sum())
covered = TP + FP
positives = TP + FN
if metric == "laplace":
return (TP + 1.0) / (covered + 2.0)
if total <= 0:
return 0.0
base_rate = positives / total
if metric == "m_estimate":
return (TP + M_ESTIMATE_M * base_rate) / (covered + M_ESTIMATE_M)
if metric == "wracc":
return TP / total - (covered * positives) / (total * total)
if covered <= 0 or base_rate <= 0:
return 0.0
return (TP / covered) / base_rate
if metric.startswith("f"):
beta = float(metric[1:])
precision = TP / (TP + FP) if (TP + FP) > 0 else 0.0
recall = TP / (TP + FN) if (TP + FN) > 0 else 0.0
denom = beta**2 * precision + recall
return (1 + beta**2) * precision * recall / denom if denom > 0 else 0.0
raise ValueError(
f"Unsupported metric '{metric}'. Must be 'precision', 'recall', "
f"'accuracy', 'mcc', an F-beta score (f<number>), or one of "
f"{sorted(_COVERAGE_METRICS)}."
)
[docs]
def compute_metrics(
R: pl.Series | pl.DataFrame,
y: pl.Series,
weights: pl.Series | None = None,
betas: list[float] | None = None,
m: float = M_ESTIMATE_M,
) -> pl.DataFrame:
"""Compute comprehensive performance metrics for all rule columns.
Calculates confusion matrix, precision, recall, F-beta scores, and TPVE metrics
for each rule. Optionally computes weighted versions of all metrics.
Parameters
----------
R : pl.DataFrame
DataFrame with boolean columns representing rule predictions. Each column
is a rule that evaluates to True/False for each observation.
y : pl.Series
Boolean target series indicating true labels (True for positive class).
Will be cast to Boolean if not already.
weights : pl.Series | None, default=None
Optional numeric series for weighted metrics computation. If provided,
computes both count-based and weighted versions of all metrics.
betas : list[float], default=[0.25, 0.5, 1, 1.5, 2]
F-beta values to compute. Each value ``b`` produces a column named
``f{b}`` (and ``f{b}_weight`` when *weights* is provided).
m : float, default=10.0
Shrinkage strength for the ``m_estimate`` column. Larger values pull
low-coverage rules further toward the base rate.
Returns
-------
pl.DataFrame
DataFrame with one row per rule containing:
- rule: Rule name (column name from R)
- TP, FP, TN, FN: Confusion matrix counts
- precision, recall, accuracy: Standard classification metrics
- flagged(%): Percentage of total flagged as positive
- good_flagged(%): Percentage of negatives flagged as positive
- f{b} for each b in *betas*: F-beta scores
- lift: precision divided by the base rate
- wracc: weighted relative accuracy, ``coverage * (precision - base_rate)``
- laplace: ``(TP + 1) / (TP + FP + 2)``
- m_estimate: precision shrunk toward the base rate by *m*
- num_rules: Number of individual rules y_pred (1 for single rules)
- num_conditions: Atomic conditions in the rule expression
- num_features: Distinct features the rule expression references
If weights is provided, additional columns with "_weight" suffix:
- TP_weight, FP_weight, TN_weight, FN_weight: Weighted confusion matrix
- total_weight, precision_weight, recall_weight, accuracy_weight: Weighted versions
- f{b}_weight for each b in *betas*: Weighted F-beta scores
Examples
--------
>>> import polars as pl
>>> # Count-based metrics only
>>> metrics_df = compute_metrics(R, y, weights=None)
>>>
>>> # Both count and weighted metrics
>>> metrics_df = compute_metrics(R, y, weights=transaction_amounts)
>>>
>>> # Sort by TPVE3 to find best rules
>>> top_rules = metrics_df.sort("TPVE3", descending=True).head(10)
"""
if betas is None:
betas = [0.25, 0.5, 1, 1.5, 2]
if y.dtype != pl.Boolean:
y = y.cast(pl.Boolean)
if isinstance(R, pl.Series):
R = R.to_frame()
# Compute confusion matrix for all columns
if weights is not None:
# Both count and weighted metrics
metrics_df = pl.DataFrame(
{
"rule": R.columns,
"TP": [(y & R[col]).sum() for col in R.columns],
"FP": [(~y & R[col]).sum() for col in R.columns],
"TN": [(~y & ~R[col]).sum() for col in R.columns],
"FN": [(y & ~R[col]).sum() for col in R.columns],
"TP_weight": [(weights.filter(y & R[col])).sum() for col in R.columns],
"FP_weight": [(weights.filter(~y & R[col])).sum() for col in R.columns],
"TN_weight": [(weights.filter(~y & ~R[col])).sum() for col in R.columns],
"FN_weight": [(weights.filter(y & ~R[col])).sum() for col in R.columns],
}
)
else:
# Only count metrics
metrics_df = pl.DataFrame(
{
"rule": R.columns,
"TP": [(y & R[col]).sum() for col in R.columns],
"FP": [(~y & R[col]).sum() for col in R.columns],
"TN": [(~y & ~R[col]).sum() for col in R.columns],
"FN": [(y & ~R[col]).sum() for col in R.columns],
}
)
# Step 1: Add basic metrics (precision, recall, and accuracy)
metrics_df = metrics_df.with_columns(
[
(pl.col("TP") / (pl.col("TP") + pl.col("FP"))).alias("precision"),
(pl.col("TP") / (pl.col("TP") + pl.col("FN"))).alias("recall"),
(
(pl.col("TP") + pl.col("TN"))
/ (pl.col("TP") + pl.col("FP") + pl.col("TN") + pl.col("FN"))
).alias("accuracy"),
]
)
# Step 2: Build complete list of all derived metrics that depend on precision/recall
expressions = [
(
(pl.col("TP") + pl.col("FP"))
/ (pl.col("TP") + pl.col("FP") + pl.col("TN") + pl.col("FN"))
* 100
).alias("flagged(%)"),
(pl.col("FP") / (pl.col("TN") + pl.col("FP")) * 100).alias("good_flagged(%)"),
*[
(
(1 + b**2)
* pl.col("precision")
* pl.col("recall")
/ (b**2 * pl.col("precision") + pl.col("recall"))
).alias(f"f{b:g}")
for b in betas
],
pl.when(
(pl.col("TP") + pl.col("FP"))
* (pl.col("TP") + pl.col("FN"))
* (pl.col("TN") + pl.col("FP"))
* (pl.col("TN") + pl.col("FN"))
== 0
)
.then(pl.lit(0.0))
.otherwise(
(pl.col("TP") * pl.col("TN") - pl.col("FP") * pl.col("FN")).cast(pl.Float64)
/ (
(pl.col("TP") + pl.col("FP"))
* (pl.col("TP") + pl.col("FN"))
* (pl.col("TN") + pl.col("FP"))
* (pl.col("TN") + pl.col("FN"))
).cast(pl.Float64).sqrt()
)
.alias("mcc"),
# Number of rules
(pl.col("rule").str.count_matches(r"\) \| \(") + 1).alias("num_rules"),
# Complexity. Counted from the rule string, so these are 0 when columns
# carry plain names rather than rule expressions.
pl.col("rule").str.count_matches(_FEATURE_REF_PATTERN).alias("num_conditions"),
pl.col("rule")
.str.extract_all(_FEATURE_REF_PATTERN)
.list.unique()
.list.len()
.alias("num_features"),
]
# Coverage-aware rule quality metrics. Unlike precision these cannot be gamed
# by a rule firing on a handful of rows, and unlike recall they are not
# maximised by flagging everything.
n_expr = (pl.col("TP") + pl.col("FP") + pl.col("TN") + pl.col("FN")).cast(pl.Float64)
covered_expr = (pl.col("TP") + pl.col("FP")).cast(pl.Float64)
positives_expr = (pl.col("TP") + pl.col("FN")).cast(pl.Float64)
tp_expr = pl.col("TP").cast(pl.Float64)
expressions.extend(
[
pl.when((covered_expr <= 0) | (positives_expr <= 0) | (n_expr <= 0))
.then(pl.lit(0.0))
.otherwise((tp_expr / covered_expr) / (positives_expr / n_expr))
.alias("lift"),
pl.when(n_expr <= 0)
.then(pl.lit(0.0))
.otherwise(tp_expr / n_expr - (covered_expr * positives_expr) / (n_expr * n_expr))
.alias("wracc"),
((tp_expr + 1.0) / (covered_expr + 2.0)).alias("laplace"),
pl.when(n_expr <= 0)
.then(pl.lit(0.0))
.otherwise(
(tp_expr + m * (positives_expr / n_expr)) / (covered_expr + m)
)
.alias("m_estimate"),
]
)
if weights is not None:
# First compute total_weight
metrics_df = metrics_df.with_columns(
[
(
pl.col("TP_weight")
+ pl.col("FP_weight")
+ pl.col("TN_weight")
+ pl.col("FN_weight")
).alias("total_weight"),
]
)
# Then compute precision, recall, and accuracy using total_weight
metrics_df = metrics_df.with_columns(
[
(pl.col("TP_weight") / (pl.col("TP_weight") + pl.col("FP_weight"))).alias(
"precision_weight"
),
(pl.col("TP_weight") / (pl.col("TP_weight") + pl.col("FN_weight"))).alias(
"recall_weight"
),
((pl.col("TP_weight") + pl.col("TN_weight")) / pl.col("total_weight")).alias(
"accuracy_weight"
),
]
)
expressions.extend(
[
*[
(
(1 + b**2)
* pl.col("precision_weight")
* pl.col("recall_weight")
/ (b**2 * pl.col("precision_weight") + pl.col("recall_weight"))
).alias(f"f{b:g}_weight")
for b in betas
],
pl.when(
(pl.col("TP_weight") + pl.col("FP_weight"))
* (pl.col("TP_weight") + pl.col("FN_weight"))
* (pl.col("TN_weight") + pl.col("FP_weight"))
* (pl.col("TN_weight") + pl.col("FN_weight"))
== 0
)
.then(pl.lit(0.0))
.otherwise(
(pl.col("TP_weight") * pl.col("TN_weight") - pl.col("FP_weight") * pl.col("FN_weight")).cast(pl.Float64)
/ (
(pl.col("TP_weight") + pl.col("FP_weight"))
* (pl.col("TP_weight") + pl.col("FN_weight"))
* (pl.col("TN_weight") + pl.col("FP_weight"))
* (pl.col("TN_weight") + pl.col("FN_weight"))
).cast(pl.Float64).sqrt()
)
.alias("mcc_weight"),
]
)
n_w = pl.col("total_weight").cast(pl.Float64)
covered_w = (pl.col("TP_weight") + pl.col("FP_weight")).cast(pl.Float64)
positives_w = (pl.col("TP_weight") + pl.col("FN_weight")).cast(pl.Float64)
tp_w = pl.col("TP_weight").cast(pl.Float64)
# laplace/m_estimate smooth *counts*, so they have no weighted analogue.
expressions.extend(
[
pl.when((covered_w <= 0) | (positives_w <= 0) | (n_w <= 0))
.then(pl.lit(0.0))
.otherwise((tp_w / covered_w) / (positives_w / n_w))
.alias("lift_weight"),
pl.when(n_w <= 0)
.then(pl.lit(0.0))
.otherwise(tp_w / n_w - (covered_w * positives_w) / (n_w * n_w))
.alias("wracc_weight"),
]
)
metrics_df = metrics_df.with_columns(expressions)
return metrics_df